mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
feat(ppkg): array-aware analyze rules + allow-list + size-chunked scan event
Reworks PPKG detection to one aggregate event the rules parse as an array, adds a default + customizable allow-list, and makes the event storage-safe. Rule engine: - New condition source event_data_array + ItemField: iterates an array field (any-element match), with dot-path dataField support. (RuleEngine.ConditionEvaluators) Agent: - Drop per-package provisioning_package_detected events (noise: a clean Win11 VM ships ~22 OS-inbox .ppkg). One provisioning_package_scan carries an artifacts[] array; anyPpkgFound derived from it (consistent, incl. recovery residue). - Size-based chunking: split artifacts across multiple scan events so each event's Newtonsoft DataJson stays < 24k (well under the 30k Table-Storage truncation that would blind the rule); never drops a tail artifact. Per-artifact string fields (identity/dir/rank/installTime) hard-capped at 256; scanErrors capped. Rules: - ANALYZE-SEC-005 (default on) + SEC-006 (template) fire on artifacts.identity not matching an anchored allow-list (^(?:...)\b) that covers OS-inbox families. - ANALYZE-SEC-007 alarms when a per-source collection cap was hit (scanTruncated). Web/MCP/docs: event_data_array in the rule form (types + fields), analyze-rules docs section + example, event-type catalogs. Tests: rule-engine array/anchor/dot-path/SEC-007; agent artifacts/chunking/field-cap /worst-case-size. Build green; V2.Core 1236; backend rule + web tsc green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+1
-1
Submodule .claude/commands updated: 17a6f5bec2...3aa9ac70cd
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
+71
-27
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
+212
-22
@@ -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<Dictionary<string, object>>)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<Dictionary<string, object>>)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<Dictionary<string, object>>)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<Dictionary<string, object>>)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<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);
|
||||
Assert.False(payload.ContainsKey("packages"));
|
||||
var artifacts = (List<Dictionary<string, object>>)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<string, object>)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<string>)payload["recoveryCustomizationsFiles"]);
|
||||
|
||||
var detected = ProvisioningPackageCollector.BuildDetectedEvents(findings);
|
||||
var residue = Assert.Single(detected);
|
||||
var artifacts = (List<Dictionary<string, object>>)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<Dictionary<string, object>>)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<Dictionary<string, object>>)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<Dictionary<string, object>>)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<Dictionary<string, object>>)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<Dictionary<string, object>>)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<Dictionary<string, object>>)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<Dictionary<string, object>>)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<string, object> 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<Dictionary<string, object>>)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]
|
||||
|
||||
+179
-100
@@ -13,8 +13,9 @@ 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.
|
||||
/// Scans the device for provisioning-package (PPKG) traces and emits one or more
|
||||
/// <see cref="Constants.EventTypes.ProvisioningPackageScan"/> events with the raw facts (the
|
||||
/// artifact set is split across multiple size-bounded chunk events when large).
|
||||
/// <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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/// <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").
|
||||
/// Runs the full scan and emits one or more <c>provisioning_package_scan</c> 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
|
||||
/// <c>scanErrors</c> and an event is still emitted (so the backend can distinguish
|
||||
/// "scanned clean" from "never scanned").
|
||||
/// </summary>
|
||||
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<Dictionary<string, object>>)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<string, object> fullPayload, List<Dictionary<string, object>> 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// Splits the scan payload into one-or-more per-event payloads so each event's serialized
|
||||
/// DataJson stays under <see cref="MaxChunkJsonBytes"/> (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 <c>chunkIndex</c>/<c>chunkCount</c>.
|
||||
/// The rule engine evaluates the event_data_array condition across all chunk events, so the
|
||||
/// complete artifact set is always considered.
|
||||
/// </summary>
|
||||
internal static List<Dictionary<string, object>> BuildChunkedPayloads(
|
||||
Dictionary<string, object> fullPayload,
|
||||
List<Dictionary<string, object>> artifacts)
|
||||
{
|
||||
var total = artifacts.Count;
|
||||
var scanTruncated = fullPayload.TryGetValue("scanTruncated", out var st) && st is bool stb && stb;
|
||||
var result = new List<Dictionary<string, object>>();
|
||||
var emptyArtifacts = new List<Dictionary<string, object>>();
|
||||
|
||||
if (total == 0)
|
||||
{
|
||||
fullPayload["artifacts"] = emptyArtifacts;
|
||||
fullPayload["chunkIndex"] = 0;
|
||||
fullPayload["chunkCount"] = 1;
|
||||
return new List<Dictionary<string, object>> { 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<List<Dictionary<string, object>>>();
|
||||
var current = new List<Dictionary<string, object>>();
|
||||
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<Dictionary<string, object>>();
|
||||
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<string, object> NewContinuationChunk(int total, bool scanTruncated, List<Dictionary<string, object>> artifacts)
|
||||
=> new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["anyPpkgFound"] = true,
|
||||
["artifacts"] = artifacts,
|
||||
["artifactCount"] = total,
|
||||
["scanTruncated"] = scanTruncated,
|
||||
};
|
||||
|
||||
/// <summary>Serialized length with the transport's serializer (Newtonsoft) — matches the stored DataJson.</summary>
|
||||
private static int JsonLength(object value)
|
||||
=> Newtonsoft.Json.JsonConvert.SerializeObject(value, Newtonsoft.Json.Formatting.None).Length;
|
||||
|
||||
/// <summary>
|
||||
/// The full set of per-artifact descriptors embedded in the aggregate scan event's
|
||||
/// <c>artifacts[]</c> array (one per registry package, one per .ppkg file, one per
|
||||
/// Recovery\Customizations residue file not already covered by a .ppkg file event).
|
||||
/// <c>BuildPayload</c> embeds this AND derives <c>anyPpkgFound</c> from it, so the scan
|
||||
/// verdict and what the analyze rules iterate 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.
|
||||
/// so it is NOT silently dropped here — it still gets a (recovery_residue) artifact entry.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static List<Dictionary<string, object>> BuildDetectedEvents(ProvisioningScanFindings findings)
|
||||
@@ -172,25 +277,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Provisioning
|
||||
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.
|
||||
// ----------------------------------------------------------------------------------
|
||||
@@ -371,52 +457,39 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Provisioning
|
||||
|
||||
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();
|
||||
// `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<string, object>
|
||||
{
|
||||
["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));
|
||||
}
|
||||
|
||||
/// <summary>Hard-caps a per-artifact string field (tail-trim is safe — allow-list is anchored at start).</summary>
|
||||
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<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),
|
||||
};
|
||||
// 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<string, object> 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<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),
|
||||
["dir"] = CapField(file.Directory),
|
||||
["sizeBytes"] = file.SizeBytes,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(file.LastWriteUtc)) data["lastWriteUtc"] = file.LastWriteUtc!;
|
||||
return data;
|
||||
}
|
||||
|
||||
internal static Dictionary<string, object> BuildRecoveryResidueEventData(string fileName, string? dir)
|
||||
{
|
||||
// identity == file name; keep `dir` for location context.
|
||||
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),
|
||||
["dir"] = CapField(dir ?? string.Empty),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
938
|
||||
953
|
||||
|
||||
@@ -1 +1 @@
|
||||
1419
|
||||
1425
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the <c>event_data_array</c> condition source: it iterates an array field on a single
|
||||
/// event (e.g. provisioning_package_scan's <c>artifacts</c>) and matches when ANY element's
|
||||
/// <c>itemField</c> 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.
|
||||
/// </summary>
|
||||
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<EnrollmentEvent>
|
||||
{
|
||||
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<EnrollmentEvent>
|
||||
{
|
||||
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<IDictionary<string, object>>(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<EnrollmentEvent>
|
||||
{
|
||||
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<string, object>)result.MatchedConditions["ppkg_not_allowlisted"];
|
||||
Assert.Equal(2, Convert.ToInt32(evidence["matchCount"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyArtifacts_RuleDoesNotFire()
|
||||
{
|
||||
var events = new List<EnrollmentEvent> { 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<EnrollmentEvent>
|
||||
{
|
||||
ScanEvent("Contoso.Power.Settings.Backdoor.ppkg")
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(MakeAllowListRule(), events);
|
||||
|
||||
var result = Assert.Single(outcome.Results);
|
||||
var evidence = (IDictionary<string, object>)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<string, object>
|
||||
{
|
||||
["scan"] = new Dictionary<string, object>
|
||||
{
|
||||
["artifacts"] = new List<object>
|
||||
{
|
||||
new Dictionary<string, object> { ["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<EnrollmentEvent> { 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<RuleCondition>
|
||||
{
|
||||
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<string, object> { ["scanTruncated"] = true, ["artifacts"] = new List<object>() }
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, new List<EnrollmentEvent> { 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<RuleCondition>
|
||||
{
|
||||
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<object> of Dictionary<string,object>.
|
||||
var artifacts = new List<object>();
|
||||
foreach (var id in identities)
|
||||
artifacts.Add(new Dictionary<string, object> { ["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<string, object>
|
||||
{
|
||||
["anyPpkgFound"] = identities.Length > 0,
|
||||
["artifacts"] = artifacts
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<AnalysisOutcome> RunAsync(AnalyzeRule rule, List<EnrollmentEvent> events)
|
||||
{
|
||||
var ruleRepo = new Mock<IRuleRepository>();
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule> { rule });
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule>());
|
||||
ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(new List<RuleResult>());
|
||||
|
||||
var sessionRepo = new Mock<ISessionRepository>();
|
||||
sessionRepo.Setup(s => s.GetSessionEventsAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
|
||||
|
||||
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<RuleEngineArrayConditionTests>.Instance);
|
||||
|
||||
return await engine.AnalyzeSessionAsync(TenantId, SessionId);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a condition against an ARRAY field on the event (e.g. provisioning_package_scan's
|
||||
/// <c>artifacts</c>). For each element, the element's <see cref="RuleCondition.ItemField"/>
|
||||
/// (e.g. <c>identity</c>) 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. <c>not_regex</c> against an allow-list to
|
||||
/// fire only for array elements NOT on the list. Evidence carries the matched item value
|
||||
/// under <c>field</c>=ItemField (so <c>{{itemField}}</c> interpolates) plus a capped sample.
|
||||
/// </summary>
|
||||
private (bool matched, object evidence) EvaluateEventDataArrayCondition(RuleCondition condition, List<EnrollmentEvent> 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<string>();
|
||||
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<string, object>
|
||||
{
|
||||
["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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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. <c>foo.artifacts</c>). Returns the raw value,
|
||||
/// so an array stays an array for <see cref="AsEnumerable"/>.
|
||||
/// </summary>
|
||||
private static object? GetDataFieldRaw(Dictionary<string, object> 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;
|
||||
}
|
||||
|
||||
/// <summary>Walks a dot-path through nested dictionaries / JsonElement objects, returning the raw value.</summary>
|
||||
private static object? ResolveDotPathRaw(IDictionary<string, object> root, string path)
|
||||
{
|
||||
object? current = root;
|
||||
foreach (var part in path.Split('.'))
|
||||
{
|
||||
switch (current)
|
||||
{
|
||||
case System.Collections.Generic.IDictionary<string, object> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static System.Collections.Generic.IEnumerable<object>? AsEnumerable(object? value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case null:
|
||||
return null;
|
||||
case string:
|
||||
return null;
|
||||
case JsonElement jel:
|
||||
return jel.ValueKind == JsonValueKind.Array ? jel.EnumerateArray().Cast<object>() : null;
|
||||
case System.Collections.IEnumerable en:
|
||||
return en.Cast<object>();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <paramref name="itemField"/> is empty).
|
||||
/// </summary>
|
||||
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<string, object>) return null;
|
||||
return element.ToString();
|
||||
}
|
||||
|
||||
switch (element)
|
||||
{
|
||||
case System.Collections.Generic.IDictionary<string, object> 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<EnrollmentEvent> events)
|
||||
{
|
||||
var matchingEvents = events.Where(e => MatchesEventType(e, condition.EventType)).ToList();
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -194,11 +194,20 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// <summary>
|
||||
/// 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").
|
||||
/// </summary>
|
||||
public string DataField { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public string ItemField { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
|
||||
+18
-2
@@ -113,6 +113,7 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
|
||||
<div className="space-y-3">
|
||||
{form.conditions.map((cond, idx) => {
|
||||
const isCorrelation = cond.source === "event_correlation";
|
||||
const isArray = cond.source === "event_data_array";
|
||||
const updateCond = (patch: Partial<RuleCondition>) => {
|
||||
const c = [...form.conditions];
|
||||
c[idx] = { ...c[idx], ...patch };
|
||||
@@ -124,6 +125,7 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
|
||||
<span className="text-xs font-medium text-gray-500">
|
||||
Condition {idx + 1}
|
||||
{isCorrelation && <span className="ml-2 px-1.5 py-0.5 bg-indigo-100 text-indigo-700 rounded text-xs">event_correlation</span>}
|
||||
{isArray && <span className="ml-2 px-1.5 py-0.5 bg-teal-100 text-teal-700 rounded text-xs">event_data_array</span>}
|
||||
</span>
|
||||
{form.conditions.length > 1 && (
|
||||
<button type="button" onClick={() => setForm({ ...form, conditions: form.conditions.filter((_, i) => i !== idx) })} className="text-xs text-red-500 hover:text-red-700">Remove</button>
|
||||
@@ -141,11 +143,11 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
|
||||
|
||||
{/* Row 2: Data field / Operator / Value / Required */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-2">
|
||||
<input type="text" value={cond.dataField} onChange={(e) => 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" />
|
||||
<input type="text" value={cond.dataField} onChange={(e) => 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" />
|
||||
<select value={cond.operator} onChange={(e) => updateCond({ operator: e.target.value })} className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 bg-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
{OPERATORS.map((o) => (<option key={o} value={o}>{o}</option>))}
|
||||
</select>
|
||||
<input type="text" value={cond.value} onChange={(e) => 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" />
|
||||
<input type="text" value={cond.value} onChange={(e) => 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" />
|
||||
<label className="flex items-center space-x-2 text-sm text-gray-700">
|
||||
<input type="checkbox" checked={cond.required} onChange={(e) => updateCond({ required: e.target.checked })} className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
<span>Required</span>
|
||||
@@ -186,6 +188,20 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* event_data_array extra field */}
|
||||
{isArray && (
|
||||
<div className="pt-2 border-t border-teal-200 space-y-2">
|
||||
<p className="text-xs font-medium text-teal-700">Array settings</p>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Item field <span className="text-gray-400">(sub-field tested on each array element)</span></label>
|
||||
<input type="text" value={cond.itemField ?? ""} onChange={(e) => 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" />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
<strong>Data field</strong> is the array to iterate (e.g. <code className="bg-gray-100 px-1 rounded">artifacts</code>); the operator/value test runs against <strong>Item field</strong> on each element. The condition matches when <strong>any</strong> element matches — e.g. <code className="bg-gray-100 px-1 rounded">not_regex</code> against an allow-list fires for any element not on the list.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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<string, { bg: string; text: string; border: string; dot: string }> = {
|
||||
@@ -137,6 +139,7 @@ export const EMPTY_CONDITION: RuleCondition = {
|
||||
operator: "contains",
|
||||
value: "",
|
||||
required: true,
|
||||
itemField: "",
|
||||
correlateEventType: "",
|
||||
joinField: "",
|
||||
timeWindowSeconds: null,
|
||||
|
||||
@@ -98,6 +98,29 @@ export function SectionAnalyzeRules() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* event_data_array */}
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2.5 bg-gray-100 border-b border-gray-200">
|
||||
<p className="font-semibold text-sm text-gray-900">Source: <code className="font-mono">event_data_array</code></p>
|
||||
</div>
|
||||
<div className="px-4 py-4 space-y-2 text-sm text-gray-700">
|
||||
<p>Iterates an <strong>array field</strong> inside a single event and tests a sub-field on each element. The condition matches when <strong>any</strong> 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.</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
|
||||
<div><p className="font-medium text-gray-900">Data Field</p><p className="text-gray-500">The array to iterate (e.g. <code className="bg-gray-100 px-1 rounded">artifacts</code>)</p></div>
|
||||
<div><p className="font-medium text-gray-900">Item Field</p><p className="text-gray-500">Sub-field on each element to test (e.g. <code className="bg-gray-100 px-1 rounded">identity</code>). Leave empty to test scalar elements.</p></div>
|
||||
<div><p className="font-medium text-gray-900">Operator / Value</p><p className="text-gray-500">Same operators as <code className="bg-gray-100 px-1 rounded">event_data</code>; applied to each element</p></div>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded text-xs text-blue-900">
|
||||
<strong>Allow-list pattern:</strong> pair <code className="bg-blue-100 px-1 rounded">not_regex</code> with an allow-list regex in <em>Value</em>. The condition then fires only for elements <strong>not</strong> on the list — every allow-listed element is ignored. Evidence carries the first non-matching item (under the item-field name, so <code className="bg-blue-100 px-1 rounded">{`{{identity}}`}</code> interpolates) plus a <code className="bg-blue-100 px-1 rounded">matchCount</code>.
|
||||
</div>
|
||||
<div className="p-3 bg-green-50 border border-green-200 rounded-lg text-xs">
|
||||
<p className="font-semibold text-green-900 mb-1">Example — flag a provisioning package not on the allow-list</p>
|
||||
<p>Source: <strong>event_data_array</strong> · Event Type: <strong>provisioning_package_scan</strong> · Data Field: <strong>artifacts</strong> · Item Field: <strong>identity</strong> · Operator: <strong>not_regex</strong> · Value: <strong>{`^(?:Power\\.Settings|SecureStart\\.Settings)\\b`}</strong></p>
|
||||
<p className="text-green-700 mt-1">Anchor the allow-list with <code className="bg-green-100 px-1 rounded">^</code> (start) and <code className="bg-green-100 px-1 rounded">\b</code> (boundary) — an unanchored substring pattern would also allow-list an impostor like <code className="bg-green-100 px-1 rounded">Contoso.Power.Settings.Backdoor</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* event_count */}
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2.5 bg-gray-100 border-b border-gray-200">
|
||||
@@ -315,6 +338,33 @@ export function SectionAnalyzeRules() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Example 6 */}
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2.5 bg-indigo-50 border-b border-indigo-200">
|
||||
<p className="font-semibold text-sm text-indigo-900">Example 6 — Unexpected Provisioning Package (array + allow-list)</p>
|
||||
<p className="text-xs text-indigo-700 mt-0.5">Fire for any provisioning package (PPKG) that is not on an allow-list — from the scan event(s) that list them all</p>
|
||||
</div>
|
||||
<div className="px-4 py-4 text-xs space-y-2">
|
||||
<div className="p-2 bg-gray-100 rounded text-gray-600">
|
||||
The agent emits a <code>provisioning_package_scan</code> event whose <code>artifacts</code> array lists every PPKG it found (each with an <code>identity</code>). This rule iterates that array instead of expecting one event per package. On devices with very many packages the array is split across several <code>provisioning_package_scan</code> events (<code>chunkIndex</code>/<code>chunkCount</code>) — <code>event_data_array</code> evaluates across all of them, so nothing is missed. (A separate built-in rule, <strong>ANALYZE-SEC-007</strong>, flags the rare case where a per-source collection cap was hit.)
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<div><p className="text-gray-500">Category</p><p className="font-medium">security</p></div>
|
||||
<div><p className="text-gray-500">Severity</p><p className="font-medium">warning</p></div>
|
||||
<div><p className="text-gray-500">Base Confidence</p><p className="font-medium">80</p></div>
|
||||
<div><p className="text-gray-500">Threshold</p><p className="font-medium">40</p></div>
|
||||
</div>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded px-3 py-2 font-mono space-y-1">
|
||||
<p className="text-gray-400">{`// Condition (required) — any artifact identity NOT on the allow-list`}</p>
|
||||
<p>{`source: "event_data_array" eventType: "provisioning_package_scan"`}</p>
|
||||
<p>{`dataField: "artifacts" itemField: "identity" operator: "not_regex"`}</p>
|
||||
<p>{`value: "^(?:Microsoft\\.Windows\\.Cosa|Power\\.EnergyEstimationEngine|Power\\.Settings|SecureStart\\.Settings)\\b"`}</p>
|
||||
<p className="text-gray-400 pt-1">{`// Explanation can reference the offending item: "Unexpected package: {{identity}}"`}</p>
|
||||
</div>
|
||||
<p className="text-gray-600">The allow-list above covers the Windows OS-inbox packages that ship on every device, so a clean machine stays quiet. The pattern is <strong>anchored</strong> (<code className="bg-gray-100 px-1 rounded">^(?:…)\b</code>), so to allow your own vendor/recovery packages insert a new alternative <strong>inside the group, before the closing <code className="bg-gray-100 px-1 rounded">)</code></strong> — e.g. <code className="bg-gray-100 px-1 rounded">{`…|SecureStart\\.Settings|Acme\\.Recovery)\\b`}</code> (appending after the <code className="bg-gray-100 px-1 rounded">)</code> won't match). The built-in rules <strong>ANALYZE-SEC-005</strong> (always on) and <strong>ANALYZE-SEC-006</strong> (template, customizable allow-list) use exactly this pattern.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Template Rules ── */}
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user