From 2900b352560f1acf2e1466e981ca059aba0dbe89 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sun, 7 Jun 2026 12:06:08 +0200 Subject: [PATCH] feat(agent): provisioning-package (PPKG) detector + analyze rules (V2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V2 PPKG scanner that runs once at DeviceSetup-phase start and reports raw facts only — .ppkg file presence, Provisioning\Packages registry metadata, Recovery\Customizations residue, and best-effort per-package content indicators. PPKGs can come from legitimate bulk enrollment or be a pre/in-enrollment manipulation vector; intent is judged by backend analyze-rules, not the agent. - ProvisioningPackageCollector + ProvisioningPackageHost, wired as a kernel host in DefaultComponentFactory (one-shot at DeviceSetup start, not at agent start). - Events: provisioning_package_scan (aggregate) + provisioning_package_detected (one scalar event per artifact so analyze rules can match per package). - Analyze rules ANALYZE-SEC-005/006 incl. a per-tenant allow-list template. - Event-type catalogs kept in sync (Constants.EventTypes, MCP resource-catalog, rule-builder eventTypes). Co-Authored-By: Claude Opus 4.8 (1M context) --- rules/analyze/ANALYZE-SEC-005.json | 37 ++ rules/analyze/ANALYZE-SEC-006.json | 54 ++ rules/dist/analyze-rules.json | 100 +++ .../ProvisioningPackageCollectorTests.cs | 275 ++++++++ .../ProvisioningPackageCollector.cs | 610 ++++++++++++++++++ .../Orchestration/DefaultComponentFactory.cs | 12 + .../Hosts/ProvisioningPackageHost.cs | 135 ++++ .../src/resource-catalog.ts | 2 + .../AutopilotMonitor.Shared/Constants.cs | 11 + .../app/gather-rules/eventTypes.ts | 4 + 10 files changed, 1240 insertions(+) create mode 100644 rules/analyze/ANALYZE-SEC-005.json create mode 100644 rules/analyze/ANALYZE-SEC-006.json create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Telemetry/ProvisioningPackageCollectorTests.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Provisioning/ProvisioningPackageCollector.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ProvisioningPackageHost.cs 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). */