diff --git a/rules/analyze/ANALYZE-SEC-005.json b/rules/analyze/ANALYZE-SEC-005.json
new file mode 100644
index 00000000..9e7a79e6
--- /dev/null
+++ b/rules/analyze/ANALYZE-SEC-005.json
@@ -0,0 +1,37 @@
+{
+ "$schema": "../schema/analyze-rule.schema.json",
+ "ruleId": "ANALYZE-SEC-005",
+ "title": "Provisioning Package Detected",
+ "description": "Detects that one or more provisioning packages (PPKG) were present on the device during enrollment. PPKGs can inject local accounts, certificates, Wi-Fi/VPN profiles, apps (MSI/EXE) and scripts before or during Autopilot. They may be legitimate (bulk enrollment, OEM recovery) or a manipulation vector. This rule is ON by default so any PPKG is surfaced in every tenant. To suppress known-good packages, enable the template rule ANALYZE-SEC-006, set your allow-list, then disable this rule.",
+ "severity": "warning",
+ "category": "security",
+ "version": "1.0.0",
+ "enabled": true,
+ "baseConfidence": 80,
+ "conditions": [
+ {
+ "signal": "ppkg_detected",
+ "source": "event_data",
+ "eventType": "provisioning_package_detected",
+ "dataField": "identity",
+ "operator": "exists",
+ "value": "",
+ "required": true
+ }
+ ],
+ "confidenceThreshold": 40,
+ "explanation": "A provisioning package (PPKG) artifact was detected on this device during enrollment.\n\n- **Detected package:** `{{identity}}`\n\nProvisioning packages can apply local accounts, certificates, Wi-Fi/VPN profiles, applications (MSI/EXE) and scripts. They are legitimate in bulk-enrollment and OEM-recovery scenarios, but a PPKG appearing on a device that is expected to be \"pure Autopilot\" can also indicate tampering.\n\nReview the `provisioning_package_scan` event in this session for the full inventory (file names, registry package metadata such as ownerType/installTime, and best-effort content indicators).\n\n**To stop alerting on known-good packages (2-step):** enable the template rule **ANALYZE-SEC-006 (Unexpected Provisioning Package)**, set its allow-list regex to match your approved vendor/recovery packages, then disable this rule. ANALYZE-SEC-006 then only fires for packages that are NOT on your allow-list.",
+ "remediation": [
+ {
+ "title": "Verify the provisioning package is expected",
+ "steps": [
+ "Open the provisioning_package_scan event and review ppkgFiles + packages (packageId, name, ownerType, rank, installTime)",
+ "Confirm whether the package matches a known source: bulk-enrollment PPKG, OEM recovery package, or an internal provisioning workflow",
+ "Cross-check the device model/vendor — OEM recovery PPKGs are common and usually benign",
+ "If the package is unexpected for a pure-Autopilot device, treat it as a potential manipulation and investigate how it was applied (USB, Recovery\\Customizations, image)",
+ "To allow known-good packages going forward, enable ANALYZE-SEC-006, set the allow-list, then disable this rule"
+ ]
+ }
+ ],
+ "tags": ["security", "provisioning-package", "ppkg", "tamper"]
+}
diff --git a/rules/analyze/ANALYZE-SEC-006.json b/rules/analyze/ANALYZE-SEC-006.json
new file mode 100644
index 00000000..47fe0ce0
--- /dev/null
+++ b/rules/analyze/ANALYZE-SEC-006.json
@@ -0,0 +1,54 @@
+{
+ "$schema": "../schema/analyze-rule.schema.json",
+ "ruleId": "ANALYZE-SEC-006",
+ "title": "Unexpected Provisioning Package (Allow-List)",
+ "description": "Template rule. Fires only for provisioning packages (PPKG) whose identity is NOT on your allow-list. Use this to suppress known-good vendor/recovery packages while still catching unexpected ones. This rule is OFF by default: enable it, set your allow-list regex, then disable the always-on rule ANALYZE-SEC-005 so only non-allow-listed packages alert.",
+ "severity": "warning",
+ "category": "security",
+ "version": "1.0.0",
+ "enabled": false,
+ "baseConfidence": 80,
+ "conditions": [
+ {
+ "signal": "ppkg_not_allowlisted",
+ "source": "event_data",
+ "eventType": "provisioning_package_detected",
+ "dataField": "identity",
+ "operator": "not_regex",
+ "value": "Dell Recovery|HP Recovery|Lenovo Recovery",
+ "required": true
+ }
+ ],
+ "confidenceThreshold": 40,
+ "explanation": "A provisioning package (PPKG) was detected whose identity does NOT match your configured allow-list.\n\n- **Unexpected package:** `{{identity}}`\n\nThe `identity` value combines the package name, .ppkg file name, ownerType and packageId, so your allow-list regex can match an approved package by any of these. Packages that DO match your allow-list (e.g. OEM recovery packages) are intentionally ignored by this rule.\n\nIf this package should be allowed, add it to the allow-list regex (the 'value' of the 'ppkg_not_allowlisted' condition). If it is genuinely unexpected on a pure-Autopilot device, treat it as a potential manipulation and investigate how it was applied.",
+ "remediation": [
+ {
+ "title": "Triage the unexpected package",
+ "steps": [
+ "Open the provisioning_package_scan event for the full inventory and the matching provisioning_package_detected event for this package's identity",
+ "Decide: is this a known-good package that should be allow-listed, or genuinely unexpected?",
+ "If known-good: extend the allow-list regex (e.g. add 'Acme Recovery' as 'Dell Recovery|HP Recovery|Acme Recovery')",
+ "If unexpected: investigate the application path (USB media, C:\\Recovery\\Customizations residue, custom image) and the device's chain of custody"
+ ]
+ },
+ {
+ "title": "Activate the allow-list workflow (2-step)",
+ "steps": [
+ "Enable this rule and set the allow-list regex to match your approved vendor/recovery packages (case-insensitive)",
+ "Verify it behaves as expected on a known device",
+ "Disable the always-on rule ANALYZE-SEC-005 so only non-allow-listed packages alert going forward"
+ ]
+ }
+ ],
+ "tags": ["security", "provisioning-package", "ppkg", "allow-list", "template"],
+ "templateVariables": [
+ {
+ "name": "allowed_packages",
+ "label": "Allowed provisioning packages (regex)",
+ "description": "Case-insensitive regex matched against each package's `identity` (name | fileName | ownerType | packageId). Packages matching this pattern are treated as approved and do NOT alert. Combine multiple with '|', e.g. 'Dell Recovery|HP Recovery|CONTOSO-BULK'.",
+ "conditionIndex": 0,
+ "field": "value",
+ "placeholder": "Dell Recovery|HP Recovery|Lenovo Recovery"
+ }
+ ]
+}
diff --git a/rules/dist/analyze-rules.json b/rules/dist/analyze-rules.json
index 4d633351..531b3b78 100644
--- a/rules/dist/analyze-rules.json
+++ b/rules/dist/analyze-rules.json
@@ -1432,6 +1432,106 @@
"ca2023",
"compliance"
]
+ },
+ {
+ "ruleId": "ANALYZE-SEC-005",
+ "title": "Provisioning Package Detected",
+ "description": "Detects that one or more provisioning packages (PPKG) were present on the device during enrollment. PPKGs can inject local accounts, certificates, Wi-Fi/VPN profiles, apps (MSI/EXE) and scripts before or during Autopilot. They may be legitimate (bulk enrollment, OEM recovery) or a manipulation vector. This rule is ON by default so any PPKG is surfaced in every tenant. To suppress known-good packages, enable the template rule ANALYZE-SEC-006, set your allow-list, then disable this rule.",
+ "severity": "warning",
+ "category": "security",
+ "version": "1.0.0",
+ "enabled": true,
+ "baseConfidence": 80,
+ "conditions": [
+ {
+ "signal": "ppkg_detected",
+ "source": "event_data",
+ "eventType": "provisioning_package_detected",
+ "dataField": "identity",
+ "operator": "exists",
+ "value": "",
+ "required": true
+ }
+ ],
+ "confidenceThreshold": 40,
+ "explanation": "A provisioning package (PPKG) artifact was detected on this device during enrollment.\n\n- **Detected package:** `{{identity}}`\n\nProvisioning packages can apply local accounts, certificates, Wi-Fi/VPN profiles, applications (MSI/EXE) and scripts. They are legitimate in bulk-enrollment and OEM-recovery scenarios, but a PPKG appearing on a device that is expected to be \"pure Autopilot\" can also indicate tampering.\n\nReview the `provisioning_package_scan` event in this session for the full inventory (file names, registry package metadata such as ownerType/installTime, and best-effort content indicators).\n\n**To stop alerting on known-good packages (2-step):** enable the template rule **ANALYZE-SEC-006 (Unexpected Provisioning Package)**, set its allow-list regex to match your approved vendor/recovery packages, then disable this rule. ANALYZE-SEC-006 then only fires for packages that are NOT on your allow-list.",
+ "remediation": [
+ {
+ "title": "Verify the provisioning package is expected",
+ "steps": [
+ "Open the provisioning_package_scan event and review ppkgFiles + packages (packageId, name, ownerType, rank, installTime)",
+ "Confirm whether the package matches a known source: bulk-enrollment PPKG, OEM recovery package, or an internal provisioning workflow",
+ "Cross-check the device model/vendor — OEM recovery PPKGs are common and usually benign",
+ "If the package is unexpected for a pure-Autopilot device, treat it as a potential manipulation and investigate how it was applied (USB, Recovery\\Customizations, image)",
+ "To allow known-good packages going forward, enable ANALYZE-SEC-006, set the allow-list, then disable this rule"
+ ]
+ }
+ ],
+ "tags": [
+ "security",
+ "provisioning-package",
+ "ppkg",
+ "tamper"
+ ]
+ },
+ {
+ "ruleId": "ANALYZE-SEC-006",
+ "title": "Unexpected Provisioning Package (Allow-List)",
+ "description": "Template rule. Fires only for provisioning packages (PPKG) whose identity is NOT on your allow-list. Use this to suppress known-good vendor/recovery packages while still catching unexpected ones. This rule is OFF by default: enable it, set your allow-list regex, then disable the always-on rule ANALYZE-SEC-005 so only non-allow-listed packages alert.",
+ "severity": "warning",
+ "category": "security",
+ "version": "1.0.0",
+ "enabled": false,
+ "baseConfidence": 80,
+ "conditions": [
+ {
+ "signal": "ppkg_not_allowlisted",
+ "source": "event_data",
+ "eventType": "provisioning_package_detected",
+ "dataField": "identity",
+ "operator": "not_regex",
+ "value": "Dell Recovery|HP Recovery|Lenovo Recovery",
+ "required": true
+ }
+ ],
+ "confidenceThreshold": 40,
+ "explanation": "A provisioning package (PPKG) was detected whose identity does NOT match your configured allow-list.\n\n- **Unexpected package:** `{{identity}}`\n\nThe `identity` value combines the package name, .ppkg file name, ownerType and packageId, so your allow-list regex can match an approved package by any of these. Packages that DO match your allow-list (e.g. OEM recovery packages) are intentionally ignored by this rule.\n\nIf this package should be allowed, add it to the allow-list regex (the 'value' of the 'ppkg_not_allowlisted' condition). If it is genuinely unexpected on a pure-Autopilot device, treat it as a potential manipulation and investigate how it was applied.",
+ "remediation": [
+ {
+ "title": "Triage the unexpected package",
+ "steps": [
+ "Open the provisioning_package_scan event for the full inventory and the matching provisioning_package_detected event for this package's identity",
+ "Decide: is this a known-good package that should be allow-listed, or genuinely unexpected?",
+ "If known-good: extend the allow-list regex (e.g. add 'Acme Recovery' as 'Dell Recovery|HP Recovery|Acme Recovery')",
+ "If unexpected: investigate the application path (USB media, C:\\Recovery\\Customizations residue, custom image) and the device's chain of custody"
+ ]
+ },
+ {
+ "title": "Activate the allow-list workflow (2-step)",
+ "steps": [
+ "Enable this rule and set the allow-list regex to match your approved vendor/recovery packages (case-insensitive)",
+ "Verify it behaves as expected on a known device",
+ "Disable the always-on rule ANALYZE-SEC-005 so only non-allow-listed packages alert going forward"
+ ]
+ }
+ ],
+ "tags": [
+ "security",
+ "provisioning-package",
+ "ppkg",
+ "allow-list",
+ "template"
+ ],
+ "templateVariables": [
+ {
+ "name": "allowed_packages",
+ "label": "Allowed provisioning packages (regex)",
+ "description": "Case-insensitive regex matched against each package's `identity` (name | fileName | ownerType | packageId). Packages matching this pattern are treated as approved and do NOT alert. Combine multiple with '|', e.g. 'Dell Recovery|HP Recovery|CONTOSO-BULK'.",
+ "conditionIndex": 0,
+ "field": "value",
+ "placeholder": "Dell Recovery|HP Recovery|Lenovo Recovery"
+ }
+ ]
}
]
}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Telemetry/ProvisioningPackageCollectorTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Telemetry/ProvisioningPackageCollectorTests.cs
new file mode 100644
index 00000000..9c029c9f
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Telemetry/ProvisioningPackageCollectorTests.cs
@@ -0,0 +1,275 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Linq;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Provisioning;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Telemetry;
+
+///
+/// Unit tests for the pure aggregation in
+/// (BuildPayload / BuildContentIndicators). The IO probes are environment
+/// dependent and not exercised here; this locks the fact-shaping + classification rules.
+///
+public sealed class ProvisioningPackageCollectorTests
+{
+ [Fact]
+ public void BuildPayload_clean_device_reports_nothing_found()
+ {
+ var findings = new ProvisioningScanFindings();
+
+ var payload = ProvisioningPackageCollector.BuildPayload(findings);
+
+ Assert.False((bool)payload["anyPpkgFound"]);
+ Assert.Equal(0, (int)payload["ppkgFileCount"]);
+ Assert.Equal(0, (int)payload["packageCount"]);
+ Assert.False((bool)payload["recoveryCustomizationsResidue"]);
+ Assert.False((bool)payload["omadmAccountsPresent"]);
+ Assert.Empty((List)payload["scanErrors"]);
+
+ var indicators = (Dictionary)payload["contentIndicators"];
+ Assert.False((bool)indicators["localAccounts"]);
+ Assert.False((bool)indicators["certificates"]);
+ Assert.False((bool)indicators["wifiProfiles"]);
+ Assert.False((bool)indicators["vpnProfiles"]);
+ Assert.False((bool)indicators["appManagement"]);
+ Assert.False((bool)indicators["scripts"]);
+ Assert.True(indicators.ContainsKey("note"));
+ }
+
+ [Fact]
+ public void BuildPayload_ppkg_file_present_sets_anyPpkgFound()
+ {
+ var findings = new ProvisioningScanFindings();
+ findings.Files.Add(new PpkgFileFact
+ {
+ Directory = @"C:\ProgramData\Microsoft\Provisioning",
+ Name = "bulk.ppkg",
+ FullPath = @"C:\ProgramData\Microsoft\Provisioning\bulk.ppkg",
+ SizeBytes = 4096,
+ LastWriteUtc = "2026-06-06T10:00:00.0000000Z",
+ });
+
+ var payload = ProvisioningPackageCollector.BuildPayload(findings);
+
+ Assert.True((bool)payload["anyPpkgFound"]);
+ Assert.Equal(1, (int)payload["ppkgFileCount"]);
+ var files = (List>)payload["ppkgFiles"];
+ Assert.Single(files);
+ Assert.Equal("bulk.ppkg", files[0]["name"]);
+ Assert.Equal(4096L, files[0]["sizeBytes"]);
+ }
+
+ [Fact]
+ public void BuildPayload_package_metadata_and_subkeys_projected()
+ {
+ var findings = new ProvisioningScanFindings();
+ var pkg = new PpkgPackageFact
+ {
+ PackageId = "{abc}",
+ Name = "Contoso Bulk",
+ OwnerType = "ITPro",
+ Rank = "100",
+ InstallTime = "2026-06-06T09:00:00Z",
+ };
+ pkg.SubKeyNames.Add("WiFi");
+ pkg.SubKeyNames.Add("Accounts");
+ findings.Packages.Add(pkg);
+
+ var payload = ProvisioningPackageCollector.BuildPayload(findings);
+
+ Assert.True((bool)payload["anyPpkgFound"]);
+ Assert.Equal(1, (int)payload["packageCount"]);
+ var packages = (List>)payload["packages"];
+ var first = packages.Single();
+ Assert.Equal("{abc}", first["packageId"]);
+ Assert.Equal("Contoso Bulk", first["name"]);
+ Assert.Equal("ITPro", first["ownerType"]);
+ Assert.Equal("100", first["rank"]);
+ var subKeys = (List)first["registrySubKeys"];
+ Assert.Contains("WiFi", subKeys);
+
+ // Content indicators are derived from the package-scoped subkey names.
+ var indicators = (Dictionary)payload["contentIndicators"];
+ Assert.True((bool)indicators["wifiProfiles"]);
+ Assert.True((bool)indicators["localAccounts"]);
+ Assert.False((bool)indicators["vpnProfiles"]);
+ }
+
+ [Fact]
+ public void BuildPayload_omadm_alone_is_context_not_a_ppkg_signal()
+ {
+ // OMADM\Accounts exists on every MDM-enrolled device — must NOT flip anyPpkgFound.
+ var findings = new ProvisioningScanFindings { OmadmAccountsPresent = true, DiagnosticsPresent = true };
+
+ var payload = ProvisioningPackageCollector.BuildPayload(findings);
+
+ Assert.False((bool)payload["anyPpkgFound"]);
+ Assert.True((bool)payload["omadmAccountsPresent"]);
+ Assert.True((bool)payload["provisioningDiagnosticsPresent"]);
+ }
+
+ [Fact]
+ public void BuildPayload_recovery_residue_alone_sets_anyPpkgFound_and_emits_detected_event()
+ {
+ // Non-.ppkg residue in Recovery\Customizations is the gap case: it must still flip
+ // anyPpkgFound AND produce a detected event so ANALYZE-SEC-005 fires on residue-only devices.
+ var findings = new ProvisioningScanFindings { RecoveryCustomizationsDir = @"C:\Recovery\Customizations" };
+ findings.RecoveryCustomizationsFiles.Add("setupcomplete.cmd");
+
+ var payload = ProvisioningPackageCollector.BuildPayload(findings);
+
+ Assert.True((bool)payload["anyPpkgFound"]);
+ Assert.True((bool)payload["recoveryCustomizationsResidue"]);
+ Assert.Contains("setupcomplete.cmd", (List)payload["recoveryCustomizationsFiles"]);
+
+ var detected = ProvisioningPackageCollector.BuildDetectedEvents(findings);
+ var residue = Assert.Single(detected);
+ Assert.Equal("recovery_residue", residue["source"]);
+ Assert.Equal("setupcomplete.cmd", residue["identity"]);
+ }
+
+ [Fact]
+ public void BuildDetectedEvents_skips_ppkg_residue_to_avoid_duplicate_with_file_event()
+ {
+ // A .ppkg in Recovery is captured BOTH as a .ppkg file AND as a residue name; it must
+ // produce exactly one detected event (the file event), not two.
+ var findings = new ProvisioningScanFindings { RecoveryCustomizationsDir = @"C:\Recovery\Customizations" };
+ findings.Files.Add(new PpkgFileFact
+ {
+ Directory = @"C:\Recovery\Customizations",
+ Name = "oem.ppkg",
+ FullPath = @"C:\Recovery\Customizations\oem.ppkg",
+ });
+ findings.RecoveryCustomizationsFiles.Add("oem.ppkg");
+
+ var detected = ProvisioningPackageCollector.BuildDetectedEvents(findings);
+
+ var only = Assert.Single(detected);
+ Assert.Equal("file", only["source"]);
+ }
+
+ [Fact]
+ public void BuildDetectedEvents_emits_ppkg_residue_when_file_enumeration_missed_it()
+ {
+ // Gap case: recursive *.ppkg enumeration failed/truncated, so the .ppkg is NOT in
+ // findings.Files. The residue pass must still emit a detected event (dedup is by captured
+ // path, not by ".ppkg" extension), so anyPpkgFound stays consistent with a firing rule.
+ var findings = new ProvisioningScanFindings { RecoveryCustomizationsDir = @"C:\Recovery\Customizations" };
+ findings.RecoveryCustomizationsFiles.Add("uncaptured.ppkg");
+
+ var detected = ProvisioningPackageCollector.BuildDetectedEvents(findings);
+
+ var only = Assert.Single(detected);
+ Assert.Equal("recovery_residue", only["source"]);
+ Assert.Equal("uncaptured.ppkg", only["identity"]);
+ Assert.True((bool)ProvisioningPackageCollector.BuildPayload(findings)["anyPpkgFound"]);
+ }
+
+ [Fact]
+ public void AnyPpkgFound_is_consistent_with_detected_event_count()
+ {
+ // The invariant the Recovery-residue fix guarantees: anyPpkgFound <=> a detected event exists.
+ var clean = new ProvisioningScanFindings { OmadmAccountsPresent = true, DiagnosticsPresent = true };
+ Assert.False((bool)ProvisioningPackageCollector.BuildPayload(clean)["anyPpkgFound"]);
+ Assert.Empty(ProvisioningPackageCollector.BuildDetectedEvents(clean));
+
+ var residueOnly = new ProvisioningScanFindings { RecoveryCustomizationsDir = @"C:\Recovery\Customizations" };
+ residueOnly.RecoveryCustomizationsFiles.Add("unattend.xml");
+ Assert.True((bool)ProvisioningPackageCollector.BuildPayload(residueOnly)["anyPpkgFound"]);
+ Assert.NotEmpty(ProvisioningPackageCollector.BuildDetectedEvents(residueOnly));
+ }
+
+ [Fact]
+ public void BuildRecoveryResidueEventData_projects_scalar_fields_with_identity()
+ {
+ var data = ProvisioningPackageCollector.BuildRecoveryResidueEventData("unattend.xml", @"C:\Recovery\Customizations");
+
+ Assert.Equal("recovery_residue", data["source"]);
+ Assert.Equal("unattend.xml", data["fileName"]);
+ Assert.Equal("unattend.xml", data["identity"]);
+ Assert.Equal(@"C:\Recovery\Customizations", data["dir"]);
+ }
+
+ [Fact]
+ public void BuildPayload_surfaces_scan_errors_fail_soft()
+ {
+ var findings = new ProvisioningScanFindings();
+ findings.Errors.Add("registry:UnauthorizedAccessException: denied");
+
+ var payload = ProvisioningPackageCollector.BuildPayload(findings);
+
+ var errors = (List)payload["scanErrors"];
+ Assert.Single(errors);
+ Assert.Contains("denied", errors[0]);
+ }
+
+ [Fact]
+ public void BuildIdentity_joins_non_empty_distinct_fields()
+ {
+ var identity = ProvisioningPackageCollector.BuildIdentity(
+ name: "Dell Recovery", fileName: "dell_recovery.ppkg", ownerType: "OEM", packageId: "{guid}");
+
+ Assert.Equal("Dell Recovery | dell_recovery.ppkg | OEM | {guid}", identity);
+ }
+
+ [Fact]
+ public void BuildIdentity_skips_empty_and_dedups_case_insensitive()
+ {
+ var identity = ProvisioningPackageCollector.BuildIdentity(
+ name: "bulk", fileName: "bulk", ownerType: null, packageId: " ");
+
+ Assert.Equal("bulk", identity);
+ }
+
+ [Fact]
+ public void BuildPackageEventData_projects_scalar_fields_with_identity()
+ {
+ var data = ProvisioningPackageCollector.BuildPackageEventData(new PpkgPackageFact
+ {
+ PackageId = "{abc}",
+ Name = "Contoso Bulk",
+ OwnerType = "ITPro",
+ });
+
+ Assert.Equal("registry", data["source"]);
+ Assert.Equal("{abc}", data["packageId"]);
+ Assert.Equal("Contoso Bulk", data["packageName"]);
+ Assert.Equal("Contoso Bulk | ITPro | {abc}", data["identity"]);
+ }
+
+ [Fact]
+ public void BuildFileEventData_projects_scalar_fields_with_identity()
+ {
+ var data = ProvisioningPackageCollector.BuildFileEventData(new PpkgFileFact
+ {
+ Directory = @"C:\Recovery\Customizations",
+ Name = "oem.ppkg",
+ FullPath = @"C:\Recovery\Customizations\oem.ppkg",
+ SizeBytes = 123,
+ });
+
+ Assert.Equal("file", data["source"]);
+ Assert.Equal("oem.ppkg", data["fileName"]);
+ Assert.Equal("oem.ppkg", data["identity"]);
+ }
+
+ [Theory]
+ [InlineData("EnterpriseDesktopAppManagement", "appManagement")]
+ [InlineData("RootCATrustedCertificates", "certificates")]
+ [InlineData("ClientCertificateInstall", "certificates")]
+ [InlineData("VPNv2", "vpnProfiles")]
+ [InlineData("WLAN", "wifiProfiles")]
+ [InlineData("ProvisioningCommands", "scripts")]
+ public void BuildContentIndicators_maps_known_csp_markers(string subKey, string expectedIndicator)
+ {
+ var findings = new ProvisioningScanFindings();
+ var pkg = new PpkgPackageFact { PackageId = "{x}" };
+ pkg.SubKeyNames.Add(subKey);
+ findings.Packages.Add(pkg);
+
+ var indicators = ProvisioningPackageCollector.BuildContentIndicators(findings);
+
+ Assert.True((bool)indicators[expectedIndicator]);
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Provisioning/ProvisioningPackageCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Provisioning/ProvisioningPackageCollector.cs
new file mode 100644
index 00000000..653dc66e
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Provisioning/ProvisioningPackageCollector.cs
@@ -0,0 +1,610 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Orchestration;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.Models;
+using Microsoft.Win32;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Provisioning
+{
+ ///
+ /// Scans the device for provisioning-package (PPKG) traces and emits a single
+ /// event with the raw facts.
+ ///
+ /// PPKGs are containers that inject settings, apps, accounts, WLAN/VPN, certificates and
+ /// scripts before or during enrollment. They can come from legitimate bulk enrollment OR be
+ /// a manipulation vector. This collector reports facts only — file/registry presence plus a
+ /// best-effort, package-scoped content classification. Whether a PPKG is *unexpected* for a
+ /// given tenant is deliberately NOT decided here; that is a backend analyze-rule's job.
+ ///
+ ///
+ /// Trigger: invoked once when the ESP DeviceSetup phase starts (see
+ /// ProvisioningPackageHost), NOT at agent start — the agent may run a long time via
+ /// bootstrap before any PPKG is applied, so an at-start scan would inspect an empty machine.
+ ///
+ ///
+ /// Reliability: file presence + Provisioning\Packages\<id> metadata are
+ /// hard facts. Content indicators are best-effort and derived ONLY from the per-package
+ /// registry subtree — never from Provisioning\Diagnostics, which records every CSP the
+ /// device ever applied (including normal MDM policy) and would yield misleading positives.
+ /// Binary .ppkg decoding (CAB/SyncML) is intentionally out of scope.
+ ///
+ ///
+ public sealed class ProvisioningPackageCollector
+ {
+ internal const string SourceName = "ProvisioningPackageCollector";
+
+ private const string ProvisioningRootSubKey = @"SOFTWARE\Microsoft\Provisioning";
+ private const string PackagesSubKey = @"SOFTWARE\Microsoft\Provisioning\Packages";
+ private const string OmadmAccountsSubKey = @"SOFTWARE\Microsoft\Provisioning\OMADM\Accounts";
+ private const string DiagnosticsSubKey = @"SOFTWARE\Microsoft\Provisioning\Diagnostics";
+
+ // Bound registry recursion so a pathological hive cannot hang the scan.
+ private const int MaxRegistryNodesPerPackage = 512;
+ // Cap collection lists so a flooded directory/hive cannot blow up the event payload.
+ private const int MaxPpkgFiles = 50;
+ private const int MaxPackages = 50;
+ private const int MaxRecoveryFiles = 50;
+
+ private readonly string _sessionId;
+ private readonly string _tenantId;
+ private readonly InformationalEventPost _post;
+ private readonly AgentLogger _logger;
+ private readonly IClock? _clock;
+
+ public ProvisioningPackageCollector(
+ string sessionId,
+ string tenantId,
+ InformationalEventPost post,
+ AgentLogger logger,
+ IClock? clock = null)
+ {
+ _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
+ _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
+ _post = post ?? throw new ArgumentNullException(nameof(post));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _clock = clock;
+ }
+
+ ///
+ /// Runs the full scan and emits exactly one provisioning_package_scan event.
+ /// Fail-soft end to end: any probe error is captured into scanErrors and the event
+ /// is still emitted (so the backend can distinguish "scanned clean" from "never scanned").
+ ///
+ public void Scan()
+ {
+ _logger.Info("ProvisioningPackageCollector: scanning for provisioning-package traces (DeviceSetup trigger).");
+
+ ProvisioningScanFindings findings;
+ try
+ {
+ findings = Collect();
+ }
+ catch (Exception ex)
+ {
+ // Should never happen — Collect() is itself fail-soft — but never let a scan
+ // crash take down the host's Task.Run.
+ findings = new ProvisioningScanFindings();
+ findings.Errors.Add($"collect:{ex.GetType().Name}: {ex.Message}");
+ _logger.Warning($"ProvisioningPackageCollector: Collect threw: {ex.Message}");
+ }
+
+ var payload = BuildPayload(findings);
+ var anyFound = payload.TryGetValue("anyPpkgFound", out var f) && f is bool b && b;
+ var message = anyFound
+ ? $"Provisioning-package traces detected (files={findings.Files.Count}, packages={findings.Packages.Count}, recoveryResidue={findings.RecoveryCustomizationsFiles.Count})"
+ : "No provisioning-package traces detected";
+
+ _post.Emit(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = Constants.EventTypes.ProvisioningPackageScan,
+ Severity = EventSeverity.Info,
+ Source = SourceName,
+ Phase = EnrollmentPhase.Unknown,
+ Message = message,
+ Data = payload,
+ ImmediateUpload = true,
+ });
+
+ // One scalar event per detected artifact so analyze rules can match per package
+ // (incl. the allow-list template's not_regex on `identity`). The aggregate event
+ // above keeps the "scanned clean" / counts baseline. None emitted on a clean device.
+ EmitPerPackageEvents(findings);
+
+ _logger.Info($"ProvisioningPackageCollector: scan complete (anyPpkgFound={anyFound}, packages={findings.Packages.Count}, files={findings.Files.Count}, errors={findings.Errors.Count}).");
+ }
+
+ private void EmitPerPackageEvents(ProvisioningScanFindings findings)
+ {
+ foreach (var data in BuildDetectedEvents(findings))
+ {
+ EmitDetected(data);
+ }
+ }
+
+ ///
+ /// The full set of per-artifact provisioning_package_detected payloads: one per
+ /// registry package, one per .ppkg file, and one per Recovery\Customizations residue file
+ /// that is not already covered by a .ppkg file event. BuildPayload derives
+ /// anyPpkgFound from this same set, so the scan verdict and the events the analyze
+ /// rules match on stay consistent by construction.
+ ///
+ /// Residue dedup is by actual captured path, NOT by ".ppkg" extension: a recovery .ppkg
+ /// whose recursive enumeration failed or was truncated is absent from findings.Files,
+ /// so it is NOT silently dropped here — it still gets a (recovery_residue) detected event.
+ ///
+ ///
+ internal static List> BuildDetectedEvents(ProvisioningScanFindings findings)
+ {
+ var list = new List>();
+
+ foreach (var pkg in findings.Packages)
+ list.Add(BuildPackageEventData(pkg));
+
+ foreach (var file in findings.Files)
+ list.Add(BuildFileEventData(file));
+
+ // Paths already emitted as .ppkg file events — used to dedup recovery residue.
+ var capturedPaths = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var file in findings.Files)
+ {
+ if (!string.IsNullOrEmpty(file.FullPath)) capturedPaths.Add(file.FullPath);
+ }
+
+ foreach (var name in findings.RecoveryCustomizationsFiles)
+ {
+ if (string.IsNullOrEmpty(name)) continue;
+ var fullPath = string.IsNullOrEmpty(findings.RecoveryCustomizationsDir)
+ ? name
+ : Path.Combine(findings.RecoveryCustomizationsDir, name);
+ // Skip only when this exact file is already emitted as a .ppkg file event.
+ if (capturedPaths.Contains(fullPath)) continue;
+ list.Add(BuildRecoveryResidueEventData(name, findings.RecoveryCustomizationsDir));
+ }
+
+ return list;
+ }
+
+ private void EmitDetected(Dictionary data)
+ {
+ var identity = data.TryGetValue("identity", out var id) ? id?.ToString() : null;
+ _post.Emit(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = Constants.EventTypes.ProvisioningPackageDetected,
+ Severity = EventSeverity.Info,
+ Source = SourceName,
+ Phase = EnrollmentPhase.Unknown,
+ Message = string.IsNullOrEmpty(identity)
+ ? "Provisioning package artifact detected"
+ : $"Provisioning package artifact detected: {identity}",
+ Data = data,
+ ImmediateUpload = true,
+ });
+ }
+
+ // ----------------------------------------------------------------------------------
+ // IO probes (fail-soft) — each catch appends to findings.Errors and continues.
+ // ----------------------------------------------------------------------------------
+
+ internal ProvisioningScanFindings Collect()
+ {
+ var findings = new ProvisioningScanFindings();
+ CollectFiles(findings);
+ CollectRegistry(findings);
+ return findings;
+ }
+
+ private void CollectFiles(ProvisioningScanFindings findings)
+ {
+ string windows = SafeFolder(() => Environment.GetFolderPath(Environment.SpecialFolder.Windows), @"C:\Windows");
+ string programData = SafeFolder(() => Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), @"C:\ProgramData");
+ string systemDrive = SafeFolder(() => Path.GetPathRoot(windows) ?? @"C:\", @"C:\");
+
+ var ppkgDirs = new[]
+ {
+ Path.Combine(programData, "Microsoft", "Provisioning"),
+ Path.Combine(windows, "Provisioning"),
+ Path.Combine(systemDrive, "Recovery", "Customizations"),
+ Path.Combine(windows, "System32", "Provisioning"),
+ };
+
+ foreach (var dir in ppkgDirs)
+ {
+ try
+ {
+ if (!Directory.Exists(dir)) continue;
+
+ foreach (var file in Directory.EnumerateFiles(dir, "*.ppkg", SearchOption.AllDirectories))
+ {
+ if (findings.Files.Count >= MaxPpkgFiles) { findings.FilesTruncated = true; break; }
+ try
+ {
+ var info = new FileInfo(file);
+ findings.Files.Add(new PpkgFileFact
+ {
+ Directory = dir,
+ Name = info.Name,
+ FullPath = file,
+ SizeBytes = info.Exists ? info.Length : 0,
+ LastWriteUtc = info.Exists ? info.LastWriteTimeUtc.ToString("o") : null,
+ });
+ }
+ catch (Exception ex)
+ {
+ findings.Errors.Add($"file:{ex.GetType().Name}: {ex.Message}");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ findings.Errors.Add($"dir:{Trunc(dir)}:{ex.GetType().Name}: {ex.Message}");
+ }
+ }
+
+ // Recovery\Customizations residue — ANY leftover file counts, not just .ppkg.
+ var recoveryDir = Path.Combine(systemDrive, "Recovery", "Customizations");
+ findings.RecoveryCustomizationsDir = recoveryDir;
+ try
+ {
+ if (Directory.Exists(recoveryDir))
+ {
+ foreach (var file in Directory.EnumerateFiles(recoveryDir, "*", SearchOption.TopDirectoryOnly))
+ {
+ if (findings.RecoveryCustomizationsFiles.Count >= MaxRecoveryFiles)
+ {
+ findings.RecoveryFilesTruncated = true;
+ break;
+ }
+ findings.RecoveryCustomizationsFiles.Add(Path.GetFileName(file));
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ findings.Errors.Add($"recovery:{ex.GetType().Name}: {ex.Message}");
+ }
+ }
+
+ private void CollectRegistry(ProvisioningScanFindings findings)
+ {
+ // Forced Registry64 view — AnyCPU net48 may resolve to 32-bit and silently read the
+ // stale WOW6432Node mirror (same rationale as TenantIdResolver).
+ try
+ {
+ using (var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
+ {
+ findings.ProvisioningRootPresent = TryKeyExists(hklm, ProvisioningRootSubKey);
+ CollectPackages(hklm, findings);
+ findings.OmadmAccountsPresent = TryHasSubKeys(hklm, OmadmAccountsSubKey);
+ findings.DiagnosticsPresent = TryKeyExists(hklm, DiagnosticsSubKey);
+ }
+ }
+ catch (Exception ex)
+ {
+ findings.Errors.Add($"registry:{ex.GetType().Name}: {ex.Message}");
+ }
+ }
+
+ private void CollectPackages(RegistryKey hklm, ProvisioningScanFindings findings)
+ {
+ try
+ {
+ using (var packagesKey = hklm.OpenSubKey(PackagesSubKey))
+ {
+ if (packagesKey == null) return;
+
+ foreach (var packageId in packagesKey.GetSubKeyNames())
+ {
+ if (findings.Packages.Count >= MaxPackages) { findings.PackagesTruncated = true; break; }
+ try
+ {
+ using (var pkgKey = packagesKey.OpenSubKey(packageId))
+ {
+ if (pkgKey == null) continue;
+
+ var pkg = new PpkgPackageFact
+ {
+ PackageId = packageId,
+ Name = ReadString(pkgKey, "Name") ?? ReadString(pkgKey, "PackageName"),
+ OwnerType = ReadString(pkgKey, "OwnerType"),
+ Rank = ReadString(pkgKey, "Rank"),
+ InstallTime = ReadString(pkgKey, "InstallTime") ?? ReadString(pkgKey, "InstalledTime"),
+ };
+
+ // Package-scoped subtree key-names → feeds the best-effort content
+ // indicators. Scoped to THIS package only (never the global
+ // Diagnostics tree) to avoid MDM-policy false positives.
+ var budget = MaxRegistryNodesPerPackage;
+ CollectSubKeyNames(pkgKey, pkg.SubKeyNames, ref budget);
+
+ findings.Packages.Add(pkg);
+ }
+ }
+ catch (Exception ex)
+ {
+ findings.Errors.Add($"package:{Trunc(packageId)}:{ex.GetType().Name}: {ex.Message}");
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ findings.Errors.Add($"packages:{ex.GetType().Name}: {ex.Message}");
+ }
+ }
+
+ private static void CollectSubKeyNames(RegistryKey key, List sink, ref int budget)
+ {
+ if (budget <= 0) return;
+ string[] names;
+ try { names = key.GetSubKeyNames(); }
+ catch { return; }
+
+ foreach (var name in names)
+ {
+ if (budget <= 0) return;
+ budget--;
+ sink.Add(name);
+ try
+ {
+ using (var child = key.OpenSubKey(name))
+ {
+ if (child != null) CollectSubKeyNames(child, sink, ref budget);
+ }
+ }
+ catch { /* fail-soft: skip unreadable subkey */ }
+ }
+ }
+
+ // ----------------------------------------------------------------------------------
+ // Pure aggregation — no IO, unit-tested directly.
+ // ----------------------------------------------------------------------------------
+
+ internal static Dictionary BuildPayload(ProvisioningScanFindings findings)
+ {
+ // Derive anyPpkgFound from the SAME set that produces per-package
+ // `provisioning_package_detected` events, so the aggregate "found" verdict and the
+ // events the analyze rules listen on can never diverge (Recovery-residue-only gap fix).
+ var anyPpkgFound = BuildDetectedEvents(findings).Count > 0;
+
+ var ppkgFiles = findings.Files
+ .Select(file => new Dictionary
+ {
+ ["dir"] = file.Directory,
+ ["name"] = file.Name,
+ ["path"] = file.FullPath,
+ ["sizeBytes"] = file.SizeBytes,
+ ["lastWriteUtc"] = (object?)file.LastWriteUtc ?? string.Empty,
+ })
+ .ToList();
+
+ var packages = findings.Packages
+ .Select(pkg =>
+ {
+ var d = new Dictionary { ["packageId"] = pkg.PackageId };
+ if (!string.IsNullOrEmpty(pkg.Name)) d["name"] = pkg.Name!;
+ if (!string.IsNullOrEmpty(pkg.OwnerType)) d["ownerType"] = pkg.OwnerType!;
+ if (!string.IsNullOrEmpty(pkg.Rank)) d["rank"] = pkg.Rank!;
+ if (!string.IsNullOrEmpty(pkg.InstallTime)) d["installTime"] = pkg.InstallTime!;
+ if (pkg.SubKeyNames.Count > 0) d["registrySubKeys"] = pkg.SubKeyNames.ToList();
+ return d;
+ })
+ .ToList();
+
+ return new Dictionary
+ {
+ ["anyPpkgFound"] = anyPpkgFound,
+ ["ppkgFileCount"] = findings.Files.Count,
+ ["ppkgFiles"] = ppkgFiles,
+ ["ppkgFilesTruncated"] = findings.FilesTruncated,
+ ["packageCount"] = findings.Packages.Count,
+ ["packages"] = packages,
+ ["packagesTruncated"] = findings.PackagesTruncated,
+ ["recoveryCustomizationsResidue"] = findings.RecoveryCustomizationsFiles.Count > 0,
+ ["recoveryCustomizationsFiles"] = findings.RecoveryCustomizationsFiles.ToList(),
+ ["recoveryFilesTruncated"] = findings.RecoveryFilesTruncated,
+ // Context only — present on virtually every MDM-enrolled device, NOT a PPKG signal.
+ ["omadmAccountsPresent"] = findings.OmadmAccountsPresent,
+ ["provisioningDiagnosticsPresent"] = findings.DiagnosticsPresent,
+ ["contentIndicators"] = BuildContentIndicators(findings),
+ ["scanErrors"] = findings.Errors.ToList(),
+ };
+ }
+
+ ///
+ /// Best-effort content classification derived ONLY from per-package registry subkey names.
+ /// Absence of an indicator does NOT prove the category is absent (a full answer needs
+ /// binary .ppkg decoding, which is out of scope). Always carries a note.
+ ///
+ internal static Dictionary BuildContentIndicators(ProvisioningScanFindings findings)
+ {
+ bool localAccounts = false, certificates = false, wifi = false,
+ vpn = false, appManagement = false, scripts = false;
+
+ foreach (var pkg in findings.Packages)
+ {
+ foreach (var raw in pkg.SubKeyNames)
+ {
+ var n = raw.ToLowerInvariant();
+ if (n.Contains("account")) localAccounts = true;
+ if (n.Contains("certificate") || n.Contains("rootcatrusted") || n.Contains("clientcertificate")) certificates = true;
+ if (n.Contains("wifi") || n.Contains("wlan")) wifi = true;
+ if (n.Contains("vpn")) vpn = true;
+ if (n.Contains("enterprisedesktopappmanagement") || n.Contains("enterprisemodernappmanagement") || n.Contains("msi")) appManagement = true;
+ if (n.Contains("provisioningcommand") || n.Contains("script")) scripts = true;
+ }
+ }
+
+ return new Dictionary
+ {
+ ["localAccounts"] = localAccounts,
+ ["certificates"] = certificates,
+ ["wifiProfiles"] = wifi,
+ ["vpnProfiles"] = vpn,
+ ["appManagement"] = appManagement,
+ ["scripts"] = scripts,
+ ["note"] = "best-effort; derived from per-package registry subkey names only — absence does not prove a category is absent (no binary .ppkg decoding)",
+ };
+ }
+
+ // ----------------------------------------------------------------------------------
+ // Per-package event projection — scalar fields only (rule-engine matchable).
+ // ----------------------------------------------------------------------------------
+
+ ///
+ /// Combined allow-list match key: name | fileName | ownerType | packageId (non-empty,
+ /// distinct). A single allow-list regex (`not_regex` on this field) can match a known
+ /// vendor PPKG by any of its identifiers.
+ ///
+ internal static string BuildIdentity(string? name, string? fileName, string? ownerType, string? packageId)
+ {
+ var parts = new[] { name, fileName, ownerType, packageId }
+ .Where(p => !string.IsNullOrWhiteSpace(p))
+ .Select(p => p!.Trim());
+
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var ordered = new List();
+ foreach (var p in parts)
+ {
+ if (seen.Add(p)) ordered.Add(p);
+ }
+ return string.Join(" | ", ordered);
+ }
+
+ internal static Dictionary BuildPackageEventData(PpkgPackageFact pkg)
+ {
+ var data = new Dictionary(StringComparer.Ordinal)
+ {
+ ["source"] = "registry",
+ ["packageId"] = pkg.PackageId,
+ ["packageName"] = pkg.Name ?? string.Empty,
+ ["ownerType"] = pkg.OwnerType ?? string.Empty,
+ ["rank"] = pkg.Rank ?? string.Empty,
+ ["installTime"] = pkg.InstallTime ?? string.Empty,
+ ["identity"] = BuildIdentity(pkg.Name, fileName: null, ownerType: pkg.OwnerType, packageId: pkg.PackageId),
+ };
+ return data;
+ }
+
+ internal static Dictionary BuildFileEventData(PpkgFileFact file)
+ {
+ var data = new Dictionary(StringComparer.Ordinal)
+ {
+ ["source"] = "file",
+ ["fileName"] = file.Name,
+ ["dir"] = file.Directory,
+ ["path"] = file.FullPath,
+ ["sizeBytes"] = file.SizeBytes,
+ ["identity"] = BuildIdentity(name: null, fileName: file.Name, ownerType: null, packageId: null),
+ };
+ if (!string.IsNullOrEmpty(file.LastWriteUtc)) data["lastWriteUtc"] = file.LastWriteUtc!;
+ return data;
+ }
+
+ internal static Dictionary BuildRecoveryResidueEventData(string fileName, string? dir)
+ {
+ return new Dictionary(StringComparer.Ordinal)
+ {
+ ["source"] = "recovery_residue",
+ ["fileName"] = fileName,
+ ["dir"] = dir ?? string.Empty,
+ ["identity"] = BuildIdentity(name: null, fileName: fileName, ownerType: null, packageId: null),
+ };
+ }
+
+ // ----------------------------------------------------------------------------------
+ // Small fail-soft helpers.
+ // ----------------------------------------------------------------------------------
+
+ private static string SafeFolder(Func read, string fallback)
+ {
+ try
+ {
+ var v = read();
+ return string.IsNullOrEmpty(v) ? fallback : v;
+ }
+ catch { return fallback; }
+ }
+
+ private static bool TryKeyExists(RegistryKey hklm, string subKey)
+ {
+ try { using (var k = hklm.OpenSubKey(subKey)) return k != null; }
+ catch { return false; }
+ }
+
+ private static bool TryHasSubKeys(RegistryKey hklm, string subKey)
+ {
+ try
+ {
+ using (var k = hklm.OpenSubKey(subKey))
+ return k != null && k.GetSubKeyNames().Length > 0;
+ }
+ catch { return false; }
+ }
+
+ private static string? ReadString(RegistryKey key, string valueName)
+ {
+ try
+ {
+ var v = key.GetValue(valueName);
+ if (v == null) return null;
+ var s = v.ToString();
+ return string.IsNullOrEmpty(s) ? null : s;
+ }
+ catch { return null; }
+ }
+
+ private static string Trunc(string s) => s.Length <= 80 ? s : s.Substring(0, 80);
+ }
+
+ // --------------------------------------------------------------------------------------
+ // Raw scan model (internal; surfaced to the test assembly via InternalsVisibleTo).
+ // --------------------------------------------------------------------------------------
+
+ internal sealed class ProvisioningScanFindings
+ {
+ public List Files { get; } = new List();
+ public bool FilesTruncated { get; set; }
+
+ public List Packages { get; } = new List();
+ public bool PackagesTruncated { get; set; }
+
+ public List RecoveryCustomizationsFiles { get; } = new List();
+ public bool RecoveryFilesTruncated { get; set; }
+ public string? RecoveryCustomizationsDir { get; set; }
+
+ public bool ProvisioningRootPresent { get; set; }
+ public bool OmadmAccountsPresent { get; set; }
+ public bool DiagnosticsPresent { get; set; }
+
+ public List Errors { get; } = new List();
+ }
+
+ internal sealed class PpkgFileFact
+ {
+ public string Directory { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public string FullPath { get; set; } = string.Empty;
+ public long SizeBytes { get; set; }
+ public string? LastWriteUtc { get; set; }
+ }
+
+ internal sealed class PpkgPackageFact
+ {
+ public string PackageId { get; set; } = string.Empty;
+ public string? Name { get; set; }
+ public string? OwnerType { get; set; }
+ public string? Rank { get; set; }
+ public string? InstallTime { get; set; }
+ public List SubKeyNames { get; } = new List();
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs
index 528bd7c3..ff462691 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs
@@ -261,6 +261,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
clock: clock,
logger: logger));
+ // Provisioning-package (PPKG) scan — kernel host, NOT scan-at-Start. Arms a one-shot
+ // scan that fires when the ESP DeviceSetup phase begins (or desktop arrival as the
+ // no-ESP / WDP v2 fallback): the agent may run a long time via bootstrap before any
+ // PPKG is applied, so scanning at Start would inspect an empty machine. Emits a single
+ // provisioning_package_scan event with raw facts; a backend rule judges intent.
+ hosts.Add(new ProvisioningPackageHost(
+ sessionId: sessionId,
+ tenantId: tenantId,
+ ingress: ingress,
+ clock: clock,
+ logger: logger));
+
// Dev / test — if --replay-log-dir is set, the tracker reads from the replay folder
// with SimulationMode ON + the configured SpeedFactor instead of tailing the live
// IME log folder. Production agents leave ReplayLogDir empty.
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ProvisioningPackageHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ProvisioningPackageHost.cs
new file mode 100644
index 00000000..e3322912
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ProvisioningPackageHost.cs
@@ -0,0 +1,135 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Provisioning;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Agent.V2.Core.Orchestration
+{
+ ///
+ /// Kernel host that triggers a one-shot provisioning-package (PPKG) scan when the ESP
+ /// DeviceSetup phase starts. Unlike (scan-at-Start), this
+ /// host must NOT scan at agent start: the agent can run a long time via bootstrap before any
+ /// PPKG is applied, so an at-start scan would inspect an empty machine. The PPKG is applied
+ /// around DeviceSetup, so the scan is armed to fire then.
+ ///
+ /// Trigger mechanism follows : subscribe to
+ /// and react to the relevant signal. Fires once:
+ ///
+ ///
+ /// - Primary: with phase
+ /// DeviceSetup (classic / self-deploying / White Glove ESP).
+ /// - Fallback: for no-ESP / WDP v2
+ /// enrollments where EspPhaseChanged never fires — by then any PPKG is applied.
+ ///
+ ///
+ /// The handler runs on the ingress writer thread, so
+ /// the actual registry/filesystem scan is offloaded to and the handler
+ /// stays fast. Bootstrap-only runs that never reach DeviceSetup emit no event by design.
+ ///
+ ///
+ internal sealed class ProvisioningPackageHost : ICollectorHost
+ {
+ public string Name => "ProvisioningPackageCollector";
+
+ private readonly ProvisioningPackageCollector _collector;
+ private readonly AgentLogger _logger;
+
+ // Concrete ingress so we can subscribe to SignalPosted. Null when ingress is a test fake
+ // (non-SignalIngress) — in that case the DeviceSetup trigger is simply inert.
+ private readonly SignalIngress? _observableIngress;
+ private Action?>? _handler;
+
+ private int _scanned;
+ private int _disposed;
+
+ public ProvisioningPackageHost(
+ string sessionId,
+ string tenantId,
+ ISignalIngressSink ingress,
+ IClock clock,
+ AgentLogger logger)
+ {
+ if (ingress == null) throw new ArgumentNullException(nameof(ingress));
+ if (clock == null) throw new ArgumentNullException(nameof(clock));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+
+ var post = new InformationalEventPost(ingress, clock, logger);
+ _collector = new ProvisioningPackageCollector(sessionId, tenantId, post, logger, clock);
+ _observableIngress = ingress as SignalIngress;
+ }
+
+ public void Start()
+ {
+ if (_observableIngress == null)
+ {
+ _logger.Info("ProvisioningPackageHost: ingress is not observable (test fake) — DeviceSetup trigger disabled.");
+ return;
+ }
+
+ if (_handler == null)
+ {
+ _handler = OnSignalPosted;
+ _observableIngress.SignalPosted += _handler;
+ _logger.Info("ProvisioningPackageHost: armed — will scan once on DeviceSetup phase start (or desktop arrival fallback).");
+ }
+ }
+
+ private void OnSignalPosted(DecisionSignalKind kind, IReadOnlyDictionary? payload)
+ {
+ if (Volatile.Read(ref _scanned) != 0) return;
+ if (!IsTrigger(kind, payload)) return;
+
+ // Fire-once: only the first thread that flips the flag runs the scan + unsubscribes.
+ if (Interlocked.Exchange(ref _scanned, 1) != 0) return;
+
+ Unsubscribe();
+
+ var reason = kind == DecisionSignalKind.EspPhaseChanged ? "esp_devicesetup_start" : "desktop_arrived";
+ _logger.Info($"ProvisioningPackageHost: trigger '{reason}' — scheduling PPKG scan on background thread.");
+
+ // Offload registry/FS IO off the ingress writer thread.
+ Task.Run(() =>
+ {
+ try { _collector.Scan(); }
+ catch (Exception ex) { _logger.Warning($"ProvisioningPackageHost: scan threw: {ex.Message}"); }
+ });
+ }
+
+ private static bool IsTrigger(DecisionSignalKind kind, IReadOnlyDictionary? payload)
+ {
+ if (kind == DecisionSignalKind.EspPhaseChanged)
+ {
+ return payload != null
+ && payload.TryGetValue(SignalPayloadKeys.EspPhase, out var phase)
+ && string.Equals(phase, nameof(EnrollmentPhase.DeviceSetup), StringComparison.Ordinal);
+ }
+
+ // Fallback for no-ESP / WDP v2 enrollments where EspPhaseChanged never fires.
+ return kind == DecisionSignalKind.DesktopArrived;
+ }
+
+ public void Stop() => Unsubscribe();
+
+ public void Dispose()
+ {
+ if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
+ Unsubscribe();
+ }
+
+ private void Unsubscribe()
+ {
+ if (_observableIngress != null && _handler != null)
+ {
+ try { _observableIngress.SignalPosted -= _handler; }
+ catch { /* best-effort unsubscribe during shutdown */ }
+ _handler = null;
+ }
+ }
+ }
+}
diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts
index 691604d0..34d961ad 100644
--- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts
+++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts
@@ -139,6 +139,8 @@ export const EVENT_TYPES_CATALOG = {
'local_admin_analysis',
'security_warning',
'security_audit',
+ 'provisioning_package_scan',
+ 'provisioning_package_detected',
],
vulnerability_events: ['software_inventory_analysis', 'vulnerability_report'],
diagnostics_events: ['diagnostics_collecting', 'diagnostics_uploaded', 'diagnostics_upload_failed'],
diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs
index f3206a35..ce26c46d 100644
--- a/src/Shared/AutopilotMonitor.Shared/Constants.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs
@@ -397,6 +397,17 @@ namespace AutopilotMonitor.Shared
public const string IntegrityBypassAnalysis = "integrity_bypass_analysis";
public const string LocalAdminAnalysis = "local_admin_analysis";
public const string SecurityWarning = "security_warning";
+ // Provisioning-package (PPKG) scan — one-shot at DeviceSetup-phase start. Reports raw
+ // facts only (file/registry presence + best-effort content indicators); a backend
+ // analyze-rule judges whether a PPKG is unexpected for the tenant. PPKGs can come from
+ // legitimate bulk enrollment OR be a pre/in-enrollment manipulation vector.
+ public const string ProvisioningPackageScan = "provisioning_package_scan";
+ // One event per detected PPKG artifact (registry package, .ppkg file, OR
+ // Recovery\Customizations residue file; source field distinguishes them), carrying
+ // SCALAR identity fields so analyze rules can match per package (the rule engine
+ // cannot iterate the aggregate scan event's packages[] array). Drives the PPKG
+ // analyze rules incl. the per-tenant allow-list template (not_regex on `identity`).
+ public const string ProvisioningPackageDetected = "provisioning_package_detected";
// Termination / diagnostics / server actions
public const string EnrollmentSummaryShown = "enrollment_summary_shown";
diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts
index 8f1801ee..618c42d6 100644
--- a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts
+++ b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts
@@ -106,6 +106,10 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [
description: "Software inventory snapshot with high-confidence findings." },
{ value: "gather_result", label: "gather_result", category: "diagnostics",
description: "Output of another gather rule (can be chained)." },
+ { value: "provisioning_package_scan", label: "provisioning_package_scan", category: "diagnostics",
+ description: "Provisioning-package (PPKG) scan run once at DeviceSetup-phase start. Reports raw facts only — .ppkg file presence, Provisioning\\Packages registry metadata, Recovery\\Customizations residue, and best-effort content indicators. PPKGs can come from legitimate bulk enrollment or be a manipulation vector." },
+ { value: "provisioning_package_detected", label: "provisioning_package_detected", category: "diagnostics",
+ description: "One event per detected PPKG artifact — a registry package, a .ppkg file, or a Recovery\\Customizations residue file (the `source` field distinguishes them) — with scalar identity fields (packageName, fileName, ownerType, packageId, identity). Drives the PPKG analyze rules incl. the per-tenant allow-list template (ANALYZE-SEC-005/006)." },
];
/** Lookup a single entry by its canonical value (case-insensitive). */