mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
@@ -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"]
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+275
@@ -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]);
|
||||
}
|
||||
}
|
||||
+610
@@ -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\<id></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.
|
||||
|
||||
+135
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user