feat(agent): provisioning-package (PPKG) detector + analyze rules (V2)

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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-07 12:06:08 +02:00
parent 6daee92809
commit 2900b35256
10 changed files with 1240 additions and 0 deletions
+37
View File
@@ -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"]
}
+54
View File
@@ -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"
}
]
}
+100
View File
@@ -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"
}
]
}
]
}
@@ -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;
/// <summary>
/// Unit tests for the pure aggregation in <see cref="ProvisioningPackageCollector"/>
/// (<c>BuildPayload</c> / <c>BuildContentIndicators</c>). The IO probes are environment
/// dependent and not exercised here; this locks the fact-shaping + classification rules.
/// </summary>
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<string>)payload["scanErrors"]);
var indicators = (Dictionary<string, object>)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<Dictionary<string, object>>)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<Dictionary<string, object>>)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<string>)first["registrySubKeys"];
Assert.Contains("WiFi", subKeys);
// Content indicators are derived from the package-scoped subkey names.
var indicators = (Dictionary<string, object>)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<string>)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<string>)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]);
}
}
@@ -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
{
/// <summary>
/// Scans the device for provisioning-package (PPKG) traces and emits a single
/// <see cref="Constants.EventTypes.ProvisioningPackageScan"/> event with the raw facts.
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>Trigger</b>: invoked once when the ESP DeviceSetup phase starts (see
/// <c>ProvisioningPackageHost</c>), 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.
/// </para>
/// <para>
/// <b>Reliability</b>: file presence + <c>Provisioning\Packages\&lt;id&gt;</c> metadata are
/// hard facts. Content indicators are best-effort and derived ONLY from the per-package
/// registry subtree — never from <c>Provisioning\Diagnostics</c>, which records every CSP the
/// device ever applied (including normal MDM policy) and would yield misleading positives.
/// Binary <c>.ppkg</c> decoding (CAB/SyncML) is intentionally out of scope.
/// </para>
/// </summary>
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;
}
/// <summary>
/// Runs the full scan and emits exactly one <c>provisioning_package_scan</c> event.
/// Fail-soft end to end: any probe error is captured into <c>scanErrors</c> and the event
/// is still emitted (so the backend can distinguish "scanned clean" from "never scanned").
/// </summary>
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);
}
}
/// <summary>
/// The full set of per-artifact <c>provisioning_package_detected</c> 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. <c>BuildPayload</c> derives
/// <c>anyPpkgFound</c> from this same set, so the scan verdict and the events the analyze
/// rules match on stay consistent by construction.
/// <para>
/// Residue dedup is by actual captured path, NOT by ".ppkg" extension: a recovery .ppkg
/// whose recursive enumeration failed or was truncated is absent from <c>findings.Files</c>,
/// so it is NOT silently dropped here — it still gets a (recovery_residue) detected event.
/// </para>
/// </summary>
internal static List<Dictionary<string, object>> BuildDetectedEvents(ProvisioningScanFindings findings)
{
var list = new List<Dictionary<string, object>>();
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<string>(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<string, object> 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<string> 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<string, object> 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<string, object>
{
["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<string, object> { ["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<string, object>
{
["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(),
};
}
/// <summary>
/// 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 <c>.ppkg</c> decoding, which is out of scope). Always carries a <c>note</c>.
/// </summary>
internal static Dictionary<string, object> 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<string, object>
{
["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).
// ----------------------------------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
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<string>(StringComparer.OrdinalIgnoreCase);
var ordered = new List<string>();
foreach (var p in parts)
{
if (seen.Add(p)) ordered.Add(p);
}
return string.Join(" | ", ordered);
}
internal static Dictionary<string, object> BuildPackageEventData(PpkgPackageFact pkg)
{
var data = new Dictionary<string, object>(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<string, object> BuildFileEventData(PpkgFileFact file)
{
var data = new Dictionary<string, object>(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<string, object> BuildRecoveryResidueEventData(string fileName, string? dir)
{
return new Dictionary<string, object>(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<string> 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<PpkgFileFact> Files { get; } = new List<PpkgFileFact>();
public bool FilesTruncated { get; set; }
public List<PpkgPackageFact> Packages { get; } = new List<PpkgPackageFact>();
public bool PackagesTruncated { get; set; }
public List<string> RecoveryCustomizationsFiles { get; } = new List<string>();
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<string> Errors { get; } = new List<string>();
}
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<string> SubKeyNames { get; } = new List<string>();
}
}
@@ -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.
@@ -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
{
/// <summary>
/// Kernel host that triggers a one-shot provisioning-package (PPKG) scan when the ESP
/// <c>DeviceSetup</c> phase starts. Unlike <see cref="DeviceInfoHost"/> (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.
/// <para>
/// Trigger mechanism follows <see cref="PeriodicCollectorLifecycleHost"/>: subscribe to
/// <see cref="SignalIngress.SignalPosted"/> and react to the relevant signal. Fires once:
/// </para>
/// <list type="bullet">
/// <item>Primary: <see cref="DecisionSignalKind.EspPhaseChanged"/> with phase
/// <c>DeviceSetup</c> (classic / self-deploying / White Glove ESP).</item>
/// <item>Fallback: <see cref="DecisionSignalKind.DesktopArrived"/> for no-ESP / WDP v2
/// enrollments where <c>EspPhaseChanged</c> never fires — by then any PPKG is applied.</item>
/// </list>
/// <para>
/// The <see cref="SignalIngress.SignalPosted"/> handler runs on the ingress writer thread, so
/// the actual registry/filesystem scan is offloaded to <see cref="Task.Run"/> and the handler
/// stays fast. Bootstrap-only runs that never reach DeviceSetup emit no event by design.
/// </para>
/// </summary>
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<DecisionSignalKind, IReadOnlyDictionary<string, string>?>? _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<string, string>? 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<string, string>? 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;
}
}
}
}
@@ -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'],
@@ -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";
@@ -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). */