diff --git a/.claude/commands b/.claude/commands index 17a6f5be..3aa9ac70 160000 --- a/.claude/commands +++ b/.claude/commands @@ -1 +1 @@ -Subproject commit 17a6f5bec2157a809ea990603b2741e28ea9711b +Subproject commit 3aa9ac70cd82088fcf1530b7aa1261b3bb486371 diff --git a/rules/analyze/ANALYZE-SEC-005.json b/rules/analyze/ANALYZE-SEC-005.json index 9e7a79e6..5598d20d 100644 --- a/rules/analyze/ANALYZE-SEC-005.json +++ b/rules/analyze/ANALYZE-SEC-005.json @@ -1,37 +1,38 @@ { "$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.", + "title": "Unexpected Provisioning Package", + "description": "Detects provisioning packages (PPKG) that are not on the built-in allow-list. The agent emits one or more provisioning_package_scan events whose artifacts[] arrays list every PPKG (registry package, .ppkg file, or Recovery\\Customizations residue; a large set is split across several events). This rule iterates that array across all of them and fires for any artifact whose identity does not match the allow-list regex. The default allow-list covers the Windows OS-inbox packages that ship on every device (Cosa, Power.EnergyEstimationEngine, Power.Settings, SecureStart) so a clean device stays quiet. To allow your own vendor/recovery packages, enable the template rule ANALYZE-SEC-006, extend its allow-list, then disable this rule.", "severity": "warning", "category": "security", - "version": "1.0.0", + "version": "2.0.0", "enabled": true, "baseConfidence": 80, "conditions": [ { - "signal": "ppkg_detected", - "source": "event_data", - "eventType": "provisioning_package_detected", - "dataField": "identity", - "operator": "exists", - "value": "", + "signal": "ppkg_not_allowlisted", + "source": "event_data_array", + "eventType": "provisioning_package_scan", + "dataField": "artifacts", + "itemField": "identity", + "operator": "not_regex", + "value": "^(?:Microsoft\\.Windows\\.Cosa|Power\\.EnergyEstimationEngine|Power\\.Settings|SecureStart\\.Settings)\\b", "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.", + "explanation": "A provisioning package (PPKG) was detected that is NOT on the allow-list.\n\n- **Unexpected package:** `{{identity}}`\n\nThe agent reports every PPKG it finds in `provisioning_package_scan` event(s) (see their `artifacts` array(s) for the full list, plus file metadata and best-effort content indicators; large sets span several events). This rule ignores the Windows OS-inbox packages that ship on every device (Cosa, Power.EnergyEstimationEngine, Power.Settings, SecureStart) and only surfaces the rest.\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 indicate tampering.\n\n**To stop alerting on a known-good package:** enable the template rule **ANALYZE-SEC-006**, add the package to its allow-list regex, then disable this rule. ANALYZE-SEC-006 then only fires for packages outside your extended 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)", + "Open the provisioning_package_scan event and review the artifacts array (identity, source, file path, ownerType/installTime for registry packages)", "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" + "To allow known-good packages going forward, enable ANALYZE-SEC-006, extend the allow-list, then disable this rule" ] } ], - "tags": ["security", "provisioning-package", "ppkg", "tamper"] + "tags": ["security", "provisioning-package", "ppkg", "allow-list", "tamper"] } diff --git a/rules/analyze/ANALYZE-SEC-006.json b/rules/analyze/ANALYZE-SEC-006.json index 47fe0ce0..21391963 100644 --- a/rules/analyze/ANALYZE-SEC-006.json +++ b/rules/analyze/ANALYZE-SEC-006.json @@ -1,42 +1,43 @@ { "$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.", + "title": "Unexpected Provisioning Package (Custom Allow-List)", + "description": "Template version of ANALYZE-SEC-005 with a customizable allow-list. Iterates the provisioning_package_scan artifacts[] array and fires for any PPKG whose identity is not on YOUR allow-list regex. The placeholder ships with the same OS-inbox defaults as ANALYZE-SEC-005 — extend it with your own vendor/recovery packages. This rule is OFF by default: enable it, set your allow-list, then disable ANALYZE-SEC-005 so only packages outside your list alert.", "severity": "warning", "category": "security", - "version": "1.0.0", + "version": "2.0.0", "enabled": false, "baseConfidence": 80, "conditions": [ { "signal": "ppkg_not_allowlisted", - "source": "event_data", - "eventType": "provisioning_package_detected", - "dataField": "identity", + "source": "event_data_array", + "eventType": "provisioning_package_scan", + "dataField": "artifacts", + "itemField": "identity", "operator": "not_regex", - "value": "Dell Recovery|HP Recovery|Lenovo Recovery", + "value": "^(?:Microsoft\\.Windows\\.Cosa|Power\\.EnergyEstimationEngine|Power\\.Settings|SecureStart\\.Settings)\\b", "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.", + "explanation": "A provisioning package (PPKG) was detected whose identity is NOT on your configured allow-list.\n\n- **Unexpected package:** `{{identity}}`\n\nThe `identity` value combines a package's name, .ppkg file name, ownerType and packageId, so your allow-list regex can match an approved package by any of these. The default allow-list already covers the Windows OS-inbox packages (Cosa, Power.EnergyEstimationEngine, Power.Settings, SecureStart) — extend it with your own vendor/recovery packages.\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", + "Open the provisioning_package_scan event and review the artifacts array for this package's identity and source", "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 known-good: the allow-list is anchored (^(?:...)\\b), so insert a new alternative INSIDE the group, before the closing ')' — e.g. ...|SecureStart\\.Settings|Acme\\.Recovery)\\b. Appending after the ) or \\b will not match.", "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)", + "title": "Activate the custom allow-list workflow (2-step)", "steps": [ - "Enable this rule and set the allow-list regex to match your approved vendor/recovery packages (case-insensitive)", + "Enable this rule and extend the allow-list regex by inserting your approved packages as alternatives inside the anchored group (before the closing ')'), 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" + "Disable the built-in rule ANALYZE-SEC-005 so only packages outside your allow-list alert going forward" ] } ], @@ -45,10 +46,10 @@ { "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'.", + "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. The default is anchored (^...) so it matches only at the START of the identity — extend it by adding an alternative inside the group, e.g. add '|Acme\\.Recovery' before the closing ')'.", "conditionIndex": 0, "field": "value", - "placeholder": "Dell Recovery|HP Recovery|Lenovo Recovery" + "placeholder": "^(?:Microsoft\\.Windows\\.Cosa|Power\\.EnergyEstimationEngine|Power\\.Settings|SecureStart\\.Settings)\\b" } ] } diff --git a/rules/analyze/ANALYZE-SEC-007.json b/rules/analyze/ANALYZE-SEC-007.json new file mode 100644 index 00000000..38ab8817 --- /dev/null +++ b/rules/analyze/ANALYZE-SEC-007.json @@ -0,0 +1,36 @@ +{ + "$schema": "../schema/analyze-rule.schema.json", + "ruleId": "ANALYZE-SEC-007", + "title": "Provisioning Package Scan Incomplete", + "description": "Fires when the provisioning-package scan hit a per-source collection cap (too many .ppkg files, registry packages, or Recovery\\Customizations residue files to capture them all). When that happens the allow-list rules (ANALYZE-SEC-005/006) could not evaluate every package, so a non-allow-listed package may have gone unseen. A device with that many provisioning packages is itself unusual and worth a manual look.", + "severity": "warning", + "category": "security", + "version": "1.0.0", + "enabled": true, + "baseConfidence": 80, + "conditions": [ + { + "signal": "ppkg_scan_truncated", + "source": "event_data", + "eventType": "provisioning_package_scan", + "dataField": "scanTruncated", + "operator": "equals", + "value": "true", + "required": true + } + ], + "confidenceThreshold": 40, + "explanation": "The provisioning-package scan was incomplete: the device had more provisioning packages than the agent captures per source, so a per-source cap was hit (see `ppkgFilesTruncated` / `packagesTruncated` / `recoveryFilesTruncated` on the `provisioning_package_scan` event).\n\nBecause not every package was captured, the allow-list rules (ANALYZE-SEC-005/006) may NOT have evaluated all of them — a non-allow-listed package could be present but unseen. A device carrying this many provisioning packages is also unusual on its own and warrants a manual review.", + "remediation": [ + { + "title": "Review the device manually", + "steps": [ + "Open the provisioning_package_scan event and check which counts exceeded the cap (ppkgFileCount / packageCount vs the *Truncated flags)", + "Inspect the device directly: C:\\ProgramData\\Microsoft\\Provisioning, C:\\Windows\\Provisioning, C:\\Recovery\\Customizations, and HKLM\\SOFTWARE\\Microsoft\\Provisioning\\Packages", + "Confirm whether the large number of provisioning packages is expected for this device/model (e.g. an OEM image) or anomalous", + "If anomalous, treat it as a potential manipulation and investigate the device's chain of custody" + ] + } + ], + "tags": ["security", "provisioning-package", "ppkg", "truncated", "coverage"] +} diff --git a/rules/dist/analyze-rules.json b/rules/dist/analyze-rules.json index 0bd9c096..1a9cfb60 100644 --- a/rules/dist/analyze-rules.json +++ b/rules/dist/analyze-rules.json @@ -1657,35 +1657,36 @@ }, { "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.", + "title": "Unexpected Provisioning Package", + "description": "Detects provisioning packages (PPKG) that are not on the built-in allow-list. The agent emits one or more provisioning_package_scan events whose artifacts[] arrays list every PPKG (registry package, .ppkg file, or Recovery\\Customizations residue; a large set is split across several events). This rule iterates that array across all of them and fires for any artifact whose identity does not match the allow-list regex. The default allow-list covers the Windows OS-inbox packages that ship on every device (Cosa, Power.EnergyEstimationEngine, Power.Settings, SecureStart) so a clean device stays quiet. To allow your own vendor/recovery packages, enable the template rule ANALYZE-SEC-006, extend its allow-list, then disable this rule.", "severity": "warning", "category": "security", - "version": "1.0.0", + "version": "2.0.0", "enabled": true, "baseConfidence": 80, "conditions": [ { - "signal": "ppkg_detected", - "source": "event_data", - "eventType": "provisioning_package_detected", - "dataField": "identity", - "operator": "exists", - "value": "", + "signal": "ppkg_not_allowlisted", + "source": "event_data_array", + "eventType": "provisioning_package_scan", + "dataField": "artifacts", + "itemField": "identity", + "operator": "not_regex", + "value": "^(?:Microsoft\\.Windows\\.Cosa|Power\\.EnergyEstimationEngine|Power\\.Settings|SecureStart\\.Settings)\\b", "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.", + "explanation": "A provisioning package (PPKG) was detected that is NOT on the allow-list.\n\n- **Unexpected package:** `{{identity}}`\n\nThe agent reports every PPKG it finds in `provisioning_package_scan` event(s) (see their `artifacts` array(s) for the full list, plus file metadata and best-effort content indicators; large sets span several events). This rule ignores the Windows OS-inbox packages that ship on every device (Cosa, Power.EnergyEstimationEngine, Power.Settings, SecureStart) and only surfaces the rest.\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 indicate tampering.\n\n**To stop alerting on a known-good package:** enable the template rule **ANALYZE-SEC-006**, add the package to its allow-list regex, then disable this rule. ANALYZE-SEC-006 then only fires for packages outside your extended 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)", + "Open the provisioning_package_scan event and review the artifacts array (identity, source, file path, ownerType/installTime for registry packages)", "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" + "To allow known-good packages going forward, enable ANALYZE-SEC-006, extend the allow-list, then disable this rule" ] } ], @@ -1693,47 +1694,49 @@ "security", "provisioning-package", "ppkg", + "allow-list", "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.", + "title": "Unexpected Provisioning Package (Custom Allow-List)", + "description": "Template version of ANALYZE-SEC-005 with a customizable allow-list. Iterates the provisioning_package_scan artifacts[] array and fires for any PPKG whose identity is not on YOUR allow-list regex. The placeholder ships with the same OS-inbox defaults as ANALYZE-SEC-005 — extend it with your own vendor/recovery packages. This rule is OFF by default: enable it, set your allow-list, then disable ANALYZE-SEC-005 so only packages outside your list alert.", "severity": "warning", "category": "security", - "version": "1.0.0", + "version": "2.0.0", "enabled": false, "baseConfidence": 80, "conditions": [ { "signal": "ppkg_not_allowlisted", - "source": "event_data", - "eventType": "provisioning_package_detected", - "dataField": "identity", + "source": "event_data_array", + "eventType": "provisioning_package_scan", + "dataField": "artifacts", + "itemField": "identity", "operator": "not_regex", - "value": "Dell Recovery|HP Recovery|Lenovo Recovery", + "value": "^(?:Microsoft\\.Windows\\.Cosa|Power\\.EnergyEstimationEngine|Power\\.Settings|SecureStart\\.Settings)\\b", "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.", + "explanation": "A provisioning package (PPKG) was detected whose identity is NOT on your configured allow-list.\n\n- **Unexpected package:** `{{identity}}`\n\nThe `identity` value combines a package's name, .ppkg file name, ownerType and packageId, so your allow-list regex can match an approved package by any of these. The default allow-list already covers the Windows OS-inbox packages (Cosa, Power.EnergyEstimationEngine, Power.Settings, SecureStart) — extend it with your own vendor/recovery packages.\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", + "Open the provisioning_package_scan event and review the artifacts array for this package's identity and source", "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 known-good: the allow-list is anchored (^(?:...)\\b), so insert a new alternative INSIDE the group, before the closing ')' — e.g. ...|SecureStart\\.Settings|Acme\\.Recovery)\\b. Appending after the ) or \\b will not match.", "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)", + "title": "Activate the custom allow-list workflow (2-step)", "steps": [ - "Enable this rule and set the allow-list regex to match your approved vendor/recovery packages (case-insensitive)", + "Enable this rule and extend the allow-list regex by inserting your approved packages as alternatives inside the anchored group (before the closing ')'), 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" + "Disable the built-in rule ANALYZE-SEC-005 so only packages outside your allow-list alert going forward" ] } ], @@ -1748,12 +1751,53 @@ { "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'.", + "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. The default is anchored (^...) so it matches only at the START of the identity — extend it by adding an alternative inside the group, e.g. add '|Acme\\.Recovery' before the closing ')'.", "conditionIndex": 0, "field": "value", - "placeholder": "Dell Recovery|HP Recovery|Lenovo Recovery" + "placeholder": "^(?:Microsoft\\.Windows\\.Cosa|Power\\.EnergyEstimationEngine|Power\\.Settings|SecureStart\\.Settings)\\b" } ] + }, + { + "ruleId": "ANALYZE-SEC-007", + "title": "Provisioning Package Scan Incomplete", + "description": "Fires when the provisioning-package scan hit a per-source collection cap (too many .ppkg files, registry packages, or Recovery\\Customizations residue files to capture them all). When that happens the allow-list rules (ANALYZE-SEC-005/006) could not evaluate every package, so a non-allow-listed package may have gone unseen. A device with that many provisioning packages is itself unusual and worth a manual look.", + "severity": "warning", + "category": "security", + "version": "1.0.0", + "enabled": true, + "baseConfidence": 80, + "conditions": [ + { + "signal": "ppkg_scan_truncated", + "source": "event_data", + "eventType": "provisioning_package_scan", + "dataField": "scanTruncated", + "operator": "equals", + "value": "true", + "required": true + } + ], + "confidenceThreshold": 40, + "explanation": "The provisioning-package scan was incomplete: the device had more provisioning packages than the agent captures per source, so a per-source cap was hit (see `ppkgFilesTruncated` / `packagesTruncated` / `recoveryFilesTruncated` on the `provisioning_package_scan` event).\n\nBecause not every package was captured, the allow-list rules (ANALYZE-SEC-005/006) may NOT have evaluated all of them — a non-allow-listed package could be present but unseen. A device carrying this many provisioning packages is also unusual on its own and warrants a manual review.", + "remediation": [ + { + "title": "Review the device manually", + "steps": [ + "Open the provisioning_package_scan event and check which counts exceeded the cap (ppkgFileCount / packageCount vs the *Truncated flags)", + "Inspect the device directly: C:\\ProgramData\\Microsoft\\Provisioning, C:\\Windows\\Provisioning, C:\\Recovery\\Customizations, and HKLM\\SOFTWARE\\Microsoft\\Provisioning\\Packages", + "Confirm whether the large number of provisioning packages is expected for this device/model (e.g. an OEM image) or anomalous", + "If anomalous, treat it as a potential manipulation and investigate the device's chain of custody" + ] + } + ], + "tags": [ + "security", + "provisioning-package", + "ppkg", + "truncated", + "coverage" + ] } ] } diff --git a/rules/schema/analyze-rule.schema.json b/rules/schema/analyze-rule.schema.json index 7c2b3ebf..65199558 100644 --- a/rules/schema/analyze-rule.schema.json +++ b/rules/schema/analyze-rule.schema.json @@ -159,7 +159,7 @@ "source": { "type": "string", "description": "Source of the signal.", - "enum": ["event_type", "event_data", "phase_duration", "event_count", "app_install_duration", "event_correlation"] + "enum": ["event_type", "event_data", "event_data_array", "phase_duration", "event_count", "app_install_duration", "event_correlation"] }, "eventType": { "type": "string", @@ -167,7 +167,11 @@ }, "dataField": { "type": "string", - "description": "Data field to match on. Uses dot notation for nested fields (e.g., \"data.errorCode\")." + "description": "Data field to match on. Uses dot notation for nested fields (e.g., \"data.errorCode\"). For event_data_array: the array field to iterate (e.g., \"artifacts\")." + }, + "itemField": { + "type": "string", + "description": "For event_data_array only: the sub-field on each array element to test with operator/value (e.g., \"identity\"). Empty = treat each element as a scalar. The condition matches when ANY element satisfies the operator." }, "operator": { "type": "string", diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Telemetry/ProvisioningPackageCollectorTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Telemetry/ProvisioningPackageCollectorTests.cs index 9c029c9f..c96d3aa9 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Telemetry/ProvisioningPackageCollectorTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Telemetry/ProvisioningPackageCollectorTests.cs @@ -37,6 +37,39 @@ public sealed class ProvisioningPackageCollectorTests Assert.True(indicators.ContainsKey("note")); } + [Fact] + public void BuildPayload_embeds_artifacts_array_consistent_with_anyPpkgFound() + { + // One aggregate event carries all PPKGs as artifacts[] (no per-package events) — the rules + // iterate this array. artifactCount + anyPpkgFound must agree with the array length. + var findings = new ProvisioningScanFindings(); + findings.Files.Add(new PpkgFileFact + { + Directory = @"C:\WINDOWS\Provisioning", + Name = "Power.Settings.Sleep.ppkg", + FullPath = @"C:\WINDOWS\Provisioning\Packages\Power.Settings.Sleep.ppkg", + }); + + var payload = ProvisioningPackageCollector.BuildPayload(findings); + + Assert.True((bool)payload["anyPpkgFound"]); + Assert.Equal(1, (int)payload["artifactCount"]); + var artifacts = (List>)payload["artifacts"]; + var only = Assert.Single(artifacts); + Assert.Equal("Power.Settings.Sleep.ppkg", only["identity"]); + Assert.Equal("file", only["source"]); + } + + [Fact] + public void BuildPayload_clean_device_has_empty_artifacts() + { + var payload = ProvisioningPackageCollector.BuildPayload(new ProvisioningScanFindings()); + + Assert.False((bool)payload["anyPpkgFound"]); + Assert.Equal(0, (int)payload["artifactCount"]); + Assert.Empty((List>)payload["artifacts"]); + } + [Fact] public void BuildPayload_ppkg_file_present_sets_anyPpkgFound() { @@ -54,14 +87,17 @@ public sealed class ProvisioningPackageCollectorTests Assert.True((bool)payload["anyPpkgFound"]); Assert.Equal(1, (int)payload["ppkgFileCount"]); - var files = (List>)payload["ppkgFiles"]; - Assert.Single(files); - Assert.Equal("bulk.ppkg", files[0]["name"]); - Assert.Equal(4096L, files[0]["sizeBytes"]); + // Detail lives ONLY in the unified artifacts array (no duplicate ppkgFiles array). + Assert.False(payload.ContainsKey("ppkgFiles")); + var artifacts = (List>)payload["artifacts"]; + var file = Assert.Single(artifacts); + Assert.Equal("file", file["source"]); + Assert.Equal("bulk.ppkg", file["identity"]); + Assert.Equal(4096L, file["sizeBytes"]); } [Fact] - public void BuildPayload_package_metadata_and_subkeys_projected() + public void BuildPayload_package_metadata_projected_into_artifacts_subkeys_not_on_wire() { var findings = new ProvisioningScanFindings(); var pkg = new PpkgPackageFact @@ -80,16 +116,18 @@ public sealed class ProvisioningPackageCollectorTests Assert.True((bool)payload["anyPpkgFound"]); Assert.Equal(1, (int)payload["packageCount"]); - var packages = (List>)payload["packages"]; - var first = packages.Single(); - Assert.Equal("{abc}", first["packageId"]); - Assert.Equal("Contoso Bulk", first["name"]); - Assert.Equal("ITPro", first["ownerType"]); - Assert.Equal("100", first["rank"]); - var subKeys = (List)first["registrySubKeys"]; - Assert.Contains("WiFi", subKeys); + Assert.False(payload.ContainsKey("packages")); + var artifacts = (List>)payload["artifacts"]; + var first = Assert.Single(artifacts); + Assert.Equal("registry", first["source"]); + // identity carries name | ownerType | packageId (no redundant scalar copies on the wire). + Assert.Equal("Contoso Bulk | ITPro | {abc}", first["identity"]); + Assert.False(first.ContainsKey("packageId")); + Assert.False(first.ContainsKey("packageName")); + // Raw registry subkeys are NOT serialized (redundant with contentIndicators, size bloat). + Assert.False(first.ContainsKey("registrySubKeys")); - // Content indicators are derived from the package-scoped subkey names. + // Content indicators are still derived from the package-scoped subkey names. var indicators = (Dictionary)payload["contentIndicators"]; Assert.True((bool)indicators["wifiProfiles"]); Assert.True((bool)indicators["localAccounts"]); @@ -121,10 +159,8 @@ public sealed class ProvisioningPackageCollectorTests Assert.True((bool)payload["anyPpkgFound"]); Assert.True((bool)payload["recoveryCustomizationsResidue"]); - Assert.Contains("setupcomplete.cmd", (List)payload["recoveryCustomizationsFiles"]); - - var detected = ProvisioningPackageCollector.BuildDetectedEvents(findings); - var residue = Assert.Single(detected); + var artifacts = (List>)payload["artifacts"]; + var residue = Assert.Single(artifacts); Assert.Equal("recovery_residue", residue["source"]); Assert.Equal("setupcomplete.cmd", residue["identity"]); } @@ -186,9 +222,161 @@ public sealed class ProvisioningPackageCollectorTests 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"]); + Assert.False(data.ContainsKey("fileName")); + } + + [Fact] + public void BuildPayload_artifacts_are_complete_no_count_cap() + { + // The rule-facing artifacts list must be COMPLETE — a count cap could chop a malicious + // artifact off the tail so the allow-list rule never sees it. Entries are lean instead. + var findings = new ProvisioningScanFindings { RecoveryCustomizationsDir = @"C:\Recovery\Customizations" }; + for (int i = 0; i < 130; i++) + findings.RecoveryCustomizationsFiles.Add($"residue_{i}.txt"); + + var payload = ProvisioningPackageCollector.BuildPayload(findings); + + Assert.Equal(130, (int)payload["artifactCount"]); + Assert.Equal(130, ((List>)payload["artifacts"]).Count); + Assert.False(payload.ContainsKey("artifactsTruncated")); + } + + [Fact] + public void BuildChunkedPayloads_single_event_when_small() + { + var findings = new ProvisioningScanFindings { RecoveryCustomizationsDir = @"C:\Recovery\Customizations" }; + for (int i = 0; i < 10; i++) findings.RecoveryCustomizationsFiles.Add($"r_{i}.txt"); + var payload = ProvisioningPackageCollector.BuildPayload(findings); + var artifacts = (List>)payload["artifacts"]; + + var chunks = ProvisioningPackageCollector.BuildChunkedPayloads(payload, artifacts); + + var only = Assert.Single(chunks); + Assert.Equal(0, (int)only["chunkIndex"]); + Assert.Equal(1, (int)only["chunkCount"]); + Assert.Equal(10, ((List>)only["artifacts"]).Count); + Assert.True(only.ContainsKey("contentIndicators")); // full aggregate on chunk 0 + } + + [Fact] + public void BuildChunkedPayloads_splits_large_set_across_events_without_loss() + { + // Many artifacts → multiple size-bounded events. The rule engine evaluates the array + // condition across all of them, so splitting (vs dropping) preserves full coverage. + var findings = new ProvisioningScanFindings { RecoveryCustomizationsDir = @"C:\Recovery\Customizations" }; + for (int i = 0; i < 1000; i++) findings.RecoveryCustomizationsFiles.Add($"r_{i:D4}.txt"); + var payload = ProvisioningPackageCollector.BuildPayload(findings); + var artifacts = (List>)payload["artifacts"]; + Assert.Equal(1000, artifacts.Count); + + var chunks = ProvisioningPackageCollector.BuildChunkedPayloads(payload, artifacts); + + Assert.True(chunks.Count >= 2, "large set should span multiple events"); + // Chunk 0 carries the full aggregate; continuation chunks are lean. + Assert.True(chunks[0].ContainsKey("contentIndicators")); + Assert.False(chunks[1].ContainsKey("contentIndicators")); + + for (int i = 0; i < chunks.Count; i++) + { + Assert.Equal(i, (int)chunks[i]["chunkIndex"]); + Assert.Equal(chunks.Count, (int)chunks[i]["chunkCount"]); + Assert.Equal(1000, (int)chunks[i]["artifactCount"]); + AssertChunkUnderStorageLimit(chunks[i]); + } + + // No artifact lost or duplicated across chunks. + var ids = chunks + .SelectMany(c => (List>)c["artifacts"]) + .Select(a => (string)a["identity"]) + .ToList(); + Assert.Equal(1000, ids.Count); + Assert.Equal(1000, ids.Distinct().Count()); + } + + [Fact] + public void BuildChunkedPayloads_worstcase_long_identities_stay_under_storage_limit() + { + // Reviewer scenario: 150 artifacts with ~220-char names would blow a fixed-count chunk past + // 30k. Size-based chunking must keep EVERY chunk's serialized DataJson under the limit. + var longName = new string('a', 220) + ".ppkg"; + var findings = new ProvisioningScanFindings { RecoveryCustomizationsDir = @"C:\ProgramData\Microsoft\Provisioning\VeryDeep\Nested\Path" }; + for (int i = 0; i < 150; i++) findings.RecoveryCustomizationsFiles.Add($"{i:D3}_{longName}"); + var payload = ProvisioningPackageCollector.BuildPayload(findings); + var artifacts = (List>)payload["artifacts"]; + + var chunks = ProvisioningPackageCollector.BuildChunkedPayloads(payload, artifacts); + + Assert.True(chunks.Count >= 2); + foreach (var chunk in chunks) + AssertChunkUnderStorageLimit(chunk); + + // Still no loss despite the worst-case sizes. + var ids = chunks.SelectMany(c => (List>)c["artifacts"]).Select(a => (string)a["identity"]).ToList(); + Assert.Equal(150, ids.Count); + Assert.Equal(150, ids.Distinct().Count()); + } + + // Mirrors the backend's 30k DataJson truncation guard using the SAME serializer (Newtonsoft). + private static void AssertChunkUnderStorageLimit(Dictionary chunk) + { + var json = Newtonsoft.Json.JsonConvert.SerializeObject(chunk, Newtonsoft.Json.Formatting.None); + Assert.True(json.Length < 30000, $"chunk DataJson {json.Length} chars exceeds 30k truncation limit"); + } + + [Fact] + public void BuildPackageEventData_caps_rank_and_installTime() + { + var data = ProvisioningPackageCollector.BuildPackageEventData(new PpkgPackageFact + { + PackageId = "{x}", + Name = "P", + Rank = new string('9', 40000), + InstallTime = new string('t', 40000), + }); + + Assert.True(((string)data["rank"]).Length <= 256); + Assert.True(((string)data["installTime"]).Length <= 256); + } + + [Fact] + public void BuildChunkedPayloads_oversized_registry_field_stays_under_storage_limit() + { + // A single registry package with a pathological 40k rank must NOT push its (un-splittable) + // chunk past 30k — every per-artifact string is capped. + var findings = new ProvisioningScanFindings(); + findings.Packages.Add(new PpkgPackageFact { PackageId = "{x}", Name = "Evil", Rank = new string('9', 40000) }); + var payload = ProvisioningPackageCollector.BuildPayload(findings); + var artifacts = (List>)payload["artifacts"]; + + var chunks = ProvisioningPackageCollector.BuildChunkedPayloads(payload, artifacts); + + foreach (var chunk in chunks) + AssertChunkUnderStorageLimit(chunk); + } + + [Fact] + public void BuildIdentity_hard_caps_length() + { + var identity = ProvisioningPackageCollector.BuildIdentity( + name: new string('x', 1000), fileName: null, ownerType: null, packageId: null); + + Assert.Equal(256, identity.Length); + } + + [Fact] + public void BuildPayload_scanTruncated_reflects_per_source_caps() + { + // When a per-source collection cap bit, scanTruncated must be true so ANALYZE-SEC-007 alarms. + var clean = new ProvisioningScanFindings(); + Assert.False((bool)ProvisioningPackageCollector.BuildPayload(clean)["scanTruncated"]); + + var floodedFiles = new ProvisioningScanFindings { FilesTruncated = true }; + Assert.True((bool)ProvisioningPackageCollector.BuildPayload(floodedFiles)["scanTruncated"]); + + var floodedResidue = new ProvisioningScanFindings { RecoveryFilesTruncated = true }; + Assert.True((bool)ProvisioningPackageCollector.BuildPayload(floodedResidue)["scanTruncated"]); } [Fact] @@ -233,9 +421,9 @@ public sealed class ProvisioningPackageCollectorTests }); Assert.Equal("registry", data["source"]); - Assert.Equal("{abc}", data["packageId"]); - Assert.Equal("Contoso Bulk", data["packageName"]); Assert.Equal("Contoso Bulk | ITPro | {abc}", data["identity"]); + Assert.False(data.ContainsKey("packageId")); + Assert.False(data.ContainsKey("packageName")); } [Fact] @@ -250,8 +438,10 @@ public sealed class ProvisioningPackageCollectorTests }); Assert.Equal("file", data["source"]); - Assert.Equal("oem.ppkg", data["fileName"]); Assert.Equal("oem.ppkg", data["identity"]); + Assert.Equal(@"C:\Recovery\Customizations", data["dir"]); + Assert.Equal(123L, data["sizeBytes"]); + Assert.False(data.ContainsKey("fileName")); } [Theory] diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Provisioning/ProvisioningPackageCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Provisioning/ProvisioningPackageCollector.cs index 653dc66e..3e9d9081 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Provisioning/ProvisioningPackageCollector.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Provisioning/ProvisioningPackageCollector.cs @@ -13,8 +13,9 @@ using Microsoft.Win32; namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Provisioning { /// - /// Scans the device for provisioning-package (PPKG) traces and emits a single - /// event with the raw facts. + /// Scans the device for provisioning-package (PPKG) traces and emits one or more + /// events with the raw facts (the + /// artifact set is split across multiple size-bounded chunk events when large). /// /// 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 @@ -46,10 +47,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Provisioning // 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; + // Generous collection caps — purely a memory/time safety net against a pathological + // directory/hive (realistic devices have a few dozen). Artifacts beyond these set the + // corresponding *Truncated flag → scanTruncated → ANALYZE-SEC-007 alarms. Normal volumes + // are emitted in full, split across size-bounded chunk events (see MaxChunkJsonBytes), never dropped. + private const int MaxPpkgFiles = 250; + private const int MaxPackages = 250; + private const int MaxRecoveryFiles = 250; + // Chunking is by SERIALIZED SIZE, not a fixed count: identity/dir lengths vary, so a fixed + // count could still blow the 30k Table-Storage DataJson limit (truncation → invalid JSON → + // the rule can't read `artifacts`). Each event's payload is packed to stay under this budget, + // which sits safely below 30k to absorb serializer/escaping differences. + private const int MaxChunkJsonBytes = 24000; + // Hard cap on per-artifact string fields so a single pathological entry can't blow a chunk. + // Allow-list patterns are anchored at the START, so trimming the tail never breaks matching. + private const int MaxArtifactFieldLength = 256; + // Cap on surfaced scan errors, keeping the chunk-0 aggregate base bounded. + private const int MaxScanErrors = 50; private readonly string _sessionId; private readonly string _tenantId; @@ -72,9 +86,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Provisioning } /// - /// Runs the full scan and emits exactly one provisioning_package_scan event. - /// Fail-soft end to end: any probe error is captured into scanErrors and the event - /// is still emitted (so the backend can distinguish "scanned clean" from "never scanned"). + /// Runs the full scan and emits one or more provisioning_package_scan events (the + /// artifact set is split across size-bounded chunk events when large; a clean device still + /// emits exactly one). Fail-soft end to end: any probe error is captured into + /// scanErrors and an event is still emitted (so the backend can distinguish + /// "scanned clean" from "never scanned"). /// public void Scan() { @@ -96,49 +112,138 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Provisioning var payload = BuildPayload(findings); var anyFound = payload.TryGetValue("anyPpkgFound", out var f) && f is bool b && b; + var artifacts = (List>)payload["artifacts"]; 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, - }); + // The analyze rules iterate the `artifacts` array (event_data_array). To keep every + // event's DataJson under the 30k Table-Storage truncation limit WITHOUT dropping any + // artifact (which would blind the allow-list rule), split a large artifact set across + // MULTIPLE provisioning_package_scan events — the rule engine evaluates the array + // condition across ALL matching events in the session, so nothing is lost. The first + // chunk carries the full aggregate (counts / contentIndicators / context); later chunks + // carry only their artifact slice + chunk metadata. + EmitChunkedScan(payload, artifacts, message); - // 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})."); + _logger.Info($"ProvisioningPackageCollector: scan complete (anyPpkgFound={anyFound}, packages={findings.Packages.Count}, files={findings.Files.Count}, artifacts={artifacts.Count}, errors={findings.Errors.Count})."); } - private void EmitPerPackageEvents(ProvisioningScanFindings findings) + private void EmitChunkedScan(Dictionary fullPayload, List> artifacts, string message) { - foreach (var data in BuildDetectedEvents(findings)) + var chunks = BuildChunkedPayloads(fullPayload, artifacts); + for (var i = 0; i < chunks.Count; i++) { - EmitDetected(data); + var chunkMessage = chunks.Count > 1 ? $"{message} (part {i + 1}/{chunks.Count})" : message; + _post.Emit(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = Constants.EventTypes.ProvisioningPackageScan, + Severity = EventSeverity.Info, + Source = SourceName, + Phase = EnrollmentPhase.Unknown, + Message = chunkMessage, + Data = chunks[i], + ImmediateUpload = true, + }); } } /// - /// The full set of per-artifact provisioning_package_detected payloads: one per - /// registry package, one per .ppkg file, and one per Recovery\Customizations residue file - /// that is not already covered by a .ppkg file event. BuildPayload derives - /// anyPpkgFound from this same set, so the scan verdict and the events the analyze - /// rules match on stay consistent by construction. + /// Splits the scan payload into one-or-more per-event payloads so each event's serialized + /// DataJson stays under (well below the 30k Table-Storage + /// limit) WITHOUT dropping any artifact. Chunk 0 is the full aggregate (counts, content + /// indicators, context) with its artifact slice; continuation chunks carry only the + /// rule-relevant fields + their slice. Every chunk gets chunkIndex/chunkCount. + /// The rule engine evaluates the event_data_array condition across all chunk events, so the + /// complete artifact set is always considered. + /// + internal static List> BuildChunkedPayloads( + Dictionary fullPayload, + List> artifacts) + { + var total = artifacts.Count; + var scanTruncated = fullPayload.TryGetValue("scanTruncated", out var st) && st is bool stb && stb; + var result = new List>(); + var emptyArtifacts = new List>(); + + if (total == 0) + { + fullPayload["artifacts"] = emptyArtifacts; + fullPayload["chunkIndex"] = 0; + fullPayload["chunkCount"] = 1; + return new List> { fullPayload }; + } + + // Base (non-artifact) JSON size of each chunk kind, measured with the SAME serializer + // the transport uses (Newtonsoft), incl. chunk-meta placeholders. Greedy size-based + // packing then keeps every chunk's DataJson under MaxChunkJsonBytes regardless of how + // long individual identities/dirs are — a fixed artifact COUNT could not guarantee that. + fullPayload["chunkIndex"] = 0; + fullPayload["chunkCount"] = 0; + fullPayload["artifacts"] = emptyArtifacts; + var base0 = JsonLength(fullPayload); + + var leanProbe = NewContinuationChunk(total, scanTruncated, emptyArtifacts); + leanProbe["chunkIndex"] = 0; + leanProbe["chunkCount"] = 0; + var baseN = JsonLength(leanProbe); + + // Pack artifacts into slices by serialized size (always >= 1 artifact per slice). + var slices = new List>>(); + var current = new List>(); + var currentBytes = 0; + foreach (var a in artifacts) + { + var aBytes = JsonLength(a) + 1; // + separating comma + var allowance = MaxChunkJsonBytes - (slices.Count == 0 ? base0 : baseN); + if (current.Count > 0 && currentBytes + aBytes > allowance) + { + slices.Add(current); + current = new List>(); + currentBytes = 0; + } + current.Add(a); + currentBytes += aBytes; + } + slices.Add(current); + + for (var i = 0; i < slices.Count; i++) + { + var data = i == 0 ? fullPayload : NewContinuationChunk(total, scanTruncated, slices[i]); + data["artifacts"] = slices[i]; + data["chunkIndex"] = i; + data["chunkCount"] = slices.Count; + result.Add(data); + } + + return result; + } + + private static Dictionary NewContinuationChunk(int total, bool scanTruncated, List> artifacts) + => new Dictionary(StringComparer.Ordinal) + { + ["anyPpkgFound"] = true, + ["artifacts"] = artifacts, + ["artifactCount"] = total, + ["scanTruncated"] = scanTruncated, + }; + + /// Serialized length with the transport's serializer (Newtonsoft) — matches the stored DataJson. + private static int JsonLength(object value) + => Newtonsoft.Json.JsonConvert.SerializeObject(value, Newtonsoft.Json.Formatting.None).Length; + + /// + /// The full set of per-artifact descriptors embedded in the aggregate scan event's + /// artifacts[] array (one per registry package, one per .ppkg file, one per + /// Recovery\Customizations residue file not already covered by a .ppkg file event). + /// BuildPayload embeds this AND derives anyPpkgFound from it, so the scan + /// verdict and what the analyze rules iterate stay consistent by construction. /// /// Residue dedup is by actual captured path, NOT by ".ppkg" extension: a recovery .ppkg /// whose recursive enumeration failed or was truncated is absent from findings.Files, - /// so it is NOT silently dropped here — it still gets a (recovery_residue) detected event. + /// so it is NOT silently dropped here — it still gets a (recovery_residue) artifact entry. /// /// internal static List> BuildDetectedEvents(ProvisioningScanFindings findings) @@ -172,25 +277,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Provisioning return list; } - private void EmitDetected(Dictionary data) - { - var identity = data.TryGetValue("identity", out var id) ? id?.ToString() : null; - _post.Emit(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = Constants.EventTypes.ProvisioningPackageDetected, - Severity = EventSeverity.Info, - Source = SourceName, - Phase = EnrollmentPhase.Unknown, - Message = string.IsNullOrEmpty(identity) - ? "Provisioning package artifact detected" - : $"Provisioning package artifact detected: {identity}", - Data = data, - ImmediateUpload = true, - }); - } - // ---------------------------------------------------------------------------------- // IO probes (fail-soft) — each catch appends to findings.Errors and continues. // ---------------------------------------------------------------------------------- @@ -371,52 +457,39 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Provisioning internal static Dictionary BuildPayload(ProvisioningScanFindings findings) { - // Derive anyPpkgFound from the SAME set that produces per-package - // `provisioning_package_detected` events, so the aggregate "found" verdict and the - // events the analyze rules listen on can never diverge (Recovery-residue-only gap fix). - var anyPpkgFound = BuildDetectedEvents(findings).Count > 0; - - var ppkgFiles = findings.Files - .Select(file => new Dictionary - { - ["dir"] = file.Directory, - ["name"] = file.Name, - ["path"] = file.FullPath, - ["sizeBytes"] = file.SizeBytes, - ["lastWriteUtc"] = (object?)file.LastWriteUtc ?? string.Empty, - }) - .ToList(); - - var packages = findings.Packages - .Select(pkg => - { - var d = new Dictionary { ["packageId"] = pkg.PackageId }; - if (!string.IsNullOrEmpty(pkg.Name)) d["name"] = pkg.Name!; - if (!string.IsNullOrEmpty(pkg.OwnerType)) d["ownerType"] = pkg.OwnerType!; - if (!string.IsNullOrEmpty(pkg.Rank)) d["rank"] = pkg.Rank!; - if (!string.IsNullOrEmpty(pkg.InstallTime)) d["installTime"] = pkg.InstallTime!; - if (pkg.SubKeyNames.Count > 0) d["registrySubKeys"] = pkg.SubKeyNames.ToList(); - return d; - }) - .ToList(); + // `artifacts` is the SINGLE rule-facing array (the rules iterate it via the + // event_data_array condition). It is the ONLY detail array on the wire (no duplicate + // ppkgFiles/packages, no raw registry subkeys) AND each entry is kept lean, so the + // COMPLETE list of collected artifacts fits well under the 30k Table-Storage truncation + // limit without a count cap. That completeness matters: a count cap could chop off a + // malicious artifact in the tail and the allow-list rule would never see it. The total + // is already bounded by the per-source collection caps (files/packages/residue) — and if + // any of those bit, `scanTruncated` is set so ANALYZE-SEC-007 alarms ("not fully evaluated"). + var artifacts = BuildDetectedEvents(findings); + var anyPpkgFound = artifacts.Count > 0; + var scanTruncated = findings.FilesTruncated || findings.PackagesTruncated || findings.RecoveryFilesTruncated; return new Dictionary { ["anyPpkgFound"] = anyPpkgFound, + ["artifacts"] = artifacts, + ["artifactCount"] = artifacts.Count, + // True when a per-source cap bit, i.e. NOT every artifact could be evaluated. + // ANALYZE-SEC-007 fires on this so a flooded scan is never silently incomplete. + ["scanTruncated"] = scanTruncated, + // Counts only — per-artifact detail lives in `artifacts` (no duplicate arrays). ["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(), + // Cap count + per-entry length so the chunk-0 aggregate base stays bounded. + ["scanErrors"] = findings.Errors.Take(MaxScanErrors).Select(CapField).ToList(), }; } @@ -477,47 +550,53 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Provisioning { if (seen.Add(p)) ordered.Add(p); } - return string.Join(" | ", ordered); + return CapField(string.Join(" | ", ordered)); } + /// Hard-caps a per-artifact string field (tail-trim is safe — allow-list is anchored at start). + private static string CapField(string s) + => string.IsNullOrEmpty(s) || s.Length <= MaxArtifactFieldLength ? s : s.Substring(0, MaxArtifactFieldLength); + + // Artifact entries are kept LEAN so the COMPLETE list always fits under the 30k limit (no + // count cap that could hide a tail artifact). `identity` already carries name/ownerType/ + // packageId (registry) or the file name, so those are not repeated; only fields NOT in the + // identity (rank/installTime, file dir/size) are added for triage. internal static Dictionary BuildPackageEventData(PpkgPackageFact pkg) { var data = new Dictionary(StringComparer.Ordinal) { ["source"] = "registry", - ["packageId"] = pkg.PackageId, - ["packageName"] = pkg.Name ?? string.Empty, - ["ownerType"] = pkg.OwnerType ?? string.Empty, - ["rank"] = pkg.Rank ?? string.Empty, - ["installTime"] = pkg.InstallTime ?? string.Empty, ["identity"] = BuildIdentity(pkg.Name, fileName: null, ownerType: pkg.OwnerType, packageId: pkg.PackageId), }; + // Cap every per-artifact string so a single pathological registry value can't push one + // artifact past the chunk budget (the packer always keeps >=1 artifact per chunk). + if (!string.IsNullOrEmpty(pkg.Rank)) data["rank"] = CapField(pkg.Rank!); + if (!string.IsNullOrEmpty(pkg.InstallTime)) data["installTime"] = CapField(pkg.InstallTime!); return data; } internal static Dictionary BuildFileEventData(PpkgFileFact file) { + // identity == file name; keep `dir` for location context (Windows = OS-inbox vs + // ProgramData/Recovery = applied) and size. Full path/lastWrite omitted to stay lean. var data = new Dictionary(StringComparer.Ordinal) { ["source"] = "file", - ["fileName"] = file.Name, - ["dir"] = file.Directory, - ["path"] = file.FullPath, - ["sizeBytes"] = file.SizeBytes, ["identity"] = BuildIdentity(name: null, fileName: file.Name, ownerType: null, packageId: null), + ["dir"] = CapField(file.Directory), + ["sizeBytes"] = file.SizeBytes, }; - if (!string.IsNullOrEmpty(file.LastWriteUtc)) data["lastWriteUtc"] = file.LastWriteUtc!; return data; } internal static Dictionary BuildRecoveryResidueEventData(string fileName, string? dir) { + // identity == file name; keep `dir` for location context. return new Dictionary(StringComparer.Ordinal) { ["source"] = "recovery_residue", - ["fileName"] = fileName, - ["dir"] = dir ?? string.Empty, ["identity"] = BuildIdentity(name: null, fileName: fileName, ownerType: null, packageId: null), + ["dir"] = CapField(dir ?? string.Empty), }; } diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index b4f71605..a8ed109d 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -938 +953 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 19ef72cd..af7fac0a 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1419 +1425 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RuleEngineArrayConditionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RuleEngineArrayConditionTests.cs new file mode 100644 index 00000000..cdd1e0f4 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/RuleEngineArrayConditionTests.cs @@ -0,0 +1,240 @@ +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Pins the event_data_array condition source: it iterates an array field on a single +/// event (e.g. provisioning_package_scan's artifacts) and matches when ANY element's +/// itemField satisfies the operator. Backs the PPKG allow-list rules (ANALYZE-SEC-005/006): +/// one aggregate event + an allow-list regex, instead of one event per package. +/// +public class RuleEngineArrayConditionTests +{ + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + + // Mirrors the ANALYZE-SEC-005 default allow-list (OS-inbox families) — ANCHORED at start. + private const string AllowList = @"^(?:Microsoft\.Windows\.Cosa|Power\.EnergyEstimationEngine|Power\.Settings|SecureStart\.Settings)\b"; + + [Fact] + public async Task AllArtifactsAllowListed_RuleDoesNotFire() + { + // Clean Win11 VM: only OS-inbox .ppkg → every identity matches the allow-list → no fire. + var events = new List + { + ScanEvent( + "Power.Settings.Sleep.ppkg", + "Power.EnergyEstimationEngine.CPU.ppkg", + "Microsoft.Windows.Cosa.Desktop.Client.ppkg", + "SecureStart.Settings.ppkg") + }; + + var outcome = await RunAsync(MakeAllowListRule(), events); + + Assert.Empty(outcome.Results); + } + + [Fact] + public async Task OneArtifactNotAllowListed_RuleFires_WithThatIdentityAsEvidence() + { + var events = new List + { + ScanEvent( + "Power.Settings.Sleep.ppkg", // allow-listed + "Contoso.BulkEnroll.ppkg", // NOT allow-listed → should fire + "Microsoft.Windows.Cosa.Desktop.ppkg") + }; + + var outcome = await RunAsync(MakeAllowListRule(), events); + + var result = Assert.Single(outcome.Results); + Assert.Equal("ANALYZE-SEC-005", result.RuleId); + var evidence = Assert.IsAssignableFrom>(result.MatchedConditions["ppkg_not_allowlisted"]); + Assert.Equal("identity", evidence["field"]); + Assert.Equal("Contoso.BulkEnroll.ppkg", evidence["value"]); + Assert.Equal(1, Convert.ToInt32(evidence["matchCount"])); + } + + [Fact] + public async Task MultipleNotAllowListed_MatchCountReflectsAll() + { + var events = new List + { + ScanEvent("Power.Settings.Sleep.ppkg", "Contoso.A.ppkg", "Contoso.B.ppkg") + }; + + var outcome = await RunAsync(MakeAllowListRule(), events); + + var result = Assert.Single(outcome.Results); + var evidence = (IDictionary)result.MatchedConditions["ppkg_not_allowlisted"]; + Assert.Equal(2, Convert.ToInt32(evidence["matchCount"])); + } + + [Fact] + public async Task EmptyArtifacts_RuleDoesNotFire() + { + var events = new List { ScanEvent(/* none */) }; + + var outcome = await RunAsync(MakeAllowListRule(), events); + + Assert.Empty(outcome.Results); + } + + [Fact] + public async Task AllowListIsAnchored_PrefixedImpostorStillFires() + { + // Security: an unanchored substring allow-list would let "Contoso.Power.Settings.Backdoor" + // slip through because it contains "Power.Settings". The anchored (^) pattern must reject it. + var events = new List + { + ScanEvent("Contoso.Power.Settings.Backdoor.ppkg") + }; + + var outcome = await RunAsync(MakeAllowListRule(), events); + + var result = Assert.Single(outcome.Results); + var evidence = (IDictionary)result.MatchedConditions["ppkg_not_allowlisted"]; + Assert.Equal("Contoso.Power.Settings.Backdoor.ppkg", evidence["value"]); + } + + [Fact] + public async Task DotPathArrayField_IsResolved() + { + // event_data_array dataField supports dot-path into a nested object (Low-finding fix). + var nested = new Dictionary + { + ["scan"] = new Dictionary + { + ["artifacts"] = new List + { + new Dictionary { ["identity"] = "Contoso.BulkEnroll.ppkg" } + } + } + }; + var evt = new EnrollmentEvent + { + EventId = Guid.NewGuid().ToString(), + TenantId = TenantId, + SessionId = SessionId, + EventType = "provisioning_package_scan", + Timestamp = DateTime.UtcNow, + Sequence = 1, + Data = nested + }; + + var rule = MakeAllowListRule(); + rule.Conditions[0].DataField = "scan.artifacts"; + + var outcome = await RunAsync(rule, new List { evt }); + + Assert.Single(outcome.Results); + } + + [Fact] + public async Task ScanTruncated_FiresTruncationAlarmRule() + { + // ANALYZE-SEC-007 shape: a per-source cap hit (scanTruncated=true) must alarm, because the + // allow-list rules could not evaluate every package (a tail artifact may be unseen). + var rule = new AnalyzeRule + { + RuleId = "ANALYZE-SEC-007", + Title = "Provisioning Package Scan Incomplete", + Severity = "warning", + Category = "security", + Enabled = true, + BaseConfidence = 80, + ConfidenceThreshold = 40, + Conditions = new List + { + new() { Signal = "ppkg_scan_truncated", Source = "event_data", EventType = "provisioning_package_scan", DataField = "scanTruncated", Operator = "equals", Value = "true", Required = true } + }, + Explanation = "test" + }; + var evt = new EnrollmentEvent + { + EventId = Guid.NewGuid().ToString(), + TenantId = TenantId, + SessionId = SessionId, + EventType = "provisioning_package_scan", + Timestamp = DateTime.UtcNow, + Sequence = 1, + Data = new Dictionary { ["scanTruncated"] = true, ["artifacts"] = new List() } + }; + + var outcome = await RunAsync(rule, new List { evt }); + + Assert.Single(outcome.Results); + } + + // ===== Helpers ===== + + private static AnalyzeRule MakeAllowListRule() => new() + { + RuleId = "ANALYZE-SEC-005", + Title = "Unexpected Provisioning Package", + Severity = "warning", + Category = "security", + Enabled = true, + BaseConfidence = 80, + ConfidenceThreshold = 40, + Conditions = new List + { + new() + { + Signal = "ppkg_not_allowlisted", + Source = "event_data_array", + EventType = "provisioning_package_scan", + DataField = "artifacts", + ItemField = "identity", + Operator = "not_regex", + Value = AllowList, + Required = true + } + }, + Explanation = "test" + }; + + private static EnrollmentEvent ScanEvent(params string[] identities) + { + // Mirror the storage-read shape: artifacts is a List of Dictionary. + var artifacts = new List(); + foreach (var id in identities) + artifacts.Add(new Dictionary { ["source"] = "file", ["identity"] = id }); + + return new EnrollmentEvent + { + EventId = Guid.NewGuid().ToString(), + TenantId = TenantId, + SessionId = SessionId, + EventType = "provisioning_package_scan", + Timestamp = DateTime.UtcNow, + Sequence = 1, + Data = new Dictionary + { + ["anyPpkgFound"] = identities.Length > 0, + ["artifacts"] = artifacts + } + }; + } + + private static async Task RunAsync(AnalyzeRule rule, List events) + { + var ruleRepo = new Mock(); + ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List { rule }); + ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List()); + ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny())).ReturnsAsync(new Dictionary()); + ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + + var sessionRepo = new Mock(); + sessionRepo.Setup(s => s.GetSessionEventsAsync(TenantId, SessionId, It.IsAny())).ReturnsAsync(events); + + var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger.Instance); + var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger.Instance); + + return await engine.AnalyzeSessionAsync(TenantId, SessionId); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs index fb75a99f..8c05d8ce 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs @@ -83,6 +83,9 @@ namespace AutopilotMonitor.Functions.Services case "event_data": return EvaluateEventDataCondition(condition, events); + case "event_data_array": + return EvaluateEventDataArrayCondition(condition, events); + case "event_count": return EvaluateEventCountCondition(condition, events); @@ -182,6 +185,163 @@ namespace AutopilotMonitor.Functions.Services return (false, "no matching data"); } + /// + /// Evaluates a condition against an ARRAY field on the event (e.g. provisioning_package_scan's + /// artifacts). For each element, the element's + /// (e.g. identity) is tested with Operator/Value; the condition matches when ANY + /// element satisfies it. This lets one aggregate event carry many items (no per-item event + /// spam) while a rule still reacts per item — e.g. not_regex against an allow-list to + /// fire only for array elements NOT on the list. Evidence carries the matched item value + /// under field=ItemField (so {{itemField}} interpolates) plus a capped sample. + /// + private (bool matched, object evidence) EvaluateEventDataArrayCondition(RuleCondition condition, List events) + { + var matchingEvents = events.Where(e => MatchesEventType(e, condition.EventType)).ToList(); + + foreach (var evt in matchingEvents) + { + if (evt.Data == null) continue; + var items = AsEnumerable(GetDataFieldRaw(evt.Data, condition.DataField)); + if (items == null) continue; + + var matchedValues = new List(); + foreach (var element in items) + { + var itemValue = GetItemFieldValue(element, condition.ItemField); + if (itemValue != null && MatchesOperator(itemValue, condition.Operator, condition.Value)) + matchedValues.Add(itemValue); + } + + if (matchedValues.Count == 0) continue; + + const int MaxSamples = 10; + var fieldName = string.IsNullOrEmpty(condition.ItemField) ? "item" : condition.ItemField; + var evidence = new Dictionary + { + ["eventId"] = evt.EventId, + ["sequence"] = evt.Sequence, + ["timestamp"] = evt.Timestamp, + ["eventType"] = evt.EventType, + ["field"] = fieldName, + ["value"] = matchedValues[0], + ["matchCount"] = matchedValues.Count, + ["matchedItems"] = matchedValues.Take(MaxSamples).ToList(), + ["matchedItemsTruncated"] = matchedValues.Count > MaxSamples, + }; + return (true, evidence); + } + + return (false, "no array element matched"); + } + + /// + /// Raw (object) lookup of a data field — case-insensitive, no stringify. Supports flat keys + /// (incl. legacy keys containing a literal dot) and dot-path traversal into nested + /// dictionaries / JsonElement objects (e.g. foo.artifacts). Returns the raw value, + /// so an array stays an array for . + /// + private static object? GetDataFieldRaw(Dictionary data, string field) + { + if (data == null || string.IsNullOrEmpty(field)) return null; + if (data.TryGetValue(field, out var v)) return v; + var hit = data.Keys.FirstOrDefault(k => k.Equals(field, StringComparison.OrdinalIgnoreCase)); + if (hit != null) return data[hit]; + if (field.IndexOf('.') >= 0) return ResolveDotPathRaw(data, field); + return null; + } + + /// Walks a dot-path through nested dictionaries / JsonElement objects, returning the raw value. + private static object? ResolveDotPathRaw(IDictionary root, string path) + { + object? current = root; + foreach (var part in path.Split('.')) + { + switch (current) + { + case System.Collections.Generic.IDictionary dict: + if (!dict.TryGetValue(part, out var next)) + { + var hit = dict.Keys.FirstOrDefault(k => k.Equals(part, StringComparison.OrdinalIgnoreCase)); + if (hit == null) return null; + next = dict[hit]; + } + current = next; + break; + case JsonElement jel when jel.ValueKind == JsonValueKind.Object: + JsonElement? found = null; + if (jel.TryGetProperty(part, out var prop)) found = prop; + else + { + foreach (var p in jel.EnumerateObject()) + if (p.Name.Equals(part, StringComparison.OrdinalIgnoreCase)) { found = p.Value; break; } + } + if (found == null) return null; + current = found.Value; + break; + default: + return null; + } + } + return current; + } + + /// + /// Materializes an array value as an enumerable of elements. Handles the Newtonsoft path + /// (JArray → List<object>, the storage-read shape) and System.Text.Json arrays. A + /// string is deliberately NOT treated as an enumerable. + /// + private static System.Collections.Generic.IEnumerable? AsEnumerable(object? value) + { + switch (value) + { + case null: + return null; + case string: + return null; + case JsonElement jel: + return jel.ValueKind == JsonValueKind.Array ? jel.EnumerateArray().Cast() : null; + case System.Collections.IEnumerable en: + return en.Cast(); + default: + return null; + } + } + + /// + /// Extracts an array element's sub-field as a string. Element may be a + /// Dictionary<string,object> (storage-read), a JsonElement object, or a scalar + /// (when is empty). + /// + private static string? GetItemFieldValue(object element, string itemField) + { + if (element == null) return null; + + if (string.IsNullOrEmpty(itemField)) + { + if (element is JsonElement scalarJel) + return scalarJel.ValueKind == JsonValueKind.String ? scalarJel.GetString() : scalarJel.ToString(); + if (element is System.Collections.Generic.IDictionary) return null; + return element.ToString(); + } + + switch (element) + { + case System.Collections.Generic.IDictionary dict: + if (dict.TryGetValue(itemField, out var dv)) return dv?.ToString(); + var hit = dict.Keys.FirstOrDefault(k => k.Equals(itemField, StringComparison.OrdinalIgnoreCase)); + return hit != null ? dict[hit]?.ToString() : null; + case JsonElement jel when jel.ValueKind == JsonValueKind.Object: + if (jel.TryGetProperty(itemField, out var prop)) + return prop.ValueKind == JsonValueKind.String ? prop.GetString() : prop.ToString(); + foreach (var p in jel.EnumerateObject()) + if (p.Name.Equals(itemField, StringComparison.OrdinalIgnoreCase)) + return p.Value.ValueKind == JsonValueKind.String ? p.Value.GetString() : p.Value.ToString(); + return null; + default: + return null; + } + } + private (bool matched, object evidence) EvaluateEventCountCondition(RuleCondition condition, List events) { var matchingEvents = events.Where(e => MatchesEventType(e, condition.EventType)).ToList(); diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts index 0118400e..2cf47867 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts @@ -141,7 +141,6 @@ export const EVENT_TYPES_CATALOG = { 'security_warning', 'security_audit', 'provisioning_package_scan', - 'provisioning_package_detected', ], vulnerability_events: ['software_inventory_analysis', 'vulnerability_report'], diagnostics_events: ['diagnostics_collecting', 'diagnostics_uploaded', 'diagnostics_upload_failed'], diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index ae2dbb20..847e239f 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -397,17 +397,14 @@ 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. + // Per-device provisioning-package scan. Carries an `artifacts[]` array (one element per + // registry package / .ppkg file / Recovery\Customizations residue, each with a scalar + // `identity`). The PPKG analyze rules iterate this array via an event_data_array + // condition + allow-list regex — no per-package event spam (a clean Win11 device ships + // ~22 OS-inbox .ppkg). A large artifact set is split across MULTIPLE such events + // (chunkIndex/chunkCount); the rule engine evaluates the array across all of them. + // PPKGs can be legitimate (bulk/OEM) or a 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"; // AutoLogon scan — runs at DeviceSetup-phase completion and at final shutdown. Reports // raw facts only (Winlogon registry indicators; DefaultPassword presence-only, never the // value) at Info severity. Backend analyze-rules (ANALYZE-SEC-002/003) judge the facts: diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Rules/AnalyzeRule.cs b/src/Shared/AutopilotMonitor.Shared/Models/Rules/AnalyzeRule.cs index 19e2c830..aa8cad58 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Rules/AnalyzeRule.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Rules/AnalyzeRule.cs @@ -194,11 +194,20 @@ namespace AutopilotMonitor.Shared.Models /// /// Data field to match on. /// For "event_data": field to check with Operator/Value. + /// For "event_data_array": the ARRAY field to iterate (e.g. "artifacts"). /// For "event_correlation": optional filter field on Event B (the second event). /// Uses dot notation for nested fields (e.g., "data.errorCode"). /// public string DataField { get; set; } = default!; + /// + /// For "event_data_array" only: the sub-field on each array element to test with + /// Operator/Value (e.g. "identity"). When empty, each element is treated as a scalar. + /// The condition matches when ANY element satisfies the operator (e.g. one artifact whose + /// identity does not match an allow-list regex). + /// + public string ItemField { get; set; } = default!; + /// /// Comparison operator: "equals", "not_equals", "contains", "not_contains", "regex", "not_regex", "gt", "lt", "gte", "lte", "exists", "not_exists", "count_gte" /// For "event_correlation": operator for the Event B filter (applied to DataField). diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleFormFields.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleFormFields.tsx index 0bd82c13..88cf1198 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleFormFields.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleFormFields.tsx @@ -113,6 +113,7 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
{form.conditions.map((cond, idx) => { const isCorrelation = cond.source === "event_correlation"; + const isArray = cond.source === "event_data_array"; const updateCond = (patch: Partial) => { const c = [...form.conditions]; c[idx] = { ...c[idx], ...patch }; @@ -124,6 +125,7 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist Condition {idx + 1} {isCorrelation && event_correlation} + {isArray && event_data_array} {form.conditions.length > 1 && ( @@ -141,11 +143,11 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist {/* Row 2: Data field / Operator / Value / Required */}
- updateCond({ dataField: e.target.value })} placeholder={isCorrelation ? "Filter field on Event B" : "Data field"} autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" /> + updateCond({ dataField: e.target.value })} placeholder={isCorrelation ? "Filter field on Event B" : isArray ? "Array field (e.g. artifacts)" : "Data field"} autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" /> - updateCond({ value: e.target.value })} placeholder={isCorrelation ? "Filter value on Event B" : "Value"} autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" /> + updateCond({ value: e.target.value })} placeholder={isCorrelation ? "Filter value on Event B" : isArray ? "Value / allow-list regex" : "Value"} autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
)} + + {/* event_data_array extra field */} + {isArray && ( +
+

Array settings

+
+ + updateCond({ itemField: e.target.value })} placeholder="e.g. identity" autoComplete="off" className="w-full px-3 py-1.5 border border-teal-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-teal-500" /> +
+

+ Data field is the array to iterate (e.g. artifacts); the operator/value test runs against Item field on each element. The condition matches when any element matches — e.g. not_regex against an allow-list fires for any element not on the list. +

+
+ )} ); })} diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/types.ts b/src/Web/autopilot-monitor-web/app/analyze-rules/types.ts index 4a7351a4..7bd84607 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/types.ts +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/types.ts @@ -22,6 +22,8 @@ export interface RuleCondition { operator: string; value: string; required: boolean; + // event_data_array: sub-field tested on each array element (e.g. "identity") + itemField?: string; // event_correlation fields correlateEventType?: string; joinField?: string; @@ -102,7 +104,7 @@ export const CATEGORIES = ["network", "identity", "apps", "device", "esp", "enro export const SEVERITIES = ["info", "warning", "high", "critical"] as const; export const TRIGGERS = ["single", "correlation"] as const; export const OPERATORS = ["equals", "not_equals", "contains", "not_contains", "regex", "not_regex", "gt", "lt", "gte", "lte", "exists", "not_exists", "count_gte", "count_per_group_gte", "in", "not_in"] as const; -export const SOURCES = ["event_type", "event_data", "phase_duration", "event_count", "event_correlation"] as const; +export const SOURCES = ["event_type", "event_data", "event_data_array", "phase_duration", "event_count", "event_correlation"] as const; export const PRECONDITION_OPERATORS = ["equals", "not_equals", "contains", "not_contains", "regex", "not_regex", "gt", "lt", "gte", "lte", "exists", "not_exists", "in", "not_in"] as const; export const SEVERITY_COLORS: Record = { @@ -137,6 +139,7 @@ export const EMPTY_CONDITION: RuleCondition = { operator: "contains", value: "", required: true, + itemField: "", correlateEventType: "", joinField: "", timeWindowSeconds: null, diff --git a/src/Web/autopilot-monitor-web/app/docs/sections/SectionAnalyzeRules.tsx b/src/Web/autopilot-monitor-web/app/docs/sections/SectionAnalyzeRules.tsx index 193883bc..5927aa1d 100644 --- a/src/Web/autopilot-monitor-web/app/docs/sections/SectionAnalyzeRules.tsx +++ b/src/Web/autopilot-monitor-web/app/docs/sections/SectionAnalyzeRules.tsx @@ -98,6 +98,29 @@ export function SectionAnalyzeRules() { + {/* event_data_array */} +
+
+

Source: event_data_array

+
+
+

Iterates an array field inside a single event and tests a sub-field on each element. The condition matches when any element satisfies the operator. Use this when one event carries a list (e.g. all detected provisioning packages) and you want to react per item — without needing one event per item.

+
+

Data Field

The array to iterate (e.g. artifacts)

+

Item Field

Sub-field on each element to test (e.g. identity). Leave empty to test scalar elements.

+

Operator / Value

Same operators as event_data; applied to each element

+
+
+ Allow-list pattern: pair not_regex with an allow-list regex in Value. The condition then fires only for elements not on the list — every allow-listed element is ignored. Evidence carries the first non-matching item (under the item-field name, so {`{{identity}}`} interpolates) plus a matchCount. +
+
+

Example — flag a provisioning package not on the allow-list

+

Source: event_data_array · Event Type: provisioning_package_scan · Data Field: artifacts · Item Field: identity · Operator: not_regex · Value: {`^(?:Power\\.Settings|SecureStart\\.Settings)\\b`}

+

Anchor the allow-list with ^ (start) and \b (boundary) — an unanchored substring pattern would also allow-list an impostor like Contoso.Power.Settings.Backdoor.

+
+
+
+ {/* event_count */}
@@ -315,6 +338,33 @@ export function SectionAnalyzeRules() {
+ {/* Example 6 */} +
+
+

Example 6 — Unexpected Provisioning Package (array + allow-list)

+

Fire for any provisioning package (PPKG) that is not on an allow-list — from the scan event(s) that list them all

+
+
+
+ The agent emits a provisioning_package_scan event whose artifacts array lists every PPKG it found (each with an identity). This rule iterates that array instead of expecting one event per package. On devices with very many packages the array is split across several provisioning_package_scan events (chunkIndex/chunkCount) — event_data_array evaluates across all of them, so nothing is missed. (A separate built-in rule, ANALYZE-SEC-007, flags the rare case where a per-source collection cap was hit.) +
+
+

Category

security

+

Severity

warning

+

Base Confidence

80

+

Threshold

40

+
+
+

{`// Condition (required) — any artifact identity NOT on the allow-list`}

+

{`source: "event_data_array" eventType: "provisioning_package_scan"`}

+

{`dataField: "artifacts" itemField: "identity" operator: "not_regex"`}

+

{`value: "^(?:Microsoft\\.Windows\\.Cosa|Power\\.EnergyEstimationEngine|Power\\.Settings|SecureStart\\.Settings)\\b"`}

+

{`// Explanation can reference the offending item: "Unexpected package: {{identity}}"`}

+
+

The allow-list above covers the Windows OS-inbox packages that ship on every device, so a clean machine stays quiet. The pattern is anchored (^(?:…)\b), so to allow your own vendor/recovery packages insert a new alternative inside the group, before the closing ) — e.g. {`…|SecureStart\\.Settings|Acme\\.Recovery)\\b`} (appending after the ) won't match). The built-in rules ANALYZE-SEC-005 (always on) and ANALYZE-SEC-006 (template, customizable allow-list) use exactly this pattern.

+
+
+ {/* ── Template Rules ── */} diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts index a5ca4307..1821f184 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts +++ b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts @@ -113,9 +113,7 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [ { 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)." }, + description: "Single per-device provisioning-package (PPKG) scan at DeviceSetup-phase start. Carries an artifacts[] array — one entry per registry package, .ppkg file, or Recovery\\Customizations residue (each with a scalar identity) — plus .ppkg file metadata, registry package metadata, and best-effort content indicators. The PPKG analyze rules (ANALYZE-SEC-005/006) iterate the array against an allow-list regex. PPKGs can be legitimate bulk enrollment or a manipulation vector." }, ]; /** Lookup a single entry by its canonical value (case-insensitive). */