diff --git a/rules/analyze/ANALYZE-DEV-013.json b/rules/analyze/ANALYZE-DEV-013.json
new file mode 100644
index 00000000..e45cdd61
--- /dev/null
+++ b/rules/analyze/ANALYZE-DEV-013.json
@@ -0,0 +1,118 @@
+{
+ "$schema": "../schema/analyze-rule.schema.json",
+ "ruleId": "ANALYZE-DEV-013",
+ "title": "OOBE Quality Update Failed",
+ "description": "Detects that the Windows quality update page at the end of OOBE (Enrollment Status Page setting \"Install Windows quality updates\") reported a failed download or install and recorded no successful install afterwards — the page's own failure names on the oobe_update_page events. Setup usually continues, so the failure is otherwise invisible: the device finishes OOBE without the monthly security update. An OS build change across a restart (os_build_changed) vetoes the finding, and so does the agent's names_capped marker — once the page's event names were capped, a missing success name proves nothing. A downloadInstallFailureHelper entry alone is no failure: updates that installed fine log it too.",
+ "severity": "warning",
+ "category": "device",
+ "version": "1.0.0",
+ "enabled": true,
+ "evaluateOn": [
+ "enrollment_end"
+ ],
+ "trigger": "single",
+ "baseConfidence": 75,
+ "confidenceThreshold": 60,
+ "conditions": [
+ {
+ "signal": "update_failure",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "name",
+ "operator": "in",
+ "value": "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError,SdxWebAppCloudNDUP_processStatusChangeFromHandler_installFailedError,ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure",
+ "required": true
+ },
+ {
+ "signal": "no_install_success",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "name",
+ "operator": "not_exists",
+ "value": "",
+ "filterField": "name",
+ "filterOperator": "regex",
+ "filterValue": "installSucceededRebootRequired|commitExpeditionDownloadInstallAsyncSucceeded",
+ "required": true
+ },
+ {
+ "signal": "os_build_unchanged",
+ "source": "event_type",
+ "eventType": "os_build_changed",
+ "operator": "not_exists",
+ "value": "",
+ "required": true
+ },
+ {
+ "signal": "page_names_complete",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "cxhEvent",
+ "operator": "not_exists",
+ "value": "",
+ "filterField": "cxhEvent",
+ "filterOperator": "equals",
+ "filterValue": "names_capped",
+ "required": true
+ },
+ {
+ "signal": "update_page_failed",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "result",
+ "operator": "equals",
+ "value": "fail",
+ "filterField": "page",
+ "filterOperator": "equals",
+ "filterValue": "OobeNDUP",
+ "required": false
+ },
+ {
+ "signal": "update_skipped",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "name",
+ "operator": "regex",
+ "value": "SkipDownloadInstallButtonClicked$",
+ "required": false
+ }
+ ],
+ "confidenceFactors": [
+ {
+ "signal": "update_page_failed",
+ "condition": "exists",
+ "weight": 10
+ }
+ ],
+ "explanation": "The Windows quality update page at the end of OOBE reported `{{update_failure}}`, and no successful install was recorded afterwards. When the download or install of the update fails, the page gives up and setup continues, so the enrollment itself can still succeed — but the device most likely finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment. Selecting Skip on the page after the failure leads to the same result.",
+ "remediation": [
+ {
+ "title": "Find out why the update failed",
+ "steps": [
+ "Open the oobe_update_page events around the failure in the session timeline: the download progress before it shows how far the download got, and a Skip after it shows that someone chose to continue.",
+ "Check the network path the device uses during OOBE for Windows Update and Delivery Optimization: proxy, TLS inspection and firewall rules for the Windows Update endpoints.",
+ "Compare with other devices: the same failure on many devices of one site points at the network path, on one model at the device, and across the fleet at the update itself."
+ ]
+ },
+ {
+ "title": "Make sure the device gets the update",
+ "steps": [
+ "Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule.",
+ "Where the OOBE quality update keeps failing for one network or device group, decide per Enrollment Status Page profile whether \"Install Windows quality updates\" should stay on."
+ ]
+ }
+ ],
+ "relatedDocs": [
+ {
+ "title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
+ "url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
+ }
+ ],
+ "markSessionAsFailedDefault": false,
+ "tags": [
+ "device",
+ "windows-update",
+ "oobe",
+ "quality-update"
+ ]
+}
diff --git a/rules/analyze/ANALYZE-DEV-014.json b/rules/analyze/ANALYZE-DEV-014.json
new file mode 100644
index 00000000..6bd971f8
--- /dev/null
+++ b/rules/analyze/ANALYZE-DEV-014.json
@@ -0,0 +1,119 @@
+{
+ "$schema": "../schema/analyze-rule.schema.json",
+ "ruleId": "ANALYZE-DEV-014",
+ "title": "OOBE Quality Update Not Offered After an Earlier Failure",
+ "description": "Detects that the Windows quality update page at the end of OOBE found no update to install although the device is out of date, on a device whose update page counts an earlier failed download or install (oobe_update_page: NDUPDownloadInstallPreviousFailureCount above 0, osUpdatesWithNDUPPI false, getDaysOutOfDateSucceeded above 0). No failure and no install success are recorded in this session — a failure in this session is ANALYZE-DEV-013 — and the agent's names_capped marker vetoes the finding, because a missing name proves nothing once the page's names were capped. The device finishes OOBE without the monthly security update.",
+ "severity": "warning",
+ "category": "device",
+ "version": "1.0.0",
+ "enabled": true,
+ "evaluateOn": [
+ "enrollment_end"
+ ],
+ "trigger": "single",
+ "baseConfidence": 75,
+ "confidenceThreshold": 60,
+ "conditions": [
+ {
+ "signal": "previous_failures",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "value",
+ "operator": "gt",
+ "value": "0",
+ "filterField": "name",
+ "filterOperator": "equals",
+ "filterValue": "SdxWebAppCloudNDUP_initialize_NDUPDownloadInstallPreviousFailureCount",
+ "required": true
+ },
+ {
+ "signal": "nothing_offered",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "value",
+ "operator": "equals",
+ "value": "false",
+ "filterField": "name",
+ "filterOperator": "equals",
+ "filterValue": "SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI",
+ "required": true
+ },
+ {
+ "signal": "days_out_of_date",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "value",
+ "operator": "gt",
+ "value": "0",
+ "filterField": "name",
+ "filterOperator": "equals",
+ "filterValue": "ExpeditedUpdate_getDaysOutOfDateSucceeded",
+ "required": true
+ },
+ {
+ "signal": "no_update_failure",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "name",
+ "operator": "not_exists",
+ "value": "",
+ "filterField": "name",
+ "filterOperator": "in",
+ "filterValue": "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError,SdxWebAppCloudNDUP_processStatusChangeFromHandler_installFailedError,ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure",
+ "required": true
+ },
+ {
+ "signal": "no_install_success",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "name",
+ "operator": "not_exists",
+ "value": "",
+ "filterField": "name",
+ "filterOperator": "regex",
+ "filterValue": "installSucceededRebootRequired|commitExpeditionDownloadInstallAsyncSucceeded",
+ "required": true
+ },
+ {
+ "signal": "page_names_complete",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "cxhEvent",
+ "operator": "not_exists",
+ "value": "",
+ "filterField": "cxhEvent",
+ "filterOperator": "equals",
+ "filterValue": "names_capped",
+ "required": true
+ }
+ ],
+ "explanation": "The Windows quality update page at the end of OOBE checked for updates and found none to install, although Windows reports the device as {{days_out_of_date}} days out of date. The page's failure counter for this device stands at {{previous_failures}}: an earlier download or install of an OOBE quality update on this device failed. The device finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment.",
+ "remediation": [
+ {
+ "title": "Check the earlier attempt",
+ "steps": [
+ "Look at the device's earlier enrollment sessions — the failed attempt and its ANALYZE-DEV-013 finding are usually there.",
+ "Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule."
+ ]
+ },
+ {
+ "title": "Reduce how far behind devices start",
+ "steps": [
+ "Devices that start OOBE many months behind need a long update during setup; a current OS image or factory build shortens it and gives a failed attempt less to repeat."
+ ]
+ }
+ ],
+ "relatedDocs": [
+ {
+ "title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
+ "url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
+ }
+ ],
+ "markSessionAsFailedDefault": false,
+ "tags": [
+ "device",
+ "windows-update",
+ "oobe",
+ "quality-update"
+ ]
+}
diff --git a/rules/analyze/ANALYZE-DEV-015.json b/rules/analyze/ANALYZE-DEV-015.json
new file mode 100644
index 00000000..c4ca785d
--- /dev/null
+++ b/rules/analyze/ANALYZE-DEV-015.json
@@ -0,0 +1,117 @@
+{
+ "$schema": "../schema/analyze-rule.schema.json",
+ "ruleId": "ANALYZE-DEV-015",
+ "title": "OOBE Update Check Ended Without a Result",
+ "description": "Detects that the Windows quality update page at the end of OOBE started its Windows Update scan but ended without a scan result (oobe_update_page: startWUScanStartedSuccedded, an OobeNDUP stop and no ExpeditedUpdate_getUpdateResults name), on a device that is out of date and allowed to update. In the observed sessions the page ended two minutes after the scan started, on devices more than a year behind. The device finishes OOBE without the monthly security update. The agent's names_capped marker vetoes the finding: once the page's event names were capped, a missing scan result proves nothing.",
+ "severity": "warning",
+ "category": "device",
+ "version": "1.0.0",
+ "enabled": true,
+ "evaluateOn": [
+ "enrollment_end"
+ ],
+ "trigger": "single",
+ "baseConfidence": 75,
+ "confidenceThreshold": 60,
+ "conditions": [
+ {
+ "signal": "update_allowed",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "value",
+ "operator": "equals",
+ "value": "true",
+ "filterField": "name",
+ "filterOperator": "equals",
+ "filterValue": "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded",
+ "required": true
+ },
+ {
+ "signal": "scan_started",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "name",
+ "operator": "equals",
+ "value": "ExpeditedUpdate_startWUScanStartedSuccedded",
+ "required": true
+ },
+ {
+ "signal": "no_scan_result",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "name",
+ "operator": "not_exists",
+ "value": "",
+ "filterField": "name",
+ "filterOperator": "regex",
+ "filterValue": "^ExpeditedUpdate_getUpdateResults",
+ "required": true
+ },
+ {
+ "signal": "update_page_ended",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "cxhEvent",
+ "operator": "equals",
+ "value": "page_stopped",
+ "filterField": "page",
+ "filterOperator": "equals",
+ "filterValue": "OobeNDUP",
+ "required": true
+ },
+ {
+ "signal": "days_out_of_date",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "value",
+ "operator": "gt",
+ "value": "0",
+ "filterField": "name",
+ "filterOperator": "equals",
+ "filterValue": "ExpeditedUpdate_getDaysOutOfDateSucceeded",
+ "required": true
+ },
+ {
+ "signal": "page_names_complete",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "cxhEvent",
+ "operator": "not_exists",
+ "value": "",
+ "filterField": "cxhEvent",
+ "filterOperator": "equals",
+ "filterValue": "names_capped",
+ "required": true
+ }
+ ],
+ "explanation": "The Windows quality update page at the end of OOBE started a Windows Update scan, but no scan result arrived before the page ended. The device is {{days_out_of_date}} days out of date, so an update was due — the device finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment. The page waits only a short time for the scan (two minutes in the observed sessions): a scan that takes longer, or cannot reach Windows Update, ends the page without an update.",
+ "remediation": [
+ {
+ "title": "Check the path to Windows Update during OOBE",
+ "steps": [
+ "Verify that devices in OOBE reach the Windows Update service: proxy, TLS inspection and firewall rules for the Windows Update endpoints.",
+ "Check whether an update policy applied during the Enrollment Status Page points the scan at another source, such as WSUS."
+ ]
+ },
+ {
+ "title": "Shorten the scan",
+ "steps": [
+ "Devices far behind scan longer; a current OS image or factory build keeps the OOBE scan short.",
+ "Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule."
+ ]
+ }
+ ],
+ "relatedDocs": [
+ {
+ "title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
+ "url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
+ }
+ ],
+ "markSessionAsFailedDefault": false,
+ "tags": [
+ "device",
+ "windows-update",
+ "oobe",
+ "quality-update"
+ ]
+}
diff --git a/rules/dist/analyze-rules.json b/rules/dist/analyze-rules.json
index 8bdc7c34..20b225f2 100644
--- a/rules/dist/analyze-rules.json
+++ b/rules/dist/analyze-rules.json
@@ -1930,6 +1930,357 @@
"environment"
]
},
+ {
+ "ruleId": "ANALYZE-DEV-013",
+ "title": "OOBE Quality Update Failed",
+ "description": "Detects that the Windows quality update page at the end of OOBE (Enrollment Status Page setting \"Install Windows quality updates\") reported a failed download or install and recorded no successful install afterwards — the page's own failure names on the oobe_update_page events. Setup usually continues, so the failure is otherwise invisible: the device finishes OOBE without the monthly security update. An OS build change across a restart (os_build_changed) vetoes the finding, and so does the agent's names_capped marker — once the page's event names were capped, a missing success name proves nothing. A downloadInstallFailureHelper entry alone is no failure: updates that installed fine log it too.",
+ "severity": "warning",
+ "category": "device",
+ "version": "1.0.0",
+ "enabled": true,
+ "evaluateOn": [
+ "enrollment_end"
+ ],
+ "trigger": "single",
+ "baseConfidence": 75,
+ "confidenceThreshold": 60,
+ "conditions": [
+ {
+ "signal": "update_failure",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "name",
+ "operator": "in",
+ "value": "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError,SdxWebAppCloudNDUP_processStatusChangeFromHandler_installFailedError,ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure",
+ "required": true
+ },
+ {
+ "signal": "no_install_success",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "name",
+ "operator": "not_exists",
+ "value": "",
+ "filterField": "name",
+ "filterOperator": "regex",
+ "filterValue": "installSucceededRebootRequired|commitExpeditionDownloadInstallAsyncSucceeded",
+ "required": true
+ },
+ {
+ "signal": "os_build_unchanged",
+ "source": "event_type",
+ "eventType": "os_build_changed",
+ "operator": "not_exists",
+ "value": "",
+ "required": true
+ },
+ {
+ "signal": "page_names_complete",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "cxhEvent",
+ "operator": "not_exists",
+ "value": "",
+ "filterField": "cxhEvent",
+ "filterOperator": "equals",
+ "filterValue": "names_capped",
+ "required": true
+ },
+ {
+ "signal": "update_page_failed",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "result",
+ "operator": "equals",
+ "value": "fail",
+ "filterField": "page",
+ "filterOperator": "equals",
+ "filterValue": "OobeNDUP",
+ "required": false
+ },
+ {
+ "signal": "update_skipped",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "name",
+ "operator": "regex",
+ "value": "SkipDownloadInstallButtonClicked$",
+ "required": false
+ }
+ ],
+ "confidenceFactors": [
+ {
+ "signal": "update_page_failed",
+ "condition": "exists",
+ "weight": 10
+ }
+ ],
+ "explanation": "The Windows quality update page at the end of OOBE reported `{{update_failure}}`, and no successful install was recorded afterwards. When the download or install of the update fails, the page gives up and setup continues, so the enrollment itself can still succeed — but the device most likely finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment. Selecting Skip on the page after the failure leads to the same result.",
+ "remediation": [
+ {
+ "title": "Find out why the update failed",
+ "steps": [
+ "Open the oobe_update_page events around the failure in the session timeline: the download progress before it shows how far the download got, and a Skip after it shows that someone chose to continue.",
+ "Check the network path the device uses during OOBE for Windows Update and Delivery Optimization: proxy, TLS inspection and firewall rules for the Windows Update endpoints.",
+ "Compare with other devices: the same failure on many devices of one site points at the network path, on one model at the device, and across the fleet at the update itself."
+ ]
+ },
+ {
+ "title": "Make sure the device gets the update",
+ "steps": [
+ "Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule.",
+ "Where the OOBE quality update keeps failing for one network or device group, decide per Enrollment Status Page profile whether \"Install Windows quality updates\" should stay on."
+ ]
+ }
+ ],
+ "relatedDocs": [
+ {
+ "title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
+ "url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
+ }
+ ],
+ "markSessionAsFailedDefault": false,
+ "tags": [
+ "device",
+ "windows-update",
+ "oobe",
+ "quality-update"
+ ]
+ },
+ {
+ "ruleId": "ANALYZE-DEV-014",
+ "title": "OOBE Quality Update Not Offered After an Earlier Failure",
+ "description": "Detects that the Windows quality update page at the end of OOBE found no update to install although the device is out of date, on a device whose update page counts an earlier failed download or install (oobe_update_page: NDUPDownloadInstallPreviousFailureCount above 0, osUpdatesWithNDUPPI false, getDaysOutOfDateSucceeded above 0). No failure and no install success are recorded in this session — a failure in this session is ANALYZE-DEV-013 — and the agent's names_capped marker vetoes the finding, because a missing name proves nothing once the page's names were capped. The device finishes OOBE without the monthly security update.",
+ "severity": "warning",
+ "category": "device",
+ "version": "1.0.0",
+ "enabled": true,
+ "evaluateOn": [
+ "enrollment_end"
+ ],
+ "trigger": "single",
+ "baseConfidence": 75,
+ "confidenceThreshold": 60,
+ "conditions": [
+ {
+ "signal": "previous_failures",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "value",
+ "operator": "gt",
+ "value": "0",
+ "filterField": "name",
+ "filterOperator": "equals",
+ "filterValue": "SdxWebAppCloudNDUP_initialize_NDUPDownloadInstallPreviousFailureCount",
+ "required": true
+ },
+ {
+ "signal": "nothing_offered",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "value",
+ "operator": "equals",
+ "value": "false",
+ "filterField": "name",
+ "filterOperator": "equals",
+ "filterValue": "SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI",
+ "required": true
+ },
+ {
+ "signal": "days_out_of_date",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "value",
+ "operator": "gt",
+ "value": "0",
+ "filterField": "name",
+ "filterOperator": "equals",
+ "filterValue": "ExpeditedUpdate_getDaysOutOfDateSucceeded",
+ "required": true
+ },
+ {
+ "signal": "no_update_failure",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "name",
+ "operator": "not_exists",
+ "value": "",
+ "filterField": "name",
+ "filterOperator": "in",
+ "filterValue": "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError,SdxWebAppCloudNDUP_processStatusChangeFromHandler_installFailedError,ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure",
+ "required": true
+ },
+ {
+ "signal": "no_install_success",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "name",
+ "operator": "not_exists",
+ "value": "",
+ "filterField": "name",
+ "filterOperator": "regex",
+ "filterValue": "installSucceededRebootRequired|commitExpeditionDownloadInstallAsyncSucceeded",
+ "required": true
+ },
+ {
+ "signal": "page_names_complete",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "cxhEvent",
+ "operator": "not_exists",
+ "value": "",
+ "filterField": "cxhEvent",
+ "filterOperator": "equals",
+ "filterValue": "names_capped",
+ "required": true
+ }
+ ],
+ "explanation": "The Windows quality update page at the end of OOBE checked for updates and found none to install, although Windows reports the device as {{days_out_of_date}} days out of date. The page's failure counter for this device stands at {{previous_failures}}: an earlier download or install of an OOBE quality update on this device failed. The device finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment.",
+ "remediation": [
+ {
+ "title": "Check the earlier attempt",
+ "steps": [
+ "Look at the device's earlier enrollment sessions — the failed attempt and its ANALYZE-DEV-013 finding are usually there.",
+ "Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule."
+ ]
+ },
+ {
+ "title": "Reduce how far behind devices start",
+ "steps": [
+ "Devices that start OOBE many months behind need a long update during setup; a current OS image or factory build shortens it and gives a failed attempt less to repeat."
+ ]
+ }
+ ],
+ "relatedDocs": [
+ {
+ "title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
+ "url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
+ }
+ ],
+ "markSessionAsFailedDefault": false,
+ "tags": [
+ "device",
+ "windows-update",
+ "oobe",
+ "quality-update"
+ ]
+ },
+ {
+ "ruleId": "ANALYZE-DEV-015",
+ "title": "OOBE Update Check Ended Without a Result",
+ "description": "Detects that the Windows quality update page at the end of OOBE started its Windows Update scan but ended without a scan result (oobe_update_page: startWUScanStartedSuccedded, an OobeNDUP stop and no ExpeditedUpdate_getUpdateResults name), on a device that is out of date and allowed to update. In the observed sessions the page ended two minutes after the scan started, on devices more than a year behind. The device finishes OOBE without the monthly security update. The agent's names_capped marker vetoes the finding: once the page's event names were capped, a missing scan result proves nothing.",
+ "severity": "warning",
+ "category": "device",
+ "version": "1.0.0",
+ "enabled": true,
+ "evaluateOn": [
+ "enrollment_end"
+ ],
+ "trigger": "single",
+ "baseConfidence": 75,
+ "confidenceThreshold": 60,
+ "conditions": [
+ {
+ "signal": "update_allowed",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "value",
+ "operator": "equals",
+ "value": "true",
+ "filterField": "name",
+ "filterOperator": "equals",
+ "filterValue": "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded",
+ "required": true
+ },
+ {
+ "signal": "scan_started",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "name",
+ "operator": "equals",
+ "value": "ExpeditedUpdate_startWUScanStartedSuccedded",
+ "required": true
+ },
+ {
+ "signal": "no_scan_result",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "name",
+ "operator": "not_exists",
+ "value": "",
+ "filterField": "name",
+ "filterOperator": "regex",
+ "filterValue": "^ExpeditedUpdate_getUpdateResults",
+ "required": true
+ },
+ {
+ "signal": "update_page_ended",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "cxhEvent",
+ "operator": "equals",
+ "value": "page_stopped",
+ "filterField": "page",
+ "filterOperator": "equals",
+ "filterValue": "OobeNDUP",
+ "required": true
+ },
+ {
+ "signal": "days_out_of_date",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "value",
+ "operator": "gt",
+ "value": "0",
+ "filterField": "name",
+ "filterOperator": "equals",
+ "filterValue": "ExpeditedUpdate_getDaysOutOfDateSucceeded",
+ "required": true
+ },
+ {
+ "signal": "page_names_complete",
+ "source": "event_data",
+ "eventType": "oobe_update_page",
+ "dataField": "cxhEvent",
+ "operator": "not_exists",
+ "value": "",
+ "filterField": "cxhEvent",
+ "filterOperator": "equals",
+ "filterValue": "names_capped",
+ "required": true
+ }
+ ],
+ "explanation": "The Windows quality update page at the end of OOBE started a Windows Update scan, but no scan result arrived before the page ended. The device is {{days_out_of_date}} days out of date, so an update was due — the device finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment. The page waits only a short time for the scan (two minutes in the observed sessions): a scan that takes longer, or cannot reach Windows Update, ends the page without an update.",
+ "remediation": [
+ {
+ "title": "Check the path to Windows Update during OOBE",
+ "steps": [
+ "Verify that devices in OOBE reach the Windows Update service: proxy, TLS inspection and firewall rules for the Windows Update endpoints.",
+ "Check whether an update policy applied during the Enrollment Status Page points the scan at another source, such as WSUS."
+ ]
+ },
+ {
+ "title": "Shorten the scan",
+ "steps": [
+ "Devices far behind scan longer; a current OS image or factory build keeps the OOBE scan short.",
+ "Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule."
+ ]
+ }
+ ],
+ "relatedDocs": [
+ {
+ "title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
+ "url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
+ }
+ ],
+ "markSessionAsFailedDefault": false,
+ "tags": [
+ "device",
+ "windows-update",
+ "oobe",
+ "quality-update"
+ ]
+ },
{
"ruleId": "ANALYZE-ENRL-001",
"title": "Enrollment Failed",
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/OobeUpdateRuleTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/OobeUpdateRuleTests.cs
new file mode 100644
index 00000000..9470e7f9
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/OobeUpdateRuleTests.cs
@@ -0,0 +1,481 @@
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Models;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// The OOBE quality update page (oobe_update_page, D-310) — pins the built-in analyze rules for
+/// the update that did not happen:
+/// - ANALYZE-DEV-013: the page reported a failed download or install and no install success
+/// (field shapes e7551b82, cb1992b8). Only the explicit failure names count: an update that
+/// installed logs downloadInstallFailureHelper and a start timeout too (e4ecd6f8).
+/// - ANALYZE-DEV-014: nothing offered to an out-of-date device whose page counts an earlier
+/// failure, without a failure in this session (b2d9f12c).
+/// - ANALYZE-DEV-015: the scan started and the page ended without a scan result (c5b20dfa).
+/// Every absence condition is vetoed by the agent's names_capped marker. Sequences follow the
+/// field sessions of 2026-10-03..05 (synthetic times, no customer data); a session never fires
+/// two of the three.
+///
+public class OobeUpdateRuleTests
+{
+ private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
+ private const string SessionId = "11111111-2222-3333-4444-555555555555";
+
+ private static readonly DateTime T0 = new(2026, 10, 3, 16, 56, 59, DateTimeKind.Utc);
+
+ private const string DownloadFailed = "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError";
+ private const string InstallFailed = "SdxWebAppCloudNDUP_processStatusChangeFromHandler_installFailedError";
+ private const string CommitFailed = "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure";
+
+ private static readonly string[] RuleIds = { "ANALYZE-DEV-013", "ANALYZE-DEV-014", "ANALYZE-DEV-015" };
+
+ // ── the rules ───────────────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData("ANALYZE-DEV-013")]
+ [InlineData("ANALYZE-DEV-014")]
+ [InlineData("ANALYZE-DEV-015")]
+ public void Rules_are_enabled_warnings_that_never_fail_the_session(string ruleId)
+ {
+ var rule = Rule(ruleId);
+ Assert.True(rule.Enabled);
+ Assert.Equal("warning", rule.Severity);
+ Assert.Equal("device", rule.Category);
+ Assert.False(rule.MarkSessionAsFailedDefault); // the enrollment continues without the update
+ }
+
+ // ── ANALYZE-DEV-013: failed ─────────────────────────────────────────────
+
+ [Fact]
+ public async Task DEV_013_fires_on_a_failed_download_and_names_the_failure()
+ {
+ var outcome = await RunAsync(FailedDownload());
+
+ var result = Assert.Single(outcome.Results);
+ Assert.Equal("ANALYZE-DEV-013", result.RuleId);
+ Assert.Equal(DownloadFailed, Value(result, "update_failure")); // {{update_failure}}
+ Assert.Equal("fail", Value(result, "update_page_failed"));
+ Assert.Equal(85, result.ConfidenceScore);
+ }
+
+ [Fact]
+ public async Task DEV_013_fires_when_the_update_is_skipped_after_the_failure()
+ {
+ var outcome = await RunAsync(FailureThenSkip());
+
+ var result = Assert.Single(outcome.Results);
+ Assert.Equal("ANALYZE-DEV-013", result.RuleId);
+ Assert.Equal(DownloadFailed, Value(result, "update_failure"));
+ Assert.Equal("CloudNDUPSkipDownloadInstallButtonClicked", Value(result, "update_skipped"));
+ }
+
+ [Theory]
+ [InlineData(InstallFailed)]
+ [InlineData(CommitFailed)]
+ public async Task DEV_013_fires_on_every_explicit_failure_name(string failureName)
+ {
+ var events = Visit(days: 120, previousFailures: 0);
+ events.AddRange(new[] { Name("ExpeditedUpdate_getUpdateResultsSucceeded"), Name(failureName), PageStop("OobeNDUP", "fail") });
+
+ var result = Assert.Single((await RunAsync(events)).Results);
+ Assert.Equal(failureName, Value(result, "update_failure"));
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task DEV_013_stays_silent_on_an_update_that_installed_despite_the_failure_helper(bool buildChangeObserved)
+ {
+ // Field shape e4ecd6f8: the failure helper and a start timeout after "restart required",
+ // then the update installed — with or without the build change on record.
+ var events = InstalledDespiteFailureHelper();
+ if (!buildChangeObserved) events.RemoveAll(e => e.EventType == "os_build_changed");
+
+ Assert.Empty((await RunAsync(events)).Results);
+ }
+
+ [Fact]
+ public async Task DEV_013_stays_silent_when_a_retry_installed_the_update()
+ {
+ var events = FailedDownload();
+ events.AddRange(new[]
+ {
+ PageStart("OobeNDUP"),
+ Name("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
+ Name("SdxWebAppCloudNDUP_processStatusChangeFromHandler_installSucceededRebootRequired_lcu"),
+ });
+
+ Assert.Empty((await RunAsync(events)).Results);
+ }
+
+ [Fact]
+ public async Task DEV_013_is_vetoed_by_an_os_build_change()
+ {
+ var events = FailedDownload();
+ events.Add(OsBuildChanged());
+
+ Assert.Empty((await RunAsync(events)).Results);
+ }
+
+ // ── ANALYZE-DEV-014: not offered after an earlier failure ───────────────
+
+ [Fact]
+ public async Task DEV_014_fires_when_nothing_is_offered_after_an_earlier_failure()
+ {
+ var outcome = await RunAsync(NothingOfferedAfterFailure());
+
+ var result = Assert.Single(outcome.Results);
+ Assert.Equal("ANALYZE-DEV-014", result.RuleId);
+ Assert.Equal("137", Value(result, "days_out_of_date")); // {{days_out_of_date}}
+ Assert.Equal("1", Value(result, "previous_failures")); // {{previous_failures}}
+ }
+
+ [Theory]
+ [InlineData(0, 1)] // the device is current: nothing to offer
+ [InlineData(137, 0)] // no earlier failure on record
+ public async Task DEV_014_stays_silent_without_both_the_backlog_and_the_earlier_failure(int days, int previousFailures)
+ {
+ var events = Visit(days, previousFailures);
+ events.AddRange(new[]
+ {
+ Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
+ Name("SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI", "false"),
+ PageStop("OobeNDUP", "cancel"),
+ });
+
+ Assert.Empty((await RunAsync(events)).Results);
+ }
+
+ // ── ANALYZE-DEV-015: scan without a result ──────────────────────────────
+
+ [Fact]
+ public async Task DEV_015_fires_when_the_page_ends_without_a_scan_result()
+ {
+ var outcome = await RunAsync(ScanWithoutResult());
+
+ var result = Assert.Single(outcome.Results);
+ Assert.Equal("ANALYZE-DEV-015", result.RuleId);
+ Assert.Equal("438", Value(result, "days_out_of_date"));
+ Assert.Equal("page_stopped", Value(result, "update_page_ended"));
+ }
+
+ [Fact]
+ public async Task DEV_015_stays_silent_while_the_page_still_runs()
+ {
+ // The session ended during the scan: no page end, so no claim that the page gave up.
+ var events = ScanWithoutResult();
+ events.RemoveAll(e => e.EventType == "oobe_update_page" && (string)e.Data["cxhEvent"] == "page_stopped");
+
+ Assert.Empty((await RunAsync(events)).Results);
+ }
+
+ [Fact]
+ public async Task DEV_015_stays_silent_when_the_agent_missed_the_scan_start()
+ {
+ // A late agent sees the page end but not the scan: the missing result proves nothing.
+ var events = ScanWithoutResult();
+ events.RemoveAll(e => e.EventType == "oobe_update_page" && (e.Data.TryGetValue("name", out var n) ? (string)n : "").StartsWith("ExpeditedUpdate_startWUScan"));
+
+ Assert.Empty((await RunAsync(events)).Results);
+ }
+
+ // ── shared vetoes and the sessions that fire nothing ────────────────────
+
+ [Theory]
+ [MemberData(nameof(FiringShapes))]
+ public async Task The_names_capped_marker_vetoes_every_rule(string shape)
+ {
+ // Once the agent capped the page's names, a missing success or scan result proves nothing.
+ var events = Shapes[shape]();
+ events.Add(NamesCapped());
+
+ Assert.Empty((await RunAsync(events)).Results);
+ }
+
+ [Fact]
+ public async Task A_page_the_policy_switched_off_fires_nothing()
+ {
+ var events = new List
+ {
+ PageStart("OobeNDUP"),
+ Name("ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", "false"),
+ PageStop("OobeNDUP", "cancel"),
+ PageStart("RebootNDUP"),
+ PageStop("RebootNDUP", "success"),
+ };
+
+ Assert.Empty((await RunAsync(events)).Results);
+ }
+
+ [Fact]
+ public async Task An_installed_update_fires_nothing()
+ {
+ Assert.Empty((await RunAsync(Installed())).Results);
+ }
+
+ [Theory]
+ [MemberData(nameof(AllShapes))]
+ public async Task No_session_fires_more_than_one_of_the_three(string shape)
+ {
+ var outcome = await RunAsync(Shapes[shape]());
+
+ Assert.True(outcome.Results.Count <= 1, $"{shape}: {string.Join(", ", outcome.Results.Select(r => r.RuleId))}");
+ }
+
+ // ── session shapes (field sessions, synthetic times) ────────────────────
+
+ private static readonly Dictionary>> Shapes = new()
+ {
+ ["failed_download"] = FailedDownload,
+ ["failure_then_skip"] = FailureThenSkip,
+ ["installed_despite_failure_helper"] = InstalledDespiteFailureHelper,
+ ["nothing_offered_after_failure"] = NothingOfferedAfterFailure,
+ ["scan_without_result"] = ScanWithoutResult,
+ ["installed"] = Installed,
+ };
+
+ public static IEnumerable