mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-26 21:47:13 +00:00
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ba4a10031 | |||
| fd0ae98868 | |||
| 8101aa1e50 | |||
| cf3647da49 | |||
| cafb1e9a33 | |||
| d38265071d | |||
| 40d88ef1e0 | |||
| 14d1266b89 | |||
| 0730373e9b | |||
| 03f4470db6 | |||
| 697077c7e5 | |||
| befc101d8f | |||
| 7250baca02 | |||
| fa1809d910 | |||
| 9a718539fd | |||
| a1ba1fd1ab | |||
| 94399170d8 | |||
| 824bc64f96 | |||
| a7c3c16054 | |||
| 525be338b8 | |||
| 734ca2b8fc | |||
| 2a74395874 | |||
| 012eb8c057 | |||
| 0c1b83268c | |||
| 1088b36b8b | |||
| dfd1b86e01 | |||
| b8a663e11d | |||
| 38b499b80b | |||
| 9252b8081f | |||
| ba92811f45 | |||
| 0b813891e4 | |||
| dc4f6ed28b | |||
| 3564e5ba88 | |||
| de00471da9 | |||
| 9b3e9e3900 | |||
| 58e9f19f82 | |||
| 8e000f1849 | |||
| 77de8c92c6 | |||
| 17fc23f8cb | |||
| e046571350 | |||
| 6b5c55998f | |||
| 6df57459c2 | |||
| caeb7a3e58 | |||
| bf37fe46c1 | |||
| 3678995a9b | |||
| 7a917eeaee | |||
| d5a83374d0 | |||
| 596a9ffc2b | |||
| 0e1428922f | |||
| 10314cd8f2 | |||
| 9ee882ab68 | |||
| eefa365b6c | |||
| 21ca9eb068 | |||
| af86e8f7cc | |||
| 8c5fbd5739 | |||
| 55b667ad2c | |||
| 325632e780 | |||
| 9ad3cb2df1 | |||
| 127d5c1173 | |||
| 6df88abdb3 | |||
| 5c3d158d85 | |||
| 3bedc65ed5 | |||
| e5cb3c4f5e | |||
| d7b094852d | |||
| 516cdc71a7 | |||
| 4a822767d4 | |||
| f82eabd6eb | |||
| 1892da30e4 | |||
| fd44518e14 |
+1
-1
Submodule .claude/commands updated: e593da2447...2f276c1f9c
@@ -615,8 +615,8 @@ jobs:
|
||||
|
||||
# The devices' check, run on the FINAL ZIP rather than the staging folder: whatever the
|
||||
# archiver did (Compress-Archive skips hidden files) fails here instead of on a device.
|
||||
# Windows PowerShell 5.1, the edition installer and OOBE script verify with; the pin is
|
||||
# the installer's own literal, the value devices compare against.
|
||||
# Windows PowerShell 5.1 and the installer's own functions (dot-sourced; its entry guard
|
||||
# keeps that side-effect free) -- the very code the installer and the OOBE go-script run.
|
||||
- name: Verify payload catalog (device check)
|
||||
shell: powershell
|
||||
env:
|
||||
@@ -629,32 +629,22 @@ jobs:
|
||||
if (Test-Path $payload) { throw "$payload already exists." }
|
||||
[System.IO.Compression.ZipFile]::ExtractToDirectory((Resolve-Path $env:RELEASE_ZIP).Path, $payload)
|
||||
|
||||
$pinLine = Select-String -Path $env:BOOTSTRAP_SOURCE -Pattern '^\$ExpectedPublisher = "([^"]+)"' | Select-Object -First 1
|
||||
if (-not $pinLine) { throw "Could not read the publisher pin from $env:BOOTSTRAP_SOURCE." }
|
||||
$pin = $pinLine.Matches[0].Groups[1].Value
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:CODE_SIGN_EXPECTED_SUBJECT) -and $pin -ne $env:CODE_SIGN_EXPECTED_SUBJECT) {
|
||||
throw "Installer pin '$pin' differs from CODE_SIGN_EXPECTED_SUBJECT '$env:CODE_SIGN_EXPECTED_SUBJECT'."
|
||||
. (Resolve-Path $env:BOOTSTRAP_SOURCE).Path
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:CODE_SIGN_EXPECTED_SUBJECT) -and $ExpectedPublisher -ne $env:CODE_SIGN_EXPECTED_SUBJECT) {
|
||||
throw "Installer pin '$ExpectedPublisher' differs from CODE_SIGN_EXPECTED_SUBJECT '$env:CODE_SIGN_EXPECTED_SUBJECT'."
|
||||
}
|
||||
if ($AgentCatalogName -ne $env:PAYLOAD_CATALOG) {
|
||||
throw "Installer catalog name '$AgentCatalogName' differs from PAYLOAD_CATALOG '$env:PAYLOAD_CATALOG'."
|
||||
}
|
||||
|
||||
$catalog = Join-Path $payload $env:PAYLOAD_CATALOG
|
||||
if (-not (Test-Path $catalog -PathType Leaf)) { throw "$env:PAYLOAD_CATALOG is missing from the ZIP." }
|
||||
$result = Test-FileCatalog -Path $payload -CatalogFilePath $catalog -FilesToSkip $env:PAYLOAD_CATALOG -Detailed
|
||||
# Assign first, then flatten: the function returns its list as ONE object (comma
|
||||
# operator), so @(Get-AgentPayloadRejections ...) would nest it inside another array.
|
||||
$problems = Get-AgentPayloadRejections -Path $payload -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName
|
||||
$problems = @($problems)
|
||||
|
||||
$problems = @()
|
||||
if ($result.Status -ne 'Valid') {
|
||||
$expected = $result.CatalogItems
|
||||
$actual = $result.PathItems
|
||||
$missing = @($expected.Keys | Where-Object { -not $actual.ContainsKey($_) })
|
||||
$extra = @($actual.Keys | Where-Object { -not $expected.ContainsKey($_) })
|
||||
$changed = @($expected.Keys | Where-Object { $actual.ContainsKey($_) -and $actual[$_] -ne $expected[$_] })
|
||||
$problems += "payload differs from the catalog (missing: $($missing -join ', '); extra: $($extra -join ', '); changed: $($changed -join ', '))"
|
||||
}
|
||||
$signature = $result.Signature
|
||||
if ($signature.Status -ne 'Valid') {
|
||||
$problems += "catalog signature status is '$($signature.Status)'"
|
||||
} elseif ($signature.SignerCertificate.Subject -notlike $pin) {
|
||||
$problems += "catalog signed by '$($signature.SignerCertificate.Subject)', expected '$pin'"
|
||||
}
|
||||
# Release-only checks a device need not repeat: a timestamp (without it the signature
|
||||
# dies with the certificate) and the same certificate as the binaries.
|
||||
$signature = Get-AuthenticodeSignature (Join-Path $payload $AgentCatalogName)
|
||||
if (-not $signature.TimeStamperCertificate) {
|
||||
$problems += 'catalog signature carries no RFC-3161 timestamp -- it would expire with the certificate'
|
||||
}
|
||||
@@ -667,7 +657,7 @@ jobs:
|
||||
$problems | ForEach-Object { Write-Host "::error::$_" }
|
||||
throw "Payload catalog verification failed. Nothing was published."
|
||||
}
|
||||
Write-Host "Payload catalog verified on the final ZIP: $($result.CatalogItems.Count) files, signer $($signature.SignerCertificate.Subject)"
|
||||
Write-Host "Payload catalog verified on the final ZIP with the installer's own check, signer $($signature.SignerCertificate.Subject)"
|
||||
|
||||
- name: Upload to Azure Blob Storage
|
||||
if: inputs.upload_blob
|
||||
|
||||
+1
-1
Submodule internal updated: c9a13d6b47...144e8f6fd8
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when a Win32 app detection script fails or returns unexpected results.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -38,6 +38,26 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{detection_failure.appName}} was not detected after its install",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{detection_failure.appId}}",
|
||||
"name": "{{detection_failure.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Detection",
|
||||
"value": "{{detection_failure.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{detection_failure.errorCode}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"detection",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects app installation failures with specific exit codes or HRESULT error codes and suggests fixes.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -41,6 +41,27 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{install_error.appName}} failed to install",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{install_error.appId}}",
|
||||
"name": "{{install_error.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Exit code",
|
||||
"value": "{{install_error.exitCode}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{install_error.errorCode}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"msi",
|
||||
|
||||
@@ -33,9 +33,25 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{dependency_failure.appName}} failed because a dependency did not install",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{dependency_failure.appId}}",
|
||||
"name": "{{dependency_failure.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{dependency_failure.errorCode}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"dependency"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
"version": "1.2.0"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when disk space is critically low during enrollment, which can cause app installation failures. Confidence increases when app installations actually failed while disk space was low.",
|
||||
"severity": "critical",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -71,6 +71,27 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "Free disk space dropped to {{low_disk.value}} GB",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "disk",
|
||||
"name": "Disk space"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Free space (GB)",
|
||||
"value": "{{low_disk.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Measured",
|
||||
"value": "{{low_disk.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"disk-space",
|
||||
|
||||
@@ -33,10 +33,26 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "The content download of {{download_timeout.appName}} timed out or failed",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{download_timeout.appId}}",
|
||||
"name": "{{download_timeout.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{download_timeout.errorCode}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"download",
|
||||
"timeout"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
"version": "1.2.0"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the same app installation is attempted 3 or more times during one enrollment — a retry loop caused by unhonored reboot requests, repeated failures, or detection mismatches.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -35,6 +35,27 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{install_retry_loop.appName}} was started {{install_retry_loop.count}} times",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{install_retry_loop.groupKey}}",
|
||||
"name": "{{install_retry_loop.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Install starts",
|
||||
"value": "{{install_retry_loop.count}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "First start",
|
||||
"value": "{{install_retry_loop.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"reboot",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the app tracking summary reports multiple failed app installations.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -50,6 +50,17 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{summary_errors.value}} app installations failed",
|
||||
"facts": [
|
||||
{
|
||||
"label": "Failed apps",
|
||||
"value": "{{summary_errors.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"summary",
|
||||
|
||||
@@ -32,11 +32,32 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{slow_app_install.appName}} took {{slow_app_install.durationFormatted}} to install",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{slow_app_install.appId}}",
|
||||
"name": "{{slow_app_install.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Install time",
|
||||
"value": "{{slow_app_install.durationSeconds}}",
|
||||
"format": "duration"
|
||||
},
|
||||
{
|
||||
"label": "Started",
|
||||
"value": "{{slow_app_install.startTimestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"performance",
|
||||
"slow-install",
|
||||
"warning"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
"version": "1.2.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-APP-010",
|
||||
"version": "1.1.0",
|
||||
"title": "App Reverted to Error After Successful Install",
|
||||
"description": "Detects when a specific app (matched by appId) was first reported as successfully installed but subsequently reported an IME enforcement error — while enrollment completed successfully. The app may be broken post-enrollment.",
|
||||
"severity": "warning",
|
||||
@@ -56,5 +57,31 @@
|
||||
{ "title": "Troubleshoot Win32 app installations", "url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install" },
|
||||
{ "title": "Intune Management Extension logs", "url": "https://learn.microsoft.com/en-us/mem/intune/apps/intune-management-extension" }
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{app_regression.eventA_appName}} installed, then reported an error",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{app_regression.joinValue}}",
|
||||
"name": "{{app_regression.eventA_appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Installed",
|
||||
"value": "{{app_regression.eventA_timestamp}}",
|
||||
"format": "timestamp"
|
||||
},
|
||||
{
|
||||
"label": "Error after",
|
||||
"value": "{{app_regression.timeDeltaSeconds}}",
|
||||
"format": "duration"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{app_regression.eventB_errorCode}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["apps", "win32", "enforcement", "post-install", "correlation"]
|
||||
}
|
||||
|
||||
@@ -43,6 +43,32 @@
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-app-management#step-4-return-codes"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "{{enforcement_error_resolved.eventA_appName}} reported an install error but was detected as installed",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{enforcement_error_resolved.joinValue}}",
|
||||
"name": "{{enforcement_error_resolved.eventA_appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Exit code",
|
||||
"value": "{{enforcement_error_resolved.eventA_exitCode}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{enforcement_error_resolved.eventA_errorCode}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "Detected installed after",
|
||||
"value": "{{enforcement_error_resolved.timeDeltaSeconds}}",
|
||||
"format": "duration"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"enforcement",
|
||||
@@ -50,5 +76,5 @@
|
||||
"unmapped",
|
||||
"resolved"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
"version": "1.2.0"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the Enrollment Status Page failed because Intune could not verify an app was installed (HRESULT 0x87D1041C). The installer ran but the detection rule did not match afterwards.",
|
||||
"severity": "critical",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -82,6 +82,26 @@
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-add#step-4-detection-rules"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{app_with_detection_failure.appName}} installed, but its detection rule did not find it",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{app_with_detection_failure.appId}}",
|
||||
"name": "{{app_with_detection_failure.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "ESP error",
|
||||
"value": "{{esp_failure_errorcode.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "Detection",
|
||||
"value": "{{app_with_detection_failure.value}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"detection",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the Enrollment Status Page failed on the Apps subcategory and the agent's AppX deployment log scan identified a suspected MSIX/Store package. Store/MSIX apps install outside the Win32 pipeline, so the regular per-app tracking sees nothing — this correlation names the likely culprit.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -123,6 +123,36 @@
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/store-apps-microsoft"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "A Store or MSIX app failed during the ESP: {{appx_candidate.value}}",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{appx_candidate.value}}",
|
||||
"name": "{{appx_candidate.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "ESP error",
|
||||
"value": "{{appx_esp_errorcode.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "ESP phase",
|
||||
"value": "{{appx_esp_category.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Package",
|
||||
"value": "{{appx_top_candidate_package.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Candidates",
|
||||
"value": "{{appx_candidate_count.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"appx",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "The IME reported its user-session completion line, but at least one required app stayed pending — without ever failing or even starting to install — so the agent kept deferring the enrollment completion signal. Typically an assignment/requirement problem (requirement rules never met, dependency never satisfied, or the app needs a context that is not available during ESP, e.g. a VPN client waiting for user sign-in to a portal).",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
@@ -76,5 +76,20 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "Completion waited on {{apps_pending.value}} pending app(s)",
|
||||
"facts": [
|
||||
{
|
||||
"label": "Pending apps",
|
||||
"value": "{{apps_pending.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Apps",
|
||||
"value": "{{pending_app_names.value}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["apps", "esp", "completion", "assignment", "pending"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "One or more required user-ESP apps never started installing while the ESP AccountSetup apps gate waited on them — the agent observed the ESP page exit with those apps still in a non-terminal, never-started state. Unlike ANALYZE-APP-015 (which reports that completion was DEFERRED because of a pending app, and stays silent once the session reaches a terminal verdict) this rule reports the delivery gap itself and fires regardless of the final outcome: an enrollment that Windows called done while required apps never ran is a successful enrollment with missing software, and the operator must see it.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -102,6 +102,26 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "Required apps never started installing; first: {{appName.value}}",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{appName.appId}}",
|
||||
"name": "{{appName.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "First app",
|
||||
"value": "{{appName.value}}"
|
||||
},
|
||||
{
|
||||
"label": "State",
|
||||
"value": "{{state.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"esp",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects Win32 app installs that failed because the Windows Installer was busy with another installation (HRESULT 0x80070652 / MSI exit code 1618, ERROR_INSTALL_ALREADY_RUNNING). The IME retries on a fixed budget (~15-17 minutes across several attempts) and then reports the app as failed; when the app is ESP-blocking, the Device Setup Apps subcategory terminates with it.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"baseConfidence": 70,
|
||||
@@ -81,6 +81,27 @@
|
||||
"url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{mutex_blocked_install.appName}} was blocked by another installation",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{mutex_blocked_install.appId}}",
|
||||
"name": "{{mutex_blocked_install.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Exit code",
|
||||
"value": "{{installer_exit_1618.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "ESP error",
|
||||
"value": "{{esp_failed_with_mutex_code.value}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"msiexec",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects Win32 apps whose installer exit code maps to Soft reboot or Hard reboot in the app's Intune return-code table (the '[Win32App] lpExitCode is defined as ...' line). Hard reboot stops app processing and restarts the device in Device Setup - the mid-enrollment restart and second sign-in; Soft reboot leaves the app pending until the next restart while the ESP moves on. Confidence rises for Hard reboot and when a reboot was actually observed in the session.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -86,6 +86,31 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "{{reboot_return_code.appName}} asked for a restart",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{reboot_return_code.appId}}",
|
||||
"name": "{{reboot_return_code.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Return code",
|
||||
"value": "{{reboot_return_code.exitCode}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Return code class",
|
||||
"value": "{{reboot_return_code.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Restarts",
|
||||
"value": "{{reboot_observed.count}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"reboot",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Correlates an active proxy configuration with app content download failures (Delivery Optimization timeout or download error), suggesting the proxy is blocking or throttling Intune content delivery.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -67,6 +67,30 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "App downloads failed while a {{proxy_active.value}} proxy was configured",
|
||||
"subject": {
|
||||
"kind": "network",
|
||||
"key": "proxy",
|
||||
"name": "Proxy"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Proxy type",
|
||||
"value": "{{proxy_active.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Failed app",
|
||||
"value": "{{download_failure.appName}}"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{download_failure.errorCode}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"correlation",
|
||||
"apps",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when Windows Hello for Business provisioning did not start within the expected timeframe after ESP completion.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"baseConfidence": 60,
|
||||
"conditions": [
|
||||
@@ -30,5 +30,21 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "Windows Hello provisioning did not start in time",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "windows-hello",
|
||||
"name": "Windows Hello"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Timed out",
|
||||
"value": "{{hello_timeout.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["device", "hello", "timeout"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when memory usage stays above 90% across at least 3 performance snapshots during enrollment, indicating sustained memory pressure that can cause app installation failures or system instability.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -65,6 +65,22 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "Memory use was above 90% in {{sustained_high_memory.count}} snapshots",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "memory",
|
||||
"name": "Memory"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Snapshots above 90%",
|
||||
"value": "{{sustained_high_memory.count}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"memory",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects devices enrolling with an OS build that has reached end of support: Windows 11 22H2 (build 22621) or older, which includes every Windows 10 build. Maintenance note: raise the build cutoff to 22631 after Windows 11 23H2 reaches end of support on 2026-11-10.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -56,6 +56,26 @@
|
||||
"url": "https://learn.microsoft.com/en-us/lifecycle/products/windows-11-home-and-pro"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "Windows build {{old_build.value}} is out of support",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "os",
|
||||
"name": "Windows version"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "OS",
|
||||
"value": "{{os_version.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Build",
|
||||
"value": "{{old_build.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"os-version",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects a Windows quality/cumulative update that FAILED to install or download during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20 from the System log, 31 from the Operational channel). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
|
||||
"severity": "high",
|
||||
"category": "device",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -113,6 +113,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "A Windows update failed during setup: {{wu_update_title.value}}",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "windows-update",
|
||||
"name": "Windows Update"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{wu_hresult.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "Symbol",
|
||||
"value": "{{wu_hresult_symbol.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Informational: a Windows quality/cumulative update installed during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 19). Surfaces updates applied mid-enrollment — useful context when correlating enrollment duration, reboots, or later issues with a specific KB.",
|
||||
"severity": "info",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -61,6 +61,22 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "A Windows update installed during setup: {{wu_update_title.value}}",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "windows-update",
|
||||
"name": "Windows Update"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Installed",
|
||||
"value": "{{wu_installed.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Informational: the OS build (CurrentBuild.UBR) changed across a reboot during enrollment — an update was installed, regardless of which servicing path delivered it. Deterministic corroboration that works even when the Windows Update event channel shows nothing (registry comparison across restart cannot be lost to timing or logging-path differences).",
|
||||
"severity": "info",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -69,6 +69,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "Windows was updated from build {{previous_build.value}} to {{current_build.value}}",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "os",
|
||||
"name": "Windows version"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Build before",
|
||||
"value": "{{previous_build.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Build after",
|
||||
"value": "{{current_build.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "The device's real-time clock deviates from NTP by more than two minutes. Microsoft documents that a clock off by several minutes causes TPM attestation errors (self-deploying/pre-provisioning) and ESP timeouts.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -59,6 +59,26 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "clock",
|
||||
"name": "System clock"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Clock offset (s)",
|
||||
"value": "{{clock_behind.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Clock offset (s)",
|
||||
"value": "{{clock_ahead.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"ntp",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "The device's clock changed by five or more minutes mid-session (clock jump) or ran five or more minutes off server time for the whole session (sustained offset), measured against the server receive frame of every uploaded event. Complements ANALYZE-DEV-007, which is a one-shot NTP check at agent startup and misses devices where NTP was unreachable or the clock changed later. IME-log-derived events are excluded from the measurement.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -57,6 +57,26 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "clock",
|
||||
"name": "System clock"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Clock jump (min)",
|
||||
"value": "{{clock_jump.jumpMinutes}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Clock offset (min)",
|
||||
"value": "{{sustained_offset.offsetMinutes}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"clock-skew",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects that the device's battery dropped to 15% or less while enrolling on battery power (power_state_change threshold event from the agent's live power watcher). A device dying mid-enrollment leaves a half-provisioned state that typically needs a full reset; this rule surfaces the risk while there is still time to plug the device in. Uses a single required condition on the agent-stamped thresholdPercent field (the data field 'transition' on the event is unrelated to this rule's 'trigger' property).",
|
||||
"severity": "high",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -39,6 +39,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "The battery fell below {{battery_threshold_15.value}}% during setup",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "power",
|
||||
"name": "Power"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Battery below (%)",
|
||||
"value": "{{battery_threshold_15.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Reported",
|
||||
"value": "{{battery_threshold_15.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"power",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects that the device lost AC power and switched to battery while the enrollment was running (power_state_change event with transition ac_to_battery from the agent's live power watcher). Uses a single required condition so the match is pinned to one event instance; the event payload carries the battery percentage at the moment of the switch. The agent debounces power flapping (5s) so a brief unplug/replug does not produce this event.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -39,6 +39,22 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "The device switched from AC to battery power during setup",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "power",
|
||||
"name": "Power"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Switched",
|
||||
"value": "{{switched_to_battery.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"power",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects that the device completed a sleep, hibernate or Modern Standby episode of at least 5 minutes while the enrollment was running (system_sleep_episode event from the agent's system timeline watcher, recorded retroactively at wake from the Windows event log). The watcher also backfills pre-agent episodes from the last 24 h for timeline context (backfilled=true); those ended before the enrollment started and are excluded via the same-event filter backfilled equals false — a separate condition could not do that, because each condition scans all events on its own and a pre-session hibernate would still satisfy the duration test. A single required condition keeps the match pinned to one event instance so the interpolated duration is the matched value. Short Modern Standby screen-off scenarios (under 60 s of real sleep) never emit the event, and this rule adds a 5-minute floor so brief lid-close moments do not fire it.",
|
||||
"severity": "info",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -42,6 +42,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "The device went to sleep during setup",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "power",
|
||||
"name": "Power"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Asleep for",
|
||||
"value": "{{slept_during_enrollment.value}}",
|
||||
"format": "duration"
|
||||
},
|
||||
{
|
||||
"label": "Recorded",
|
||||
"value": "{{slept_during_enrollment.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"power",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"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",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end"
|
||||
@@ -109,6 +109,22 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "The Windows quality update at the end of OOBE failed",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "oobe-update",
|
||||
"name": "OOBE quality update"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Failed step",
|
||||
"value": "{{update_failure.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"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",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end"
|
||||
@@ -110,6 +110,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "No quality update was offered although the device is {{days_out_of_date.value}} days out of date",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "oobe-update",
|
||||
"name": "OOBE quality update"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Days out of date",
|
||||
"value": "{{days_out_of_date.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Earlier update failures",
|
||||
"value": "{{previous_failures.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"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",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end"
|
||||
@@ -108,6 +108,22 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "The OOBE update check ended without a result",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "oobe-update",
|
||||
"name": "OOBE quality update"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Days out of date",
|
||||
"value": "{{days_out_of_date.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the Autopilot enrollment has explicitly failed.",
|
||||
"severity": "critical",
|
||||
"category": "enrollment",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"baseConfidence": 95,
|
||||
"conditions": [
|
||||
@@ -57,19 +57,40 @@
|
||||
"confidenceFactors": [
|
||||
{ "signal": "failed_app", "condition": "exists", "weight": 5 }
|
||||
],
|
||||
"explanation": "The Autopilot enrollment has explicitly failed.\n\n**Key facts extracted from this session's events:**\n\n- **Failure reason:** `{{reason}}`\n- **First failed app:** `{{appName}}`\n- **ESP failed subcategory:** `{{failedSubcategory}}`\n- **ESP HRESULT:** `{{errorCode}}`\n\nWhen an ESP Apps-subcategory failure is paired with HRESULT `0x87D1041C`, the device-side install most likely completed but the Intune detection rule did not match afterwards — see the **App Detection Failure During ESP** rule (ANALYZE-APP-013) in this same analysis for the actionable root cause and remediation steps. Any field still shown as a literal `{{…}}` placeholder was simply not recorded for this session — for example, a non-ESP failure path has no ESP failed-subcategory or HRESULT.",
|
||||
"explanation": "The Autopilot enrollment has explicitly failed.\n\n**Key facts from this session's events:**\n\n- **Failure reason:** `{{reason}}`\n- **First failed app:** `{{appName}}`\n- **ESP step that failed:** `{{failedSubcategory}}`\n- **ESP error code:** `{{errorCode}}`\n\nA more specific finding in this analysis usually names the cause, such as an app, ESP or timeout finding.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Investigate enrollment failure",
|
||||
"title": "Find the cause",
|
||||
"steps": [
|
||||
"Start with the more specific ANALYZE-APP-* / ANALYZE-ESP-* rules that fired alongside this one — they correlate specific HRESULTs to actionable causes (e.g. 0x87D1041C → detection rule mismatch in ANALYZE-APP-013)",
|
||||
"Read the `reason` field on the enrollment_failed event for the immediate trigger (e.g. esp_terminal_failure, hello_timeout, max_lifetime)",
|
||||
"If a HRESULT is present, look it up against the ESP error catalog (Microsoft Learn: Autopilot ESP error codes)",
|
||||
"Review other analyze rule results for correlated issues (disk space, network, app failures)",
|
||||
"Check the device event timeline for the sequence of events leading to failure",
|
||||
"Verify the Autopilot profile and ESP configuration in Intune"
|
||||
"Open the finding in this analysis that names the cause (an app, ESP or timeout finding) and follow its steps",
|
||||
"If no other finding fired, look up the ESP error code and check the failed ESP step in the session timeline",
|
||||
"Verify the Autopilot profile and the ESP settings in Intune"
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "outcome",
|
||||
"summary": "The enrollment failed at the ESP step {{esp_failure_subcategory.value}} with {{esp_failure_errorcode.value}}",
|
||||
"facts": [
|
||||
{
|
||||
"label": "Reason",
|
||||
"value": "{{enrollment_failed_reason.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "ESP step",
|
||||
"value": "{{esp_failure_subcategory.value}}"
|
||||
},
|
||||
{
|
||||
"label": "ESP error",
|
||||
"value": "{{esp_failure_errorcode.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "First failed app",
|
||||
"value": "{{failed_app.appName}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["enrollment", "failed", "critical"]
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-ENRL-002",
|
||||
"title": "Session Timed Out",
|
||||
"description": "Detects when the enrollment session was force-failed by the server-side inactivity timeout (no agent telemetry for the configured SessionTimeoutHours window) without ever completing or explicitly failing.",
|
||||
"description": "Detects when the server-side inactivity timeout closed the enrollment session (no agent telemetry for the configured SessionTimeoutHours window) before it ever completed or explicitly failed.",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.2.0",
|
||||
"version": "1.4.0",
|
||||
"enabled": true,
|
||||
"baseConfidence": 80,
|
||||
"preconditions": [
|
||||
@@ -20,6 +20,12 @@
|
||||
"eventType": "enrollment_failed",
|
||||
"operator": "not_exists",
|
||||
"description": "Suppress when an explicit enrollment_failed exists — that path is already covered by ANALYZE-ENRL-001; avoid double-reporting the same session."
|
||||
},
|
||||
{
|
||||
"source": "event_data",
|
||||
"eventType": "device_blocked",
|
||||
"operator": "not_exists",
|
||||
"description": "Suppress when the platform blocked the session's device: the silence is the refused uploads, which ANALYZE-ENRL-006 explains — 'the agent died or the device went offline' would be wrong."
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
@@ -58,7 +64,7 @@
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"explanation": "The enrollment session was force-failed by the server-side maintenance sweep after `{{timeoutHours}}` hour(s) of agent inactivity — the device stopped sending telemetry and never reported completion or an explicit failure.\n\nThis usually means the agent process died or the device went offline mid-enrollment (power off, network loss, bluescreen, reimage) rather than a clean enrollment failure. Because there is no terminal `enrollment_failed` event, the more specific app/ESP rules cannot pinpoint a cause — the evidence simply stops at the last event before the gap.\n\n**If the evidence shows the user desktop was reached and all tracked apps completed**, the device most likely got all its content but the ESP never signaled completion — on hybrid-joined devices the usual culprit is the user session never establishing Entra affinity (Account Setup re-running on every logon). Check whether the hybrid user-affinity finding also fired for this session.",
|
||||
"explanation": "The server-side maintenance sweep closed this session after `{{timeoutHours}}` hour(s) of agent inactivity — the device stopped sending telemetry and never reported completion or an explicit failure.\n\nThis usually means the agent process died or the device went offline mid-enrollment (power off, network loss, bluescreen, reimage) rather than a clean enrollment failure. Because there is no terminal `enrollment_failed` event, the more specific app/ESP rules cannot pinpoint a cause — the evidence simply stops at the last event before the gap.\n\n**If the evidence shows the user desktop was reached and all tracked apps completed**, the device most likely got all its content but the ESP never signaled completion — on hybrid-joined devices the usual culprit is the user session never establishing Entra affinity (Account Setup re-running on every logon). Check whether the hybrid user-affinity finding also fired for this session.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Investigate the silent session",
|
||||
@@ -78,5 +84,21 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "outcome",
|
||||
"summary": "No completion or failure arrived within {{timeoutHours.value}} hours",
|
||||
"facts": [
|
||||
{
|
||||
"label": "Timeout (hours)",
|
||||
"value": "{{timeoutHours.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Desktop reached",
|
||||
"value": "{{desktop_was_reached.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["enrollment", "timeout", "stalled"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "A TPM-attestation-related error code documented in the Microsoft Autopilot known-issues list was observed (0x800705B4, 0x801C03EA, 0x81039001, 0x81039023, 0x81039024, 0x80070490). These block self-deploying and pre-provisioning deployments during 'Securing your hardware'. Observability: attestation precedes MDM enrollment, so in the normal deployment model (agent installed via IME after enrollment) these codes are rarely visible — the rule primarily serves bootstrap-token deployments.",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -80,6 +80,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "TPM attestation failed with a known Autopilot error code",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "tpm",
|
||||
"name": "TPM attestation"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Error code",
|
||||
"value": "{{tpm_hresult_esp_failure.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "Error code",
|
||||
"value": "{{tpm_hresult_esp_advisory.value}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"enrollment",
|
||||
"tpm",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "An enrollment-blocking error code documented in the Microsoft Autopilot known-issues/troubleshooting pages was observed (0x80180014, 0x80180018, 0xC1036501, 0x801C03F3, 0x80070774). Each has a specific, documented cause and fix. Observability: a blocked MDM enrollment normally means no agent gets installed at all (IME install happens after enrollment) — this rule can realistically only fire for bootstrap-token deployments (pre-MDM reporting) or mixed retry scenarios.",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -78,6 +78,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "MDM enrollment was blocked with a known Autopilot error code",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "mdm-enrollment",
|
||||
"name": "MDM enrollment"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Error code",
|
||||
"value": "{{enrl_hresult_esp_failure.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "Error code",
|
||||
"value": "{{enrl_hresult_esp_advisory.value}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"enrollment",
|
||||
"mdm",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "A Microsoft Entra hybrid join Autopilot deployment hit error 0x80004005 — a documented build-dependent known issue, resolved by KB5065789 (25H2, build >= 26200.6725), KB5065426 (24H2, build >= 26100.6584), and KB5070312 (23H2, build >= 22631.6276).",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -103,6 +103,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "Hybrid join hit 0x80004005, a known timeout fixed by Windows updates",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "hybrid-join",
|
||||
"name": "Hybrid join"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Error code",
|
||||
"value": "{{e_fail_esp_failure.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "Error code",
|
||||
"value": "{{e_fail_esp_advisory.value}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"enrollment",
|
||||
"hybrid-join",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-ENRL-006",
|
||||
"title": "Monitoring Paused by a Protective Limit",
|
||||
"description": "Detects when the platform blocked the session's device — automatically after an unusually high event volume, or by the platform operator — so the session's telemetry stopped reaching Autopilot Monitor.",
|
||||
"severity": "warning",
|
||||
"category": "enrollment",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"on_event:device_blocked"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 90,
|
||||
"confidenceThreshold": 50,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "device_blocked",
|
||||
"source": "event_type",
|
||||
"eventType": "device_blocked",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "event_count",
|
||||
"source": "event_data",
|
||||
"eventType": "device_blocked",
|
||||
"dataField": "eventCount",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "event_limit",
|
||||
"source": "event_data",
|
||||
"eventType": "device_blocked",
|
||||
"dataField": "threshold",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "blocked_until",
|
||||
"source": "event_data",
|
||||
"eventType": "device_blocked",
|
||||
"dataField": "blockedUntil",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"explanation": "Autopilot Monitor stopped accepting telemetry from this session. A protective limit keeps a single device that sends far more events than an enrollment normally does from distorting your fleet data or overloading the platform. When a session crosses it, the device's uploads are paused for a limited time; in severe cases the agent ends monitoring and removes itself. The platform operator can apply the same pause by hand.\n\nA pause does not affect the enrollment itself: the agent keeps running and only holds back its uploads, so Windows Autopilot and Intune carry on as usual. While the pause lasts, no events from this session reach Autopilot Monitor, so the timeline and the final status reflect the last state the agent reported before it. A paused session does not affect the device's next enrollment, which starts unblocked. When the agent ended monitoring, the device stays unmonitored until the end time shown on the session page, new enrollments included.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Find what made the session so busy",
|
||||
"steps": [
|
||||
"Open the event timeline and look at which event type repeats most often before the pause — for example a network connection that drops and reconnects every few minutes, or a download that reports progress continuously",
|
||||
"Fix the underlying condition (for example a loose dock, cable or Wi-Fi), then re-enroll the device if you need a complete record of the enrollment",
|
||||
"If no event type stands out, report the session from its page so it can be looked at"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "What happens next",
|
||||
"steps": [
|
||||
"A pause ends on its own at the time shown on the session page; an agent that is still running then resumes uploading",
|
||||
"After a pause, a new enrollment on this device starts unblocked",
|
||||
"After the agent ended monitoring, wait for the end time shown on the session page before re-enrolling if you want the new enrollment monitored"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Autopilot Monitor: Protective Limits",
|
||||
"url": "https://docs.autopilotmonitor.com/concepts/protective-limits"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "outcome",
|
||||
"summary": "Monitoring was paused by a protective limit",
|
||||
"facts": [
|
||||
{
|
||||
"label": "Paused at",
|
||||
"value": "{{device_blocked.timestamp}}",
|
||||
"format": "timestamp"
|
||||
},
|
||||
{
|
||||
"label": "Events in this session",
|
||||
"value": "{{event_count.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Event limit",
|
||||
"value": "{{event_limit.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Paused until",
|
||||
"value": "{{blocked_until.value}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["enrollment", "protective-limit", "blocked"]
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the DeviceSetup phase of the ESP took longer than 30 minutes, indicating a stuck or very slow blocking app. Confidence increases when the phase exceeds 60 minutes.",
|
||||
"severity": "high",
|
||||
"category": "esp",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -47,6 +47,27 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "Device setup took {{esp_stalled.durationFormatted}}",
|
||||
"subject": {
|
||||
"kind": "esp",
|
||||
"key": "{{esp_stalled.phase}}",
|
||||
"name": "Device setup"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Duration",
|
||||
"value": "{{esp_stalled.durationSeconds}}",
|
||||
"format": "duration"
|
||||
},
|
||||
{
|
||||
"label": "Started",
|
||||
"value": "{{esp_stalled.phaseStartTimestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"esp",
|
||||
"blocking",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the Certificates subcategory of the Enrollment Status Page reported a failure, even if the enrollment later appears to have succeeded. Users can click 'Continue anyway' on an ESP error, which masks the underlying certificate provisioning failure — this rule surfaces the transition so it stays visible.",
|
||||
"severity": "high",
|
||||
"category": "esp",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -58,6 +58,22 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "The ESP certificates step failed",
|
||||
"subject": {
|
||||
"kind": "esp",
|
||||
"key": "Certificates",
|
||||
"name": "Certificates"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Failed at",
|
||||
"value": "{{certificates_subcategory_failed.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"esp",
|
||||
"certificates",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects an ESP terminal failure where the ESP gave up while a blocking app was still installing (the agent's own esp_apps_timeout verdict — no per-app HRESULT was reported) on a profile that allows 'Continue anyway'. The enrollment is reported as failed, but the user most likely dismissed the error screen and reached the desktop — a soft failure rather than an unusable device. An ESP failure that carries a specific error code (a failed installer, a detection mismatch) is a different case and stays with the rule that explains that code.",
|
||||
"severity": "warning",
|
||||
"category": "esp",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
@@ -81,5 +81,30 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "The ESP gave up while {{appName.value}} was still installing ('Continue anyway' allowed)",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{appName.appId}}",
|
||||
"name": "{{appName.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "ESP error",
|
||||
"value": "{{enrollment_failed.errorCode}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "ESP time limit (min)",
|
||||
"value": "{{espSyncFailureTimeoutMinutes.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "ESP step",
|
||||
"value": "{{failedSubcategory.value}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["esp", "timeout", "continue-anyway", "soft-failure", "enrollment"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Attributes an observed enrollment reboot to device-assigned MDM policies that Windows flagged as reboot-required. Fires ONLY when both facts are present in the session: at least one actually-observed reboot (system_reboot_detected) AND reboot-required policy flags (mdm_policy_reboot_required). Policy flags alone — e.g. from a user-policy sync during AccountSetup — force no reboot and stay advisory-silent.",
|
||||
"severity": "warning",
|
||||
"category": "esp",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -94,6 +94,31 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "A device policy forced a restart during setup: {{reboot_forcing_policy_uri.value}}",
|
||||
"subject": {
|
||||
"kind": "policy",
|
||||
"key": "{{reboot_forcing_policy_uri.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "First policy",
|
||||
"value": "{{reboot_forcing_policy_uri.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Policies",
|
||||
"value": "{{multiple_reboot_uris.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Restarts",
|
||||
"value": "{{reboot_observed.count}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"esp",
|
||||
"reboot",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects the recovery story: the ESP reported a terminal failure (typically DeviceSetup/Apps), the user most likely pressed 'Try again' on the failure page, the failed step re-ran and completed successfully. The earlier failure did not stick — the enrollment continued past it.",
|
||||
"severity": "info",
|
||||
"category": "esp",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -98,6 +98,34 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "The ESP failed in the {{category.value}} phase, then recovered",
|
||||
"subject": {
|
||||
"kind": "esp",
|
||||
"key": "{{failedSubcategory.value}}",
|
||||
"name": "{{failedSubcategory.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "ESP phase",
|
||||
"value": "{{category.value}}"
|
||||
},
|
||||
{
|
||||
"label": "ESP step",
|
||||
"value": "{{failedSubcategory.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Likely apps",
|
||||
"value": "{{likelyCulpritApps.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Recovered after (min)",
|
||||
"value": "{{minutesSinceFailure.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"esp",
|
||||
"recovery",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -55,6 +55,15 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "The expected machine certificate was not found",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "machine-certificate",
|
||||
"name": "Machine certificate"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"identity",
|
||||
"certificates",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -41,6 +41,30 @@
|
||||
"operator": "equals",
|
||||
"value": "error",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "unexpected_accounts",
|
||||
"source": "event_data",
|
||||
"eventType": "local_admin_analysis",
|
||||
"dataField": "checks.unexpected_accounts",
|
||||
"operator": "not_equals",
|
||||
"value": "",
|
||||
"filterField": "finding",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "unexpected_admin_members",
|
||||
"source": "event_data",
|
||||
"eventType": "local_admin_analysis",
|
||||
"dataField": "checks.unexpected_admin_members",
|
||||
"operator": "not_equals",
|
||||
"value": "",
|
||||
"filterField": "finding",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
@@ -85,6 +109,26 @@
|
||||
}
|
||||
],
|
||||
"relatedDocs": [],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "Unexpected local accounts: {{unexpected_accounts.value}}",
|
||||
"subject": {
|
||||
"kind": "account",
|
||||
"key": "{{unexpected_accounts.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Unexpected accounts",
|
||||
"value": "{{unexpected_accounts.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "In Administrators",
|
||||
"value": "{{unexpected_admin_members.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"identity",
|
||||
"security",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Fires when the vulnerability correlation finds critical-severity CVEs or CISA KEV entries in installed software. This indicates the device has software with known, actively exploited vulnerabilities.",
|
||||
"severity": "critical",
|
||||
"category": "security",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -72,6 +72,27 @@
|
||||
"url": "https://nvd.nist.gov/"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "Installed software has {{critical_vulnerabilities.value}} critical vulnerabilities",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "vulnerabilities",
|
||||
"name": "Installed software"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Critical CVEs",
|
||||
"value": "{{critical_vulnerabilities.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Known exploited",
|
||||
"value": "{{kev_matches.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"vulnerability",
|
||||
"cve",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "On a hybrid-joined device the real user reached the desktop, but the Intune Management Extension never acquired a Microsoft Entra user token for that user within the wait window — repeatedly across reboots when the warning recurs. Without a user token the signed-in user has no Entra user affinity: user-targeted apps and policies cannot apply, Windows Hello for Business cannot provision, and with a user ESP the Account Setup page re-runs on every logon.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
@@ -44,6 +44,24 @@
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "token_failure_count",
|
||||
"source": "event_data",
|
||||
"eventType": "entra_user_affinity_pending",
|
||||
"dataField": "tokenFailureCount",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "token_failure_codes",
|
||||
"source": "event_data",
|
||||
"eventType": "entra_user_affinity_pending",
|
||||
"dataField": "tokenFailureCodes",
|
||||
"operator": "not_equals",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
@@ -96,5 +114,36 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "The user reached the desktop, but Entra user affinity never formed",
|
||||
"subject": {
|
||||
"kind": "account",
|
||||
"key": "entra-user-affinity",
|
||||
"name": "Entra user affinity"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Token failures",
|
||||
"value": "{{token_failure_count.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Token error codes",
|
||||
"value": "{{token_failure_codes.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Reported in agent runs",
|
||||
"value": "{{user_affinity_pending.count}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Desktop reached",
|
||||
"value": "{{user_desktop_reached.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["identity", "hybrid-join", "entra-id", "user-affinity", "esp"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects diagnostics package uploads that were answered by a security appliance's HTML block page instead of the Autopilot Monitor backend. A forward proxy / secure web gateway (SWG) with SSL inspection is intercepting the upload endpoint, so exactly the sessions where diagnostics matter most (OnFailure uploads) are the ones losing them.",
|
||||
"severity": "warning",
|
||||
"category": "network",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"baseConfidence": 75,
|
||||
@@ -71,6 +71,15 @@
|
||||
"url": "https://learn.microsoft.com/en-us/autopilot/networking-requirements"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "A web security gateway blocked the diagnostics upload",
|
||||
"subject": {
|
||||
"kind": "network",
|
||||
"key": "security-gateway",
|
||||
"name": "Web security gateway"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"network",
|
||||
"proxy",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the Microsoft 365 Apps (Office Click-to-Run) install failed. The V2 OfficeInstallDetector watches the C2R client directly (registry, Delivery Optimization, process) and emits office_install_failed when the C2R INSTALL scenario records an error code. Completion is proven by the core Office binaries on disk; an install window that closes without that proof ends silently, never as a failure. This catches Office failures the deployment status hides: the Intune built-in app type is marked done once the configuration is delivered, and a Win32 or RealmJoin package once its installer exits, while C2R keeps installing in the background.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -52,6 +52,22 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "Microsoft 365 Apps failed to install",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "office-c2r",
|
||||
"name": "Microsoft 365 Apps"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{error_code_present.value}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"office",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects devices where the Windows UEFI CA 2023 certificate has not been deployed to the Secure Boot DB. The 2011 certificates expired in June 2026 — affected devices no longer receive Secure Boot security updates until the 2023 certificate is deployed. Since v3 the agent reads the firmware db/KEK variables directly: when the firmware itself confirms the 2023 certificate, the rule is suppressed even if the Windows servicing status has not caught up yet.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "3.1.0",
|
||||
"version": "3.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -132,6 +132,35 @@
|
||||
"url": "https://support.microsoft.com/en-us/topic/kb5036210"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "The Windows UEFI CA 2023 certificate is not deployed",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "secure-boot",
|
||||
"name": "Secure Boot"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "CA 2023 status",
|
||||
"value": "{{ca2023_not_updated.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{ca2023_error.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "CA 2023 in firmware DB",
|
||||
"value": "{{db_ca2023_firmware_missing.value}}"
|
||||
},
|
||||
{
|
||||
"label": "KEK 2023 in firmware",
|
||||
"value": "{{kek_2023_missing.value}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"secureboot",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Fires when the AutoLogon analyzer reports a DefaultPassword value present in Winlogon. A plaintext AutoLogon password stored in the registry is readable by any local administrator and is a credential exposure — more severe than AutoLogon alone (the Sysinternals Autologon tool deliberately avoids this by storing the secret in the LSA instead).",
|
||||
"severity": "high",
|
||||
"category": "security",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -49,6 +49,15 @@
|
||||
"url": "https://learn.microsoft.com/en-us/sysinternals/downloads/autologon"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "A plaintext AutoLogon password is stored in the registry",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "autologon",
|
||||
"name": "AutoLogon"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"autologon",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Optional kiosk allow-list for AutoLogon. Fires when AutoLogon is enabled for a default user that is NOT on your configured list of approved kiosk / shared-device accounts. Disabled until configured because a normal Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (AutoLogon enabled, a default user, no plaintext password) — so AutoLogon-enabled alone is not graded by default; only a plaintext DefaultPassword on disk (ANALYZE-SEC-003) is. Enable this rule with your expected kiosk account name(s) if you want to surface AutoLogon configured for any other user.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": false,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -59,6 +59,21 @@
|
||||
"url": "https://learn.microsoft.com/en-us/troubleshoot/windows-server/user-profiles-and-logon/turn-on-automatic-logon"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "AutoLogon is enabled for an unexpected user: {{unexpected_autologon_user.value}}",
|
||||
"subject": {
|
||||
"kind": "account",
|
||||
"key": "{{unexpected_autologon_user.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "AutoLogon user",
|
||||
"value": "{{unexpected_autologon_user.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"autologon",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"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 (Cosa, Power.EnergyEstimationEngine, Power.Settings, SecureStart) plus common OEM factory-preload families (Intel PPM power profiles, AMD Power profiles, Lenovo ThinkPad power/battery packages, Microsoft Surface defaults and carrier packages) so clean factory devices stay quiet. OEM imaging leftovers (e.g. USMT.PPKG, InstallOrder.ini) and generically named packages deliberately still alert — on a pure-Autopilot device they are worth a look. 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": "2.2.0",
|
||||
"version": "2.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -38,6 +38,27 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "Provisioning package not on the allow list: {{ppkg_not_allowlisted.value}}",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "provisioning-packages",
|
||||
"name": "Provisioning packages"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Package",
|
||||
"value": "{{ppkg_not_allowlisted.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Unexpected packages",
|
||||
"value": "{{ppkg_not_allowlisted.matchCount}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"provisioning-package",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"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": "2.2.0",
|
||||
"version": "2.3.0",
|
||||
"enabled": false,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -45,6 +45,27 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "Provisioning package not on the allow list: {{ppkg_not_allowlisted.value}}",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "provisioning-packages",
|
||||
"name": "Provisioning packages"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Package",
|
||||
"value": "{{ppkg_not_allowlisted.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Unexpected packages",
|
||||
"value": "{{ppkg_not_allowlisted.matchCount}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"provisioning-package",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"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.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -36,6 +36,15 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "The provisioning package scan hit its capture limit",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "provisioning-packages",
|
||||
"name": "Provisioning packages"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"provisioning-package",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects devices enrolling with Secure Boot turned off. Secure Boot is a firmware-level protection against boot-time malware and is a prerequisite for several Windows security features (VBS/Credential Guard, device health attestation) and common Intune compliance policies. Devices that support Secure Boot but have it disabled should be flagged during enrollment. Legacy BIOS devices (no Secure Boot capability) are excluded — the agent marks those with a note field when the SecureBoot registry state key is absent.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -82,6 +82,15 @@
|
||||
"url": "https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/disabling-secure-boot"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "Secure Boot is disabled",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "secure-boot",
|
||||
"name": "Secure Boot"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"secureboot",
|
||||
|
||||
+1271
-63
File diff suppressed because it is too large
Load Diff
@@ -133,6 +133,7 @@
|
||||
"items": { "$ref": "#/$defs/relatedDoc" },
|
||||
"default": []
|
||||
},
|
||||
"presentation": { "$ref": "#/$defs/rulePresentation" },
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"description": "Tags for filtering and categorization.",
|
||||
@@ -352,6 +353,83 @@
|
||||
"required": ["title", "url"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"rulePresentation": {
|
||||
"type": "object",
|
||||
"description": "How a finding of this rule is shown: its role, a one-line summary, what it is about and its key facts. Every string is a {{token}} template resolved from the finding's evidence when it is rendered; a fact whose value was not recorded is left out.",
|
||||
"properties": {
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": ["outcome", "cause", "context", "advisory"],
|
||||
"description": "outcome: restates the session outcome (shown in the verdict, not as a finding row). cause: can explain why a session failed or is stuck. context: a circumstance such as standby, a restart or battery power. advisory: hygiene or security without bearing on the outcome."
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160,
|
||||
"description": "One line for the finding row, for example \"{{install_retry_loop.appName}} was started {{install_retry_loop.count}} times\". When a token was not recorded, the first sentence of the explanation is shown instead."
|
||||
},
|
||||
"subject": { "$ref": "#/$defs/ruleSubject" },
|
||||
"facts": {
|
||||
"type": "array",
|
||||
"description": "Key facts shown as label/value pairs, in this order.",
|
||||
"maxItems": 6,
|
||||
"items": { "$ref": "#/$defs/ruleFact" }
|
||||
}
|
||||
},
|
||||
"required": ["role"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ruleSubject": {
|
||||
"type": "object",
|
||||
"description": "What the finding is about. Findings with the same kind and resolved key are grouped, for example every finding about one app. Use qualified tokens of one signal for key and name, such as {{failed_app.appId}} and {{failed_app.appName}}, so both come from the same event.",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["app", "policy", "account", "script", "device", "network", "esp", "session"],
|
||||
"description": "The kind of thing the key identifies."
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200,
|
||||
"description": "Template of the grouping key."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200,
|
||||
"description": "Template of the display name; the key is shown when it is missing."
|
||||
}
|
||||
},
|
||||
"required": ["kind", "key"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ruleFact": {
|
||||
"type": "object",
|
||||
"description": "One key fact of a finding.",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 40,
|
||||
"description": "Short label, for example \"Install starts\"."
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200,
|
||||
"description": "Template of the value, for example {{install_retry_loop.count}}."
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["text", "code", "errorCode", "duration", "number", "timestamp"],
|
||||
"description": "How the value is shown. errorCode adds the error-code catalog meaning, duration reads seconds, timestamp an ISO 8601 time.",
|
||||
"default": "text"
|
||||
}
|
||||
},
|
||||
"required": ["label", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"templateVariable": {
|
||||
"type": "object",
|
||||
"description": "A variable in a rule condition that must be customized per-tenant before the rule can be used.",
|
||||
|
||||
@@ -30,6 +30,17 @@ BeforeAll {
|
||||
return $dir
|
||||
}
|
||||
|
||||
# What Test-FileCatalog -Detailed returns, reduced to the fields the check reads.
|
||||
function New-FakeCatalogResult {
|
||||
param(
|
||||
[string]$Status = 'Valid',
|
||||
[object]$Signature,
|
||||
[hashtable]$CatalogItems = @{ 'AutopilotMonitor.Agent.exe' = 'AA' },
|
||||
[hashtable]$PathItems = @{ 'AutopilotMonitor.Agent.exe' = 'AA' }
|
||||
)
|
||||
return [pscustomobject]@{ Status = $Status; Signature = $Signature; CatalogItems = $CatalogItems; PathItems = $PathItems }
|
||||
}
|
||||
|
||||
function New-FakeProfile {
|
||||
param([string]$Name, [double]$AgeMinutes)
|
||||
$p = Join-Path $TestDrive $Name
|
||||
@@ -322,86 +333,163 @@ Describe 'logging purity' {
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-UnverifiedAgentBinaries' {
|
||||
# The publisher gate on the extracted payload. What matters is the DECISION: which
|
||||
# payload is accepted, which is refused, and which files are looked at at all.
|
||||
Describe 'Get-AgentPayloadRejections' {
|
||||
# The payload catalog check, shared verbatim with the OOBE go-script. What matters is the
|
||||
# DECISION: which payload is accepted and which is refused. Test-FileCatalog is mocked for
|
||||
# the branches; one test runs the real cmdlet over a real (unsigned) catalog.
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
$script:ours = 'CN=glueckkanja AG, O=glueckkanja AG, C=DE'
|
||||
$script:dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.cat')
|
||||
}
|
||||
|
||||
It 'accepts a payload where every binary of ours is validly signed by us' {
|
||||
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Shared.dll')
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject $script:ours }
|
||||
Get-UnverifiedAgentBinaries -Path $dir | Should -BeNullOrEmpty
|
||||
It 'accepts a payload whose catalog is signed by us and matches every file' {
|
||||
Mock Test-FileCatalog { New-FakeCatalogResult -Signature (New-FakeSignature -Status 'Valid' -Subject $script:ours) }
|
||||
Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'checks the binaries we author and leaves third-party assemblies alone' {
|
||||
$dir = New-FakePayload -Files @(
|
||||
'AutopilotMonitor.Agent.exe',
|
||||
'AutopilotMonitor.Agent.exe.config',
|
||||
'AutopilotMonitor.Shared.dll',
|
||||
'Newtonsoft.Json.dll',
|
||||
'System.Management.Automation.dll'
|
||||
)
|
||||
$script:checked = @()
|
||||
Mock Get-AuthenticodeSignature {
|
||||
$script:checked += (Split-Path $FilePath -Leaf)
|
||||
New-FakeSignature -Status 'Valid' -Subject $script:ours
|
||||
It 'asks for the detailed result and skips the catalog itself' {
|
||||
Mock Test-FileCatalog {
|
||||
$script:call = @{ Skip = $FilesToSkip; Detailed = [bool]$Detailed; Catalog = $CatalogFilePath; Path = $Path }
|
||||
New-FakeCatalogResult -Signature (New-FakeSignature -Status 'Valid' -Subject $script:ours)
|
||||
}
|
||||
|
||||
Get-UnverifiedAgentBinaries -Path $dir | Should -BeNullOrEmpty
|
||||
$script:checked | Should -Be @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Shared.dll')
|
||||
Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher | Out-Null
|
||||
$script:call.Skip | Should -Be @('AutopilotMonitor.Agent.cat')
|
||||
$script:call.Detailed | Should -BeTrue
|
||||
$script:call.Catalog | Should -Be (Join-Path $script:dir 'AutopilotMonitor.Agent.cat')
|
||||
$script:call.Path | Should -Be $script:dir
|
||||
}
|
||||
|
||||
It 'refuses an unsigned agent executable' {
|
||||
It 'refuses a payload without catalog and does not run the check at all' {
|
||||
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'NotSigned' -Subject $null }
|
||||
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
|
||||
Mock Test-FileCatalog { throw 'must not run' }
|
||||
$r = Get-AgentPayloadRejections -Path $dir -ExpectedPublisher $ExpectedPublisher
|
||||
$r.Count | Should -Be 1
|
||||
$r[0] | Should -BeLike '*AutopilotMonitor.Agent.cat missing*'
|
||||
Should -Invoke Test-FileCatalog -Times 0
|
||||
}
|
||||
|
||||
It 'refuses a tampered binary (hash mismatch)' {
|
||||
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'HashMismatch' -Subject $script:ours }
|
||||
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
|
||||
It 'refuses matching files under an unsigned catalog (Status alone is not the verdict)' {
|
||||
Mock Test-FileCatalog { New-FakeCatalogResult -Status 'Valid' -Signature (New-FakeSignature -Status 'NotSigned' -Subject $null) }
|
||||
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
|
||||
$r.Count | Should -Be 1
|
||||
$r[0] | Should -BeLike '*signature status NotSigned*'
|
||||
}
|
||||
|
||||
It 'refuses a signature whose chain does not validate' {
|
||||
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'UnknownError' -Subject $script:ours }
|
||||
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
|
||||
It 'refuses a catalog whose signature does not validate' {
|
||||
Mock Test-FileCatalog { New-FakeCatalogResult -Signature (New-FakeSignature -Status 'HashMismatch' -Subject $script:ours) }
|
||||
(Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher).Count | Should -Be 1
|
||||
}
|
||||
|
||||
It 'refuses a validly signed binary from a different publisher' {
|
||||
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }
|
||||
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
|
||||
It 'refuses a validly signed catalog from another publisher' {
|
||||
Mock Test-FileCatalog { New-FakeCatalogResult -Signature (New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US') }
|
||||
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
|
||||
$r.Count | Should -Be 1
|
||||
$r[0] | Should -BeLike '*does not match*'
|
||||
}
|
||||
|
||||
It 'refuses the whole payload when a single DLL of ours is swapped' {
|
||||
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Shared.dll')
|
||||
Mock Get-AuthenticodeSignature {
|
||||
if ((Split-Path $FilePath -Leaf) -eq 'AutopilotMonitor.Shared.dll') {
|
||||
New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US'
|
||||
} else {
|
||||
New-FakeSignature -Status 'Valid' -Subject $script:ours
|
||||
}
|
||||
It 'names the missing, extra and changed files of a payload that differs' {
|
||||
Mock Test-FileCatalog {
|
||||
New-FakeCatalogResult -Status 'ValidationFailed' -Signature (New-FakeSignature -Status 'Valid' -Subject $script:ours) `
|
||||
-CatalogItems @{ 'a.dll' = 'AA'; 'b.dll' = 'BB'; 'c.config' = 'CC' } `
|
||||
-PathItems @{ 'a.dll' = 'AA'; 'b.dll' = 'XX'; 'evil.dll' = 'EE' }
|
||||
}
|
||||
$rejected = Get-UnverifiedAgentBinaries -Path $dir
|
||||
$rejected.Count | Should -Be 1
|
||||
$rejected[0] | Should -BeLike 'AutopilotMonitor.Shared.dll*'
|
||||
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
|
||||
$r.Count | Should -Be 1
|
||||
$r[0] | Should -BeLike '*missing: c.config;*'
|
||||
$r[0] | Should -BeLike '*extra: evil.dll;*'
|
||||
$r[0] | Should -BeLike '*changed: b.dll)*'
|
||||
}
|
||||
|
||||
It 'refuses when the signature check itself throws' {
|
||||
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
|
||||
Mock Get-AuthenticodeSignature { throw 'access denied' }
|
||||
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
|
||||
It 'reports a bad signature and a differing payload together' {
|
||||
Mock Test-FileCatalog {
|
||||
New-FakeCatalogResult -Status 'ValidationFailed' -Signature (New-FakeSignature -Status 'NotSigned' -Subject $null) `
|
||||
-PathItems @{ 'AutopilotMonitor.Agent.exe' = 'AA'; 'extra.dll' = 'EE' }
|
||||
}
|
||||
(Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher).Count | Should -Be 2
|
||||
}
|
||||
|
||||
It 'refuses an empty payload instead of passing it' {
|
||||
$dir = New-FakePayload -Files @('Newtonsoft.Json.dll')
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject $script:ours }
|
||||
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
|
||||
It 'refuses when the catalog check itself throws' {
|
||||
Mock Test-FileCatalog { throw 'access denied' }
|
||||
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
|
||||
$r.Count | Should -Be 1
|
||||
$r[0] | Should -BeLike '*failed to run*access denied*'
|
||||
}
|
||||
|
||||
It 'refuses when the catalog check returns nothing' {
|
||||
Mock Test-FileCatalog { }
|
||||
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
|
||||
$r[0] | Should -BeLike '*returned nothing*'
|
||||
}
|
||||
|
||||
It 'returns a single rejection as an array' {
|
||||
Mock Test-FileCatalog { throw 'boom' }
|
||||
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
|
||||
($r -is [array]) | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'catches an extra file with the real cmdlet over a real catalog' {
|
||||
# Real New-FileCatalog/Test-FileCatalog over text files: no PE, no process, no mock.
|
||||
$dir = New-FakePayload -Files @('a.txt', 'b.txt')
|
||||
$catalog = Join-Path $TestDrive "real-$([guid]::NewGuid()).cat"
|
||||
New-FileCatalog -Path $dir -CatalogFilePath $catalog -CatalogVersion 2.0 | Out-Null
|
||||
Copy-Item $catalog (Join-Path $dir 'AutopilotMonitor.Agent.cat')
|
||||
Set-Content -Path (Join-Path $dir 'extra.txt') -Value 'planted'
|
||||
|
||||
$r = Get-AgentPayloadRejections -Path $dir -ExpectedPublisher $ExpectedPublisher
|
||||
|
||||
($r -join ' | ') | Should -BeLike '*extra: extra.txt;*'
|
||||
($r -join ' | ') | Should -BeLike '*signature status NotSigned*'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Install-VerifiedAgentPayload' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
$src = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.cat', 'Newtonsoft.Json.dll')
|
||||
$script:zip = Join-Path $TestDrive "payload-$([guid]::NewGuid()).zip"
|
||||
Compress-Archive -Path (Join-Path $src '*') -DestinationPath $script:zip
|
||||
$script:staging = Join-Path $TestDrive "staging-$([guid]::NewGuid())"
|
||||
$script:target = Join-Path $TestDrive "agent-$([guid]::NewGuid())"
|
||||
}
|
||||
|
||||
It 'moves an accepted payload into the agent folder and removes the staging folder' {
|
||||
Mock Get-AgentPayloadRejections { , @() }
|
||||
Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher
|
||||
@(Get-ChildItem $script:target | ForEach-Object Name | Sort-Object) |
|
||||
Should -Be @('AutopilotMonitor.Agent.cat', 'AutopilotMonitor.Agent.exe', 'Newtonsoft.Json.dll')
|
||||
Test-Path $script:staging | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'verifies the staging folder, never the agent folder' {
|
||||
Mock Get-AgentPayloadRejections { $script:checked = $Path; , @() }
|
||||
Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher
|
||||
$script:checked | Should -Be $script:staging
|
||||
}
|
||||
|
||||
It 'leaves the agent folder untouched when the payload is refused, and cleans up' {
|
||||
Mock Get-AgentPayloadRejections { , @('payload differs from catalog (missing: none; extra: evil.dll; changed: none)') }
|
||||
{ Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher } |
|
||||
Should -Throw '*Payload verification FAILED*evil.dll*'
|
||||
Test-Path $script:target | Should -BeFalse
|
||||
Test-Path $script:staging | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'removes leftovers from the agent folder before the payload moves in' {
|
||||
New-Item -ItemType Directory -Path $script:target | Out-Null
|
||||
Set-Content -Path (Join-Path $script:target 'stale.dll') -Value 'x'
|
||||
Mock Get-AgentPayloadRejections { , @() }
|
||||
Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher
|
||||
Test-Path (Join-Path $script:target 'stale.dll') | Should -BeFalse
|
||||
Test-Path (Join-Path $script:target 'AutopilotMonitor.Agent.exe') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'starts from an empty staging folder even when a previous run left one behind' {
|
||||
New-Item -ItemType Directory -Path $script:staging | Out-Null
|
||||
Set-Content -Path (Join-Path $script:staging 'leftover.dll') -Value 'x'
|
||||
Mock Get-AgentPayloadRejections { $script:seen = @(Get-ChildItem $Path | ForEach-Object Name); , @() }
|
||||
Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher
|
||||
$script:seen | Should -Not -Contain 'leftover.dll'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,4 +498,8 @@ Describe 'publisher pin' {
|
||||
$ExpectedPublisher | Should -Be '*O=glueckkanja AG*'
|
||||
$ExpectedPublisher | Should -Not -Match '^[0-9A-Fa-f]{40}$'
|
||||
}
|
||||
|
||||
It 'names the payload catalog the release pipeline signs' {
|
||||
$AgentCatalogName | Should -Be 'AutopilotMonitor.Agent.cat'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@
|
||||
freshly provisioned Windows 365 Cloud PC.
|
||||
2. Downloads the monitoring agent ZIP from Azure Blob Storage
|
||||
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
|
||||
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
|
||||
5. Verifies the Authenticode signature and publisher of every binary we author
|
||||
6. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
|
||||
7. Verifies the runtime process actually launched
|
||||
4. Extracts the agent into a fresh staging folder
|
||||
5. Verifies the payload against its signed catalog (AutopilotMonitor.Agent.cat):
|
||||
signed by the expected publisher, every file present and unchanged, nothing extra
|
||||
6. Moves the verified payload into %ProgramData%\AutopilotMonitor\Agent
|
||||
7. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
|
||||
8. Verifies the runtime process actually launched
|
||||
Agent self-destructs when enrollment completes.
|
||||
|
||||
Relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
|
||||
@@ -77,6 +79,14 @@
|
||||
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
|
||||
|
||||
.CHANGELOG
|
||||
2026-10-13 v2.6 Payload catalog: the agent ZIP carries AutopilotMonitor.Agent.cat,
|
||||
a catalog of every payload file signed by the expected publisher.
|
||||
The ZIP is extracted into a staging folder and moved into the
|
||||
agent folder only when Test-FileCatalog finds the catalog signed
|
||||
by the expected publisher and every file present, unchanged and
|
||||
nothing extra. A rejected payload is deleted, so the next run
|
||||
starts clean. Replaces the v2.5 gate, which covered only the
|
||||
AutopilotMonitor binaries.
|
||||
2026-09-08 v2.5 Publisher gate: every AutopilotMonitor binary in the extracted
|
||||
payload must carry a valid Authenticode signature from the
|
||||
expected publisher before --install runs. The SHA-256 manifest
|
||||
@@ -134,16 +144,20 @@ param(
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.5"
|
||||
$ScriptVersion = "2.6"
|
||||
|
||||
# The publisher every binary we author must carry. A -like pattern on the subject, NOT a
|
||||
# The publisher the payload catalog must carry. A -like pattern on the subject, NOT a
|
||||
# thumbprint: certificates are renewed, deployed copies of this script are not. Same value
|
||||
# the loader pins for this script.
|
||||
$ExpectedPublisher = "*O=glueckkanja AG*"
|
||||
|
||||
# Signed catalog at the root of every agent ZIP: the SHA-256 of every other payload file.
|
||||
$AgentCatalogName = "AutopilotMonitor.Agent.cat"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentStagingPath = "$AgentBasePath\Agent-Install"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\bootstrap-script.log"
|
||||
|
||||
@@ -340,56 +354,107 @@ function Get-BootstrapDecision {
|
||||
return [pscustomobject]@{ Install = $true; ReasonCode = $null; RelaxActive = $relax.Active }
|
||||
}
|
||||
|
||||
# Returns the list of reasons why the extracted payload is not ours; an empty result means
|
||||
# every binary we author carries a valid Authenticode signature from the expected publisher.
|
||||
# Third-party assemblies keep their own publisher's signature and are not checked here.
|
||||
# Any error is a rejection: an unreadable signature is an unverified binary.
|
||||
function Get-UnverifiedAgentBinaries {
|
||||
param([string]$Path)
|
||||
|
||||
$rejected = @()
|
||||
$ourBinaries = @(
|
||||
Get-ChildItem -Path $Path -Filter 'AutopilotMonitor.*' -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Extension -in @('.exe', '.dll') }
|
||||
### BEGIN shared payload verification -- embedded verbatim into the OOBE go-script by
|
||||
### OobeBootstrapScriptGenerator (D-354). Keep it self-contained: parameters only, no
|
||||
### script-scope variables, no Write-Log.
|
||||
# Returns the reasons why the extracted payload in $Path must not run; an empty result means
|
||||
# it matches its signed catalog exactly. Test-FileCatalog's Status covers the hashes only (an
|
||||
# unsigned catalog over matching files reports Valid), so the signature and its publisher are
|
||||
# judged on their own. Any error is a rejection: a payload that cannot be verified is an
|
||||
# unverified payload. The comma keeps a single rejection an ARRAY: PowerShell unrolls a
|
||||
# one-element result to a bare string, and the caller reads .Count.
|
||||
function Get-AgentPayloadRejections {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$ExpectedPublisher,
|
||||
[string]$CatalogName = 'AutopilotMonitor.Agent.cat'
|
||||
)
|
||||
|
||||
# An empty set is a rejection, not a pass: it means the extraction produced nothing we
|
||||
# recognise, and the alternative would be a gate that silently approves an empty folder.
|
||||
# The comma keeps a single rejection an ARRAY: PowerShell unrolls a one-element result to
|
||||
# a bare string, and the caller reads .Count and indexes into it.
|
||||
if ($ourBinaries.Count -eq 0) {
|
||||
return , @("no AutopilotMonitor binaries found in $Path")
|
||||
$catalog = Join-Path $Path $CatalogName
|
||||
if (-not (Test-Path -LiteralPath $catalog -PathType Leaf)) {
|
||||
return , @("catalog $CatalogName missing")
|
||||
}
|
||||
|
||||
foreach ($binary in $ourBinaries) {
|
||||
try {
|
||||
$sig = Get-AuthenticodeSignature -FilePath $binary.FullName -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
$rejected += "$($binary.Name): signature check failed to run ($($_.Exception.Message))"
|
||||
continue
|
||||
}
|
||||
try {
|
||||
$result = Test-FileCatalog -Path $Path -CatalogFilePath $catalog -FilesToSkip $CatalogName -Detailed -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
return , @("catalog check failed to run ($($_.Exception.Message))")
|
||||
}
|
||||
if ($null -eq $result) {
|
||||
return , @('catalog check returned nothing')
|
||||
}
|
||||
|
||||
if ($null -eq $sig) {
|
||||
$rejected += "$($binary.Name): signature check returned nothing"
|
||||
continue
|
||||
}
|
||||
$rejected = @()
|
||||
$signature = $result.Signature
|
||||
if ($null -eq $signature -or "$($signature.Status)" -ne 'Valid') {
|
||||
$status = if ($null -eq $signature) { 'missing' } else { "$($signature.Status)" }
|
||||
$rejected += "catalog signature status $status"
|
||||
}
|
||||
elseif ($null -eq $signature.SignerCertificate -or $signature.SignerCertificate.Subject -notlike $ExpectedPublisher) {
|
||||
$subject = if ($null -eq $signature.SignerCertificate) { '' } else { $signature.SignerCertificate.Subject }
|
||||
$rejected += "catalog publisher '$subject' does not match $ExpectedPublisher"
|
||||
}
|
||||
|
||||
$subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '<unsigned>' }
|
||||
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
$rejected += "$($binary.Name): signature status $($sig.Status)"
|
||||
continue
|
||||
}
|
||||
if ($subject -notlike $ExpectedPublisher) {
|
||||
$rejected += "$($binary.Name): unexpected publisher $subject"
|
||||
continue
|
||||
if ("$($result.Status)" -ne 'Valid') {
|
||||
$expected = if ($null -eq $result.CatalogItems) { @{} } else { $result.CatalogItems }
|
||||
$actual = if ($null -eq $result.PathItems) { @{} } else { $result.PathItems }
|
||||
$names = {
|
||||
param($list)
|
||||
if ($list.Count -eq 0) { return 'none' }
|
||||
$shown = ($list | Select-Object -First 5) -join ', '
|
||||
if ($list.Count -gt 5) { $shown += " (+$($list.Count - 5) more)" }
|
||||
return $shown
|
||||
}
|
||||
$missing = @($expected.Keys | Where-Object { -not $actual.ContainsKey($_) } | Sort-Object)
|
||||
$extra = @($actual.Keys | Where-Object { -not $expected.ContainsKey($_) } | Sort-Object)
|
||||
$changed = @($expected.Keys | Where-Object { $actual.ContainsKey($_) -and $actual[$_] -ne $expected[$_] } | Sort-Object)
|
||||
$rejected += "payload differs from catalog (missing: $(& $names $missing); extra: $(& $names $extra); changed: $(& $names $changed))"
|
||||
}
|
||||
|
||||
return , $rejected
|
||||
}
|
||||
|
||||
# Extracts the agent ZIP into a fresh staging folder, verifies it there and moves it into the
|
||||
# agent folder only when it matches its signed catalog. A rejected payload is deleted with the
|
||||
# staging folder: nothing unverified lands where SYSTEM runs the agent, and the next run starts
|
||||
# clean instead of finding a half-installed agent. The caller has made sure no agent is
|
||||
# installed, so anything already in the agent folder is a leftover and goes first.
|
||||
function Install-VerifiedAgentPayload {
|
||||
param(
|
||||
[string]$ZipPath,
|
||||
[string]$StagingPath,
|
||||
[string]$TargetPath,
|
||||
[string]$ExpectedPublisher,
|
||||
[string]$CatalogName = 'AutopilotMonitor.Agent.cat'
|
||||
)
|
||||
|
||||
if (Test-Path -LiteralPath $StagingPath) {
|
||||
Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
New-Item -Path $StagingPath -ItemType Directory -Force | Out-Null
|
||||
try {
|
||||
Expand-Archive -Path $ZipPath -DestinationPath $StagingPath -Force -ErrorAction Stop
|
||||
|
||||
$rejections = Get-AgentPayloadRejections -Path $StagingPath -ExpectedPublisher $ExpectedPublisher -CatalogName $CatalogName
|
||||
if ($rejections.Count -gt 0) {
|
||||
throw "Payload verification FAILED ($($rejections -join '; ')). Refusing to install an agent that is not the published one."
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $TargetPath) {
|
||||
Get-ChildItem -LiteralPath $TargetPath -Force | Remove-Item -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
else {
|
||||
New-Item -Path $TargetPath -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
Get-ChildItem -LiteralPath $StagingPath -Force | Move-Item -Destination $TargetPath -Force -ErrorAction Stop
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
### END shared payload verification
|
||||
|
||||
# Main bootstrap flow. Only invoked when the script is run directly (see entry guard
|
||||
# at the bottom); never on dot-source.
|
||||
function Invoke-Bootstrap {
|
||||
@@ -408,117 +473,106 @@ function Invoke-Bootstrap {
|
||||
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (relaxActive=$($decision.RelaxActive))"
|
||||
|
||||
# Download and extract agent binaries
|
||||
# Download, verify and install the agent binaries. Guard 5 has already returned when an
|
||||
# agent exe exists, so this always starts without an installed agent.
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
|
||||
if (Test-Path $agentExePath) {
|
||||
Write-Log "Agent already installed at $agentExePath"
|
||||
}
|
||||
else {
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
try {
|
||||
# Derive manifest URL from the agent download URL (same blob container)
|
||||
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
|
||||
$expectedSha256 = $null
|
||||
$manifestAttempts = 3
|
||||
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
|
||||
|
||||
try {
|
||||
# Derive manifest URL from the agent download URL (same blob container)
|
||||
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
|
||||
$expectedSha256 = $null
|
||||
$manifestAttempts = 3
|
||||
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
|
||||
|
||||
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
|
||||
try {
|
||||
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
|
||||
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||
if ($versionJsonResponse.sha256) {
|
||||
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
|
||||
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
|
||||
} else {
|
||||
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
|
||||
}
|
||||
break # manifest reached (with or without hash); retrying would not help
|
||||
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
|
||||
try {
|
||||
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
|
||||
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||
if ($versionJsonResponse.sha256) {
|
||||
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
|
||||
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
|
||||
} else {
|
||||
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
|
||||
}
|
||||
catch {
|
||||
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
|
||||
if ($attempt -lt $manifestAttempts) {
|
||||
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
|
||||
}
|
||||
break # manifest reached (with or without hash); retrying would not help
|
||||
}
|
||||
catch {
|
||||
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
|
||||
if ($attempt -lt $manifestAttempts) {
|
||||
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
|
||||
}
|
||||
}
|
||||
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
|
||||
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
Invoke-WebRequest `
|
||||
-Uri $AgentDownloadUrl `
|
||||
-OutFile $zipPath `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 30 `
|
||||
-ErrorAction Stop
|
||||
$downloadStopwatch.Stop()
|
||||
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
|
||||
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) {
|
||||
throw
|
||||
}
|
||||
|
||||
$retryDelaysInSeconds = @(2, 4, 8)
|
||||
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
|
||||
Start-Sleep -Seconds $retryDelaySeconds
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
# SHA-256 from the version manifest is the only accepted integrity proof.
|
||||
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
|
||||
if (-not $expectedSha256) {
|
||||
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
|
||||
}
|
||||
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Log "Validating SHA-256 hash against manifest"
|
||||
if ($actualSha256 -ne $expectedSha256) {
|
||||
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
|
||||
}
|
||||
Write-Log "SHA-256 integrity check passed"
|
||||
|
||||
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
|
||||
Write-Log "Extracted agent to $AgentBinPath"
|
||||
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "Cleaned up temporary files"
|
||||
|
||||
if (-not (Test-Path $agentExePath)) {
|
||||
throw "Agent executable not found after extraction at $agentExePath"
|
||||
}
|
||||
|
||||
Write-Log "Agent installation completed successfully"
|
||||
}
|
||||
catch {
|
||||
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
# Publisher gate. The SHA-256 above proves the ZIP is the one the manifest names, but
|
||||
# that manifest lives in the same container as the ZIP and cannot outlive a compromise
|
||||
# of the download host. The expected publisher travels inside THIS script, which the
|
||||
# loader verified before running it, so the chain from the file an administrator
|
||||
# assigns to the process that starts here stays publisher-anchored end to end.
|
||||
# Fail-closed, exactly like the hash check: running unverified code as SYSTEM is the
|
||||
# risk being removed.
|
||||
Write-Log "Verifying Authenticode signatures of the agent binaries..."
|
||||
$unverified = Get-UnverifiedAgentBinaries -Path $AgentBinPath
|
||||
if ($unverified.Count -gt 0) {
|
||||
throw "Authenticode verification FAILED ($($unverified -join '; ')). Refusing to run an agent that is not signed by the expected publisher."
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
|
||||
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
Invoke-WebRequest `
|
||||
-Uri $AgentDownloadUrl `
|
||||
-OutFile $zipPath `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 30 `
|
||||
-ErrorAction Stop
|
||||
$downloadStopwatch.Stop()
|
||||
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
|
||||
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) {
|
||||
throw
|
||||
}
|
||||
|
||||
$retryDelaysInSeconds = @(2, 4, 8)
|
||||
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
|
||||
Start-Sleep -Seconds $retryDelaySeconds
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
# SHA-256 from the version manifest is the only accepted integrity proof.
|
||||
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
|
||||
if (-not $expectedSha256) {
|
||||
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
|
||||
}
|
||||
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Log "Validating SHA-256 hash against manifest"
|
||||
if ($actualSha256 -ne $expectedSha256) {
|
||||
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
|
||||
}
|
||||
Write-Log "SHA-256 integrity check passed"
|
||||
|
||||
# Payload catalog. The SHA-256 above proves the ZIP is the one the manifest names, but
|
||||
# that manifest lives in the same container as the ZIP and cannot outlive a compromise
|
||||
# of the download host. The catalog is signed by the expected publisher, whose pin
|
||||
# travels inside THIS script -- verified by the loader before it ran -- so the chain
|
||||
# from the file an administrator assigns to every file the agent runs from stays
|
||||
# publisher-anchored end to end. Fail-closed like the hash check: running unverified
|
||||
# code as SYSTEM is the risk being removed.
|
||||
Write-Log "Verifying the payload against its signed catalog ($AgentCatalogName, publisher pin: $ExpectedPublisher)..."
|
||||
Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $AgentStagingPath -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName
|
||||
Write-Log "Payload verified against its signed catalog and installed to $AgentBinPath"
|
||||
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "Cleaned up temporary files"
|
||||
|
||||
if (-not (Test-Path $agentExePath)) {
|
||||
throw "Agent executable not found after extraction at $agentExePath"
|
||||
}
|
||||
|
||||
Write-Log "Agent installation completed successfully"
|
||||
}
|
||||
catch {
|
||||
Write-Log "ERROR downloading/verifying agent: $($_.Exception.Message)"
|
||||
throw
|
||||
}
|
||||
Write-Log "Authenticode verification passed (publisher pin: $ExpectedPublisher)"
|
||||
|
||||
Write-Log "Calling agent install mode (--install)..."
|
||||
& $agentExePath --install
|
||||
|
||||
@@ -17,6 +17,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
public DistressErrorType ErrorType { get; set; }
|
||||
public string Message { get; set; } = default!;
|
||||
public int? HttpStatusCode { get; set; }
|
||||
public string? TpmVersion { get; set; }
|
||||
}
|
||||
|
||||
public List<Call> Calls { get; } = new List<Call>();
|
||||
@@ -32,9 +33,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
{
|
||||
}
|
||||
|
||||
public override Task TrySendAsync(DistressErrorType errorType, string message, int? httpStatusCode = null)
|
||||
public override Task TrySendAsync(DistressErrorType errorType, string message, int? httpStatusCode = null, string? tpmVersion = null)
|
||||
{
|
||||
Calls.Add(new Call { ErrorType = errorType, Message = message, HttpStatusCode = httpStatusCode });
|
||||
Calls.Add(new Call { ErrorType = errorType, Message = message, HttpStatusCode = httpStatusCode, TpmVersion = tpmVersion });
|
||||
return Send?.Invoke() ?? Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
+35
-1
@@ -12,7 +12,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
/// The bootstrap provenance in <c>agent_started</c>. Fixture lines are the scripts' real
|
||||
/// formats: installer <c>[ts] Bootstrap script version: vX.Y</c>
|
||||
/// (Install-AutopilotMonitor.ps1), loader <c>[ts] === Autopilot Monitor bootstrap loader vX.Y
|
||||
/// started (user=..., pid=...) ===</c> (Start-AutopilotMonitor.ps1).
|
||||
/// started (user=..., pid=...) ===</c> (Start-AutopilotMonitor.ps1), the MSI's downloader before
|
||||
/// the loader <c>[ts] MSI bootstrap runner started (user=..., pid=...).</c>.
|
||||
/// </summary>
|
||||
public sealed class BootstrapProvenanceReaderTests : IDisposable
|
||||
{
|
||||
@@ -79,6 +80,39 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
Assert.Null(p.ScriptVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_installer_from_before_the_log_rename_is_read_under_its_old_name()
|
||||
{
|
||||
Write(BootstrapProvenanceReader.LegacyInstallerLogName, InstallerRun("2.3"));
|
||||
|
||||
var p = BootstrapProvenanceReader.Read(_dir);
|
||||
|
||||
Assert.True(p.ScriptLogPresent);
|
||||
Assert.Equal("2.3", p.ScriptVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_both_installer_logs_the_most_recently_written_one_counts()
|
||||
{
|
||||
Write(BootstrapProvenanceReader.InstallerLogName, InstallerRun("2.6"));
|
||||
Write(BootstrapProvenanceReader.LegacyInstallerLogName, InstallerRun("2.3"));
|
||||
File.SetLastWriteTimeUtc(Path.Combine(_dir, BootstrapProvenanceReader.InstallerLogName), DateTime.UtcNow.AddDays(-3));
|
||||
File.SetLastWriteTimeUtc(Path.Combine(_dir, BootstrapProvenanceReader.LegacyInstallerLogName), DateTime.UtcNow.AddMinutes(-1));
|
||||
|
||||
Assert.Equal("2.3", BootstrapProvenanceReader.Read(_dir).ScriptVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_msi_downloader_from_before_the_loader_counts_as_msi_without_version()
|
||||
{
|
||||
Write(BootstrapProvenanceReader.LegacyMsiLoaderLogName, "[2026-08-01 09:00:00] MSI bootstrap runner started (user=SECRETUSER, pid=4242).");
|
||||
|
||||
var p = BootstrapProvenanceReader.Read(_dir);
|
||||
|
||||
Assert.Equal("msi", p.LoaderLog);
|
||||
Assert.Null(p.LoaderVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_msi_loader_log_is_told_apart_from_the_intune_loader()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Runtime;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
{
|
||||
/// <summary>
|
||||
/// The TPM-PSS report after a terminal SecureChannelFailure: it leaves before the run exits,
|
||||
/// carries the handshake code, the Schannel events and the TPM firmware, and a slow or failing
|
||||
/// context read never holds it back.
|
||||
/// </summary>
|
||||
public sealed class TpmPssReportTests
|
||||
{
|
||||
private static AgentLogger NewLogger(TempDirectory tmp)
|
||||
=> new AgentLogger(Path.Combine(tmp.Path, "logs"), AgentLogLevel.Debug);
|
||||
|
||||
private static TpmPssProbeResult Probe(bool pssWorks = false, string provider = "Microsoft Platform Crypto Provider") =>
|
||||
new TpmPssProbeResult(
|
||||
pkcs1Sha256Works: true, pssSha256Works: pssWorks, pssSha384Works: pssWorks,
|
||||
providerName: provider, keySizeBits: 2048,
|
||||
pssSha256Error: pssWorks ? null : "salt", pssSha384Error: pssWorks ? null : "not supported",
|
||||
pssSha256ErrorCode: pssWorks ? (int?)null : unchecked((int)0x80290423),
|
||||
pssSha384ErrorCode: pssWorks ? (int?)null : unchecked((int)0x80090029));
|
||||
|
||||
private static Exception TlsFailure() =>
|
||||
new WebException("Could not create SSL/TLS secure channel.",
|
||||
new Win32Exception(unchecked((int)0x80090326)), WebExceptionStatus.SecureChannelFailure, null);
|
||||
|
||||
private static Func<Task<(string TpmVersion, string SchannelEvents)>> Context(string tpm, string schannel) =>
|
||||
() => Task.FromResult((tpm, schannel));
|
||||
|
||||
[Fact]
|
||||
public async Task Broken_tpm_report_completes_only_after_the_send()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var sendGate = new TaskCompletionSource<bool>();
|
||||
var reporter = new FakeDistressReporter { Send = () => sendGate.Task };
|
||||
|
||||
var report = BackendSessionRegistration.ReportProbeResultAsync(
|
||||
Probe(), TlsFailure(), reporter, NewLogger(tmp), Context("IFX 7.63.3353.0", "36888:40/1205"));
|
||||
|
||||
var call = Assert.Single(reporter.Calls);
|
||||
Assert.False(report.IsCompleted);
|
||||
Assert.Equal(DistressErrorType.TpmPssUnsupported, call.ErrorType);
|
||||
Assert.Equal("IFX 7.63.3353.0", call.TpmVersion);
|
||||
Assert.Equal(
|
||||
"pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029) tls=0x80090326 sch=36888:40/1205 prov=PCP key=2048",
|
||||
call.Message);
|
||||
|
||||
sendGate.SetResult(true);
|
||||
await report;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failing_context_read_still_sends()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var reporter = new FakeDistressReporter();
|
||||
|
||||
await BackendSessionRegistration.ReportProbeResultAsync(
|
||||
Probe(), TlsFailure(), reporter, NewLogger(tmp),
|
||||
() => throw new InvalidOperationException("WMI gone"));
|
||||
|
||||
var call = Assert.Single(reporter.Calls);
|
||||
Assert.Null(call.TpmVersion);
|
||||
Assert.DoesNotContain("sch=", call.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Working_pss_sends_nothing()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var reporter = new FakeDistressReporter();
|
||||
|
||||
await BackendSessionRegistration.ReportProbeResultAsync(
|
||||
Probe(pssWorks: true), TlsFailure(), reporter, NewLogger(tmp), Context("IFX 7.85", null!));
|
||||
|
||||
Assert.Empty(reporter.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Software_key_with_broken_pss_sends_nothing()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var reporter = new FakeDistressReporter();
|
||||
|
||||
await BackendSessionRegistration.ReportProbeResultAsync(
|
||||
Probe(provider: "Microsoft Software Key Storage Provider"), TlsFailure(), reporter, NewLogger(tmp), Context(null!, null!));
|
||||
|
||||
Assert.Empty(reporter.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Context_reads_return_what_finished_within_the_bound()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var slowRelease = new ManualResetEventSlim(false);
|
||||
using var fastReturned = new ManualResetEventSlim(false);
|
||||
try
|
||||
{
|
||||
// The bound ends only after the fast read returned (plus a grace for its task to
|
||||
// complete) — independent of how busy the thread pool is.
|
||||
var bound = Task.Run(async () =>
|
||||
{
|
||||
fastReturned.Wait(TimeSpan.FromSeconds(30));
|
||||
await Task.Delay(500);
|
||||
});
|
||||
|
||||
var (tpm, schannel) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
|
||||
() => { fastReturned.Set(); return "IFX 7.63.3353.0"; },
|
||||
() => { slowRelease.Wait(TimeSpan.FromSeconds(30)); return "late"; },
|
||||
bound,
|
||||
NewLogger(tmp));
|
||||
|
||||
Assert.Equal("IFX 7.63.3353.0", tpm);
|
||||
Assert.Null(schannel);
|
||||
}
|
||||
finally
|
||||
{
|
||||
slowRelease.Set();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Context_read_that_throws_counts_as_missing()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
|
||||
var (tpm, schannel) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
|
||||
() => throw new InvalidOperationException("no WMI"),
|
||||
() => "36887:116",
|
||||
Task.Delay(TimeSpan.FromSeconds(30)),
|
||||
NewLogger(tmp));
|
||||
|
||||
Assert.Null(tpm);
|
||||
Assert.Equal("36887:116", schannel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
public sealed class SchannelEventReaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Token_is_event_id_with_numeric_properties()
|
||||
{
|
||||
Assert.Equal("36888:40/1205", SchannelEventReader.FormatToken(36888, new[] { "40", "1205" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_drops_text_properties()
|
||||
{
|
||||
// Newer builds add target name and certificate subjects; they stay on the device.
|
||||
Assert.Equal("36887:116", SchannelEventReader.FormatToken(
|
||||
36887, new[] { "116", "autopilotmonitor-api.example.test", "CN=device", "" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_without_numbers_is_the_event_id()
|
||||
{
|
||||
Assert.Equal("36871", SchannelEventReader.FormatToken(36871, new[] { "TLS 1.2" }));
|
||||
Assert.Equal("36871", SchannelEventReader.FormatToken(36871, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_keeps_at_most_three_numbers()
|
||||
{
|
||||
Assert.Equal("36888:1/2/3", SchannelEventReader.FormatToken(36888, new[] { "1", "2", "3", "4" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_rejects_non_ascii_digits()
|
||||
{
|
||||
Assert.Equal("36888", SchannelEventReader.FormatToken(36888, new[] { "٤٠" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_is_accepted_by_the_event_log_service()
|
||||
{
|
||||
// A malformed XPath would only surface as "no events" through ReadSince (it never
|
||||
// throws), so the query itself is run here.
|
||||
using (var reader = new EventLogReader(new EventLogQuery(
|
||||
"System", PathType.LogName, SchannelEventReader.BuildXPath(DateTime.UtcNow.AddDays(-1))) { ReverseDirection = true }))
|
||||
{
|
||||
using (reader.ReadEvent()) { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_events_after_now()
|
||||
{
|
||||
Assert.Null(SchannelEventReader.ReadSince(DateTime.UtcNow.AddHours(1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -332,6 +332,74 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
Assert.Contains("transient attempt 6", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Terminal_callback_gets_the_last_attempts_exception()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
Exception? seen = null;
|
||||
|
||||
var apiClient = new FakeApiClient(attempt =>
|
||||
throw new InvalidOperationException($"transient attempt {attempt}"));
|
||||
|
||||
await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
backoffDelay: _ => Task.CompletedTask,
|
||||
onTerminalTransportFailure: ex => { seen = ex; return Task.CompletedTask; });
|
||||
|
||||
Assert.Equal("transient attempt 6", seen?.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Terminal_callback_skipped_when_the_last_attempt_answered()
|
||||
{
|
||||
// Five transport failures, then the backend answers without success: the run did not
|
||||
// end in a transport failure, so nothing may classify the old exception.
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
var callbackRan = false;
|
||||
|
||||
var apiClient = new FakeApiClient(attempt =>
|
||||
{
|
||||
if (attempt < 6) throw new InvalidOperationException($"transient attempt {attempt}");
|
||||
return new RegisterSessionResponse { Success = false, Message = "backend 500" };
|
||||
});
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
backoffDelay: _ => Task.CompletedTask,
|
||||
onTerminalTransportFailure: _ => { callbackRan = true; return Task.CompletedTask; });
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.Failed, result.Outcome);
|
||||
Assert.Equal("backend 500", result.ErrorMessage);
|
||||
Assert.False(callbackRan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failure_message_carries_the_native_code_of_the_tls_failure()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
|
||||
var apiClient = new FakeApiClient(_ =>
|
||||
throw new System.Net.Http.HttpRequestException(
|
||||
"An error occurred while sending the request.",
|
||||
new System.Net.WebException(
|
||||
"Could not create SSL/TLS secure channel.",
|
||||
new System.ComponentModel.Win32Exception(unchecked((int)0x80090326)),
|
||||
System.Net.WebExceptionStatus.SecureChannelFailure,
|
||||
null)));
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
backoffDelay: _ => Task.CompletedTask);
|
||||
|
||||
Assert.Equal("An error occurred while sending the request. (win32 0x80090326)", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Waits_for_network_link_before_first_attempt()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// The TPM-PSS distress message: codes instead of localized error texts, so reports from
|
||||
/// German, Czech and English devices read the same and the 256-char budget holds the
|
||||
/// handshake code and the Schannel events.
|
||||
/// </summary>
|
||||
public sealed class TpmPssProbeResultTests
|
||||
{
|
||||
private const int SaltSizeMismatch = unchecked((int)0x80290423);
|
||||
private const int NotSupported = unchecked((int)0x80090029);
|
||||
|
||||
private static TpmPssProbeResult BrokenPcp(string provider = "Microsoft Platform Crypto Provider") =>
|
||||
new TpmPssProbeResult(
|
||||
pkcs1Sha256Works: true,
|
||||
pssSha256Works: false,
|
||||
pssSha384Works: false,
|
||||
providerName: provider,
|
||||
keySizeBits: 2048,
|
||||
pssSha256Error: "Die angeforderte Salt-Größe für die Signierung mit RSAPSS entspricht nicht dem, was TPM verwendet.",
|
||||
pssSha384Error: "Der angeforderte Vorgang wird nicht unterstützt.",
|
||||
pssSha256ErrorCode: SaltSizeMismatch,
|
||||
pssSha384ErrorCode: NotSupported);
|
||||
|
||||
[Fact]
|
||||
public void Message_carries_codes_handshake_and_schannel_events()
|
||||
{
|
||||
var message = BrokenPcp().ToDistressMessage("0x80090326", "36888:40/1205,36887:116");
|
||||
|
||||
Assert.Equal(
|
||||
"pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029) tls=0x80090326 sch=36888:40/1205,36887:116 prov=PCP key=2048",
|
||||
message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Message_never_carries_the_localized_error_text()
|
||||
{
|
||||
var message = BrokenPcp().ToDistressMessage();
|
||||
|
||||
Assert.DoesNotContain("Salt", message);
|
||||
Assert.Equal("pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029) prov=PCP key=2048", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Working_signatures_and_failures_without_a_code()
|
||||
{
|
||||
var result = new TpmPssProbeResult(
|
||||
pkcs1Sha256Works: true, pssSha256Works: true, pssSha384Works: false,
|
||||
providerName: "Microsoft Software Key Storage Provider", keySizeBits: 0,
|
||||
pssSha256Error: null, pssSha384Error: "empty signature");
|
||||
|
||||
Assert.Equal("pkcs1=ok pss256=ok pss384=fail prov=KSP", result.ToDistressMessage());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Message_stays_within_the_distress_cap()
|
||||
{
|
||||
var longEvents = string.Join(",", System.Linq.Enumerable.Repeat("36888:4294967295/4294967295/4294967295", 10));
|
||||
|
||||
var message = BrokenPcp(new string('P', 200)).ToDistressMessage("0x80090326", longEvents);
|
||||
|
||||
Assert.Equal(TpmPssProbeResult.MaxDistressMessageLength, message.Length);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Microsoft Platform Crypto Provider", "PCP")]
|
||||
[InlineData("microsoft platform crypto provider", "PCP")]
|
||||
[InlineData("Microsoft Software Key Storage Provider", "KSP")]
|
||||
[InlineData("Contoso Smart Card Provider", "Contoso Smart Card Provider")]
|
||||
[InlineData("", "(unknown)")]
|
||||
public void Provider_names_are_shortened(string provider, string expected)
|
||||
{
|
||||
Assert.Equal(expected, TpmPssProbeResult.ShortProviderName(provider));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Long_provider_name_is_cut_to_32_chars()
|
||||
{
|
||||
Assert.Equal(32, TpmPssProbeResult.ShortProviderName(new string('x', 40)).Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tpm_backed_broken_key_is_the_reportable_footprint()
|
||||
{
|
||||
var result = BrokenPcp();
|
||||
|
||||
Assert.True(result.IsTpmPssBroken);
|
||||
Assert.True(result.IsTpmBacked);
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.DeviceInfo
|
||||
{
|
||||
public sealed class TpmInfoLabelTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("IFX", "7.63.3353.0", "IFX 7.63.3353.0")]
|
||||
[InlineData("STM ", "73.4.17568.4452", "STM 73.4.17568.4452")] // ManufacturerIdTxt is space-padded
|
||||
[InlineData("INTC", null, "INTC")]
|
||||
[InlineData(null, "403.1.0.0", "403.1.0.0")]
|
||||
[InlineData(" ", " ", null)]
|
||||
[InlineData(null, null, null)]
|
||||
public void Label_is_trimmed_manufacturer_and_firmware(string? name, string? version, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, TpmInfo.FormatLabel(name!, version!));
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// What a distress report carries and that it fits the backend's body cap: the endpoint drops
|
||||
/// a larger body with 200, so an oversized report would be lost without a trace.
|
||||
/// </summary>
|
||||
public sealed class DistressReporterPayloadTests
|
||||
{
|
||||
private static DistressReporter NewReporter(string field = "x", string agentVersion = "2.0.1500")
|
||||
=> new DistressReporter(
|
||||
baseUrl: "https://example.test",
|
||||
tenantId: "11111111-2222-3333-4444-555555555555",
|
||||
manufacturer: field,
|
||||
model: field,
|
||||
serialNumber: field,
|
||||
agentVersion: agentVersion,
|
||||
logger: null!);
|
||||
|
||||
[Fact]
|
||||
public void Report_carries_os_build_and_setup_state_at_send_time()
|
||||
{
|
||||
using (var reporter = NewReporter())
|
||||
{
|
||||
var report = reporter.BuildReport(DistressErrorType.AuthCertificateRejected, "m", 401, tpmVersion: null);
|
||||
|
||||
var osBuild = DeviceInfoProvider.GetOsBuild();
|
||||
Assert.Equal(string.IsNullOrEmpty(osBuild) ? null : osBuild, report.OsBuild);
|
||||
Assert.Equal(OobeStateReader.Read(), report.OobeState);
|
||||
Assert.Null(report.TpmVersion);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tpm_version_passes_through_capped_at_48()
|
||||
{
|
||||
using (var reporter = NewReporter())
|
||||
{
|
||||
Assert.Equal("IFX 7.63.3353.0",
|
||||
reporter.BuildReport(DistressErrorType.TpmPssUnsupported, "m", null, "IFX 7.63.3353.0").TpmVersion);
|
||||
Assert.Equal(48,
|
||||
reporter.BuildReport(DistressErrorType.TpmPssUnsupported, "m", null, new string('v', 60)).TpmVersion!.Length);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Largest_ascii_report_fits_the_cap_untouched()
|
||||
{
|
||||
// Every field at the agent's own caps; the agent version as built (not cut to 32).
|
||||
using (var reporter = NewReporter(new string('A', 64), "2.0.1486+d95640c597cb90849803dd65afda19e2d7a39444"))
|
||||
{
|
||||
var report = reporter.BuildReport(DistressErrorType.ClientCertificateNotReceived, new string('m', 256), 403, new string('t', 48));
|
||||
report.CertThumbprint = new string('F', 40);
|
||||
report.CertSubject = new string('S', 96);
|
||||
report.CertIssuer = new string('I', 96);
|
||||
report.CertNotBefore = DateTime.UtcNow;
|
||||
report.CertNotAfter = DateTime.UtcNow;
|
||||
report.OsBuild = "26300.97111";
|
||||
report.OobeState = "unknown_2147483647".Substring(0, 16);
|
||||
|
||||
var json = DistressReporter.SerializeWithinCap(report);
|
||||
|
||||
Assert.Equal(256, report.Message!.Length);
|
||||
Assert.True(Encoding.UTF8.GetByteCount(json) <= Constants.DistressReportMaxBytes,
|
||||
$"{Encoding.UTF8.GetByteCount(json)} bytes");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Oversized_report_gives_up_message_characters_to_fit()
|
||||
{
|
||||
// Three-byte characters everywhere — far past the cap before trimming.
|
||||
using (var reporter = NewReporter(new string('€', 64)))
|
||||
{
|
||||
var report = reporter.BuildReport(DistressErrorType.ClientCertificateNotReceived, new string('€', 256), 403, null);
|
||||
report.CertSubject = new string('€', 96);
|
||||
report.CertIssuer = new string('€', 96);
|
||||
Assert.True(Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(report)) > Constants.DistressReportMaxBytes);
|
||||
|
||||
var json = DistressReporter.SerializeWithinCap(report);
|
||||
|
||||
Assert.True(Encoding.UTF8.GetByteCount(json) <= Constants.DistressReportMaxBytes,
|
||||
$"{Encoding.UTF8.GetByteCount(json)} bytes");
|
||||
Assert.True(report.Message!.Length < 256);
|
||||
Assert.Equal(report.Message, JsonConvert.DeserializeObject<DistressReport>(json)!.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Security.Authentication;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
public sealed class TransportErrorCodesTests
|
||||
{
|
||||
private const int IllegalMessage = unchecked((int)0x80090326);
|
||||
|
||||
// The .NET Framework shape of a failed TLS handshake through HttpClient.
|
||||
private static Exception TlsFailureChain(params Exception[] innermost) =>
|
||||
new HttpRequestException(
|
||||
"An error occurred while sending the request.",
|
||||
new WebException(
|
||||
"The request was aborted: Could not create SSL/TLS secure channel.",
|
||||
new AuthenticationException("A call to SSPI failed, see inner exception.", innermost.Length > 0 ? innermost[0] : null),
|
||||
WebExceptionStatus.SecureChannelFailure,
|
||||
null));
|
||||
|
||||
[Fact]
|
||||
public void Hex_is_eight_digits_with_prefix()
|
||||
{
|
||||
Assert.Equal("0x80090326", TransportErrorCodes.Hex(IllegalMessage));
|
||||
Assert.Equal("0x00002746", TransportErrorCodes.Hex(10054));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Innermost_win32_code_of_a_tls_failure()
|
||||
{
|
||||
var chain = TlsFailureChain(new Win32Exception(IllegalMessage));
|
||||
|
||||
Assert.Equal("0x80090326", TransportErrorCodes.InnermostNativeCode(chain));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Innermost_wins_over_an_outer_win32_exception()
|
||||
{
|
||||
// The outer code is whatever the last Win32 error was; only the innermost counts.
|
||||
var chain = new Win32Exception("outer", new Win32Exception(IllegalMessage));
|
||||
|
||||
Assert.Equal("0x80090326", TransportErrorCodes.InnermostNativeCode(chain));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_win32_exception_no_code()
|
||||
{
|
||||
Assert.Null(TransportErrorCodes.InnermostNativeCode(TlsFailureChain()));
|
||||
Assert.Null(TransportErrorCodes.InnermostNativeCode(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Message_gets_the_code_appended()
|
||||
{
|
||||
var chain = TlsFailureChain(new Win32Exception(IllegalMessage));
|
||||
|
||||
Assert.Equal(
|
||||
"An error occurred while sending the request. (win32 0x80090326)",
|
||||
TransportErrorCodes.MessageWithNativeCode(chain));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Message_without_a_code_stays_as_is()
|
||||
{
|
||||
Assert.Equal("boom", TransportErrorCodes.MessageWithNativeCode(new InvalidOperationException("boom")));
|
||||
Assert.Null(TransportErrorCodes.MessageWithNativeCode(null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-14
@@ -13,10 +13,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// passing a version to <c>--install</c> would only ever cover installers released after that
|
||||
/// change -- the old installers still assigned in Intune are exactly the ones worth counting.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>bootstrap-script.log</c>: <c>Bootstrap script version: vX.Y</c>, written by every
|
||||
/// <item><c>bootstrap-script.log</c> (<c>bootstrap_agent.log</c> up to installer v2.3, before
|
||||
/// the log rename of 2026-08-18): <c>Bootstrap script version: vX.Y</c>, written by every
|
||||
/// installer run since 2026-04-09. A log without that line means an older installer.</item>
|
||||
/// <item><c>bootstrap-loader.log</c> (Intune) / <c>bootstrap-msi.log</c> (MSI):
|
||||
/// <c>bootstrap loader vX.Y started</c>, written by every loader since v1.0.</item>
|
||||
/// <item><c>bootstrap-loader.log</c> (Intune) / <c>bootstrap-msi.log</c> (MSI,
|
||||
/// <c>msi-bootstrap.log</c> before 2026-08-18): <c>bootstrap loader vX.Y started</c>, written by
|
||||
/// every loader since v1.0. The MSI's own downloader, which the loader replaced on 2026-09-08,
|
||||
/// writes no version.</item>
|
||||
/// <item><c>oobe_bootstrap.log</c>: the OOBE go-script (generated by the backend, no version).</item>
|
||||
/// </list>
|
||||
/// Only the version number is extracted; the loader line also names the account, which is
|
||||
@@ -29,6 +32,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
internal const string MsiLoaderLogName = "bootstrap-msi.log";
|
||||
internal const string OobeLogName = "oobe_bootstrap.log";
|
||||
|
||||
// Names before the log rename of 2026-08-18; copies of those scripts are still assigned.
|
||||
internal const string LegacyInstallerLogName = "bootstrap_agent.log";
|
||||
internal const string LegacyMsiLoaderLogName = "msi-bootstrap.log";
|
||||
|
||||
// The logs grow across runs; the last run's lines sit at the end. One run writes a few KB.
|
||||
private const int TailBytes = 64 * 1024;
|
||||
|
||||
@@ -42,22 +49,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
try
|
||||
{
|
||||
var installerLog = ReadTail(Path.Combine(logDirectory, InstallerLogName));
|
||||
var installerLog = ReadTail(MostRecentlyWritten(logDirectory, InstallerLogName, LegacyInstallerLogName));
|
||||
|
||||
// Intune loader and MSI loader write separate files; when a device has both, the
|
||||
// more recently written one belongs to the bootstrap that ran last.
|
||||
var loader = new[] { (Name: LoaderLogName, Channel: "loader"), (Name: MsiLoaderLogName, Channel: "msi") }
|
||||
.Select(l => (l.Channel, Path: Path.Combine(logDirectory, l.Name)))
|
||||
.Where(l => File.Exists(l.Path))
|
||||
.OrderByDescending(l => File.GetLastWriteTimeUtc(l.Path))
|
||||
.Select(l => (l.Channel, Version: LastVersion(ReadTail(l.Path), LoaderStartLine)))
|
||||
.FirstOrDefault();
|
||||
// Intune loader and MSI loader write separate files; every name but the Intune
|
||||
// loader's is the MSI's.
|
||||
var loaderPath = MostRecentlyWritten(logDirectory, LoaderLogName, MsiLoaderLogName, LegacyMsiLoaderLogName);
|
||||
var loaderChannel = loaderPath == null ? null
|
||||
: string.Equals(Path.GetFileName(loaderPath), LoaderLogName, StringComparison.OrdinalIgnoreCase) ? "loader" : "msi";
|
||||
|
||||
return new BootstrapProvenance(
|
||||
scriptLogPresent: installerLog != null,
|
||||
scriptVersion: LastVersion(installerLog, InstallerVersionLine),
|
||||
loaderLog: loader.Channel,
|
||||
loaderVersion: loader.Version,
|
||||
loaderLog: loaderChannel,
|
||||
loaderVersion: LastVersion(ReadTail(loaderPath), LoaderStartLine),
|
||||
oobeLogPresent: File.Exists(Path.Combine(logDirectory, OobeLogName)));
|
||||
}
|
||||
catch
|
||||
@@ -66,6 +70,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The most recently written of the named logs that exist, or null. On a device with
|
||||
/// several, it belongs to the bootstrap that ran last.
|
||||
/// </summary>
|
||||
private static string MostRecentlyWritten(string directory, params string[] names) =>
|
||||
names.Select(name => Path.Combine(directory, name))
|
||||
.Where(path => File.Exists(path))
|
||||
.OrderByDescending(path => File.GetLastWriteTimeUtc(path))
|
||||
.FirstOrDefault();
|
||||
|
||||
internal static string LastVersion(string text, Regex line)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return null;
|
||||
|
||||
+9
-19
@@ -633,26 +633,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
new ManagementScope(@"\\.\root\CIMV2\Security\MicrosoftTpm"),
|
||||
new ObjectQuery("SELECT IsActivated_InitialValue, IsEnabled_InitialValue, IsOwned_InitialValue, ManufacturerIdTxt, ManufacturerVersion, SpecVersion FROM Win32_Tpm")))
|
||||
var tpm = TpmInfoReader.Read();
|
||||
if (tpm != null)
|
||||
{
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
data["isActivated"] = obj["IsActivated_InitialValue"]?.ToString() == "True";
|
||||
data["isEnabled"] = obj["IsEnabled_InitialValue"]?.ToString() == "True";
|
||||
data["isOwned"] = obj["IsOwned_InitialValue"]?.ToString() == "True";
|
||||
data["manufacturerName"] = obj["ManufacturerIdTxt"]?.ToString();
|
||||
data["manufacturerVersion"] = obj["ManufacturerVersion"]?.ToString();
|
||||
|
||||
// SpecVersion is comma-separated: "2.0, 0, 1.59" → first element is the spec version
|
||||
var specVersion = obj["SpecVersion"]?.ToString();
|
||||
if (!string.IsNullOrEmpty(specVersion))
|
||||
{
|
||||
var parts = specVersion.Split(',');
|
||||
data["specVersion"] = parts[0].Trim();
|
||||
}
|
||||
}
|
||||
data["isActivated"] = tpm.IsActivated;
|
||||
data["isEnabled"] = tpm.IsEnabled;
|
||||
data["isOwned"] = tpm.IsOwned;
|
||||
data["manufacturerName"] = tpm.ManufacturerName;
|
||||
data["manufacturerVersion"] = tpm.ManufacturerVersion;
|
||||
if (tpm.SpecVersion != null)
|
||||
data["specVersion"] = tpm.SpecVersion;
|
||||
}
|
||||
|
||||
if (data.Count > 0)
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
using System.Management;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
{
|
||||
/// <summary>The TPM as <c>Win32_Tpm</c> reports it.</summary>
|
||||
internal sealed class TpmInfo
|
||||
{
|
||||
public bool IsActivated { get; set; }
|
||||
public bool IsEnabled { get; set; }
|
||||
public bool IsOwned { get; set; }
|
||||
|
||||
/// <summary><c>ManufacturerIdTxt</c> as reported — may be space-padded (<c>"STM "</c>).</summary>
|
||||
public string ManufacturerName { get; set; }
|
||||
|
||||
/// <summary><c>ManufacturerVersion</c>: the firmware version.</summary>
|
||||
public string ManufacturerVersion { get; set; }
|
||||
|
||||
/// <summary>First element of <c>SpecVersion</c> (<c>"2.0, 0, 1.59"</c> → <c>"2.0"</c>); null when absent.</summary>
|
||||
public string SpecVersion { get; set; }
|
||||
|
||||
/// <summary>Manufacturer id and firmware version, trimmed: <c>IFX 7.63.3353.0</c>; null when both are empty.</summary>
|
||||
public string Label => FormatLabel(ManufacturerName, ManufacturerVersion);
|
||||
|
||||
internal static string FormatLabel(string manufacturerName, string manufacturerVersion)
|
||||
{
|
||||
var label = $"{manufacturerName?.Trim()} {manufacturerVersion?.Trim()}".Trim();
|
||||
return label.Length == 0 ? null : label;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads <c>Win32_Tpm</c> from <c>root\CIMV2\Security\MicrosoftTpm</c>: one WMI connect plus one
|
||||
/// query. Shared by the <c>tpm_status</c> device-info event and the TPM-PSS distress report.
|
||||
/// </summary>
|
||||
internal static class TpmInfoReader
|
||||
{
|
||||
/// <summary>The last listed TPM instance (there is one in practice); null when WMI lists none. Throws on WMI failure.</summary>
|
||||
public static TpmInfo Read()
|
||||
{
|
||||
TpmInfo info = null;
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
new ManagementScope(@"\\.\root\CIMV2\Security\MicrosoftTpm"),
|
||||
new ObjectQuery("SELECT IsActivated_InitialValue, IsEnabled_InitialValue, IsOwned_InitialValue, ManufacturerIdTxt, ManufacturerVersion, SpecVersion FROM Win32_Tpm")))
|
||||
{
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
var specVersion = obj["SpecVersion"]?.ToString();
|
||||
info = new TpmInfo
|
||||
{
|
||||
IsActivated = obj["IsActivated_InitialValue"]?.ToString() == "True",
|
||||
IsEnabled = obj["IsEnabled_InitialValue"]?.ToString() == "True",
|
||||
IsOwned = obj["IsOwned_InitialValue"]?.ToString() == "True",
|
||||
ManufacturerName = obj["ManufacturerIdTxt"]?.ToString(),
|
||||
ManufacturerVersion = obj["ManufacturerVersion"]?.ToString(),
|
||||
SpecVersion = string.IsNullOrEmpty(specVersion) ? null : specVersion.Split(',')[0].Trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return info;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,6 +291,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
sb.AppendLine($" WebException.Status = {we.Status}");
|
||||
}
|
||||
if (current is System.ComponentModel.Win32Exception win32)
|
||||
{
|
||||
sb.AppendLine($" NativeErrorCode = {TransportErrorCodes.Hex(win32.NativeErrorCode)}");
|
||||
}
|
||||
current = current.InnerException;
|
||||
depth++;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
@@ -120,16 +122,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to send a distress report. Returns immediately without awaiting
|
||||
/// (intended to be called as fire-and-forget via <c>_ = reporter.TrySendAsync(...)</c>).
|
||||
/// Attempts to send a distress report. Callers usually fire and forget
|
||||
/// (<c>_ = reporter.TrySendAsync(...)</c>); one that exits right after awaits it — the send
|
||||
/// is bounded by the 5-second HTTP timeout.
|
||||
///
|
||||
/// All anti-flood checks are applied synchronously before the async HTTP call,
|
||||
/// so repeated calls are cheap when suppressed.
|
||||
/// so repeated calls are cheap when suppressed. Every report carries the OS build and the
|
||||
/// Windows setup state at send time; <paramref name="tpmVersion"/> only the TPM-PSS one.
|
||||
/// </summary>
|
||||
public virtual async Task TrySendAsync(
|
||||
DistressErrorType errorType,
|
||||
string message,
|
||||
int? httpStatusCode = null)
|
||||
int? httpStatusCode = null,
|
||||
string tpmVersion = null)
|
||||
{
|
||||
var key = $"{errorType}:{httpStatusCode}";
|
||||
int currentReportCount;
|
||||
@@ -164,29 +169,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
var statusText = httpStatusCode.HasValue ? $" HTTP {httpStatusCode}" : string.Empty;
|
||||
_logger?.Warning($"[DistressChannel] Sending report {currentReportCount}/{MaxReportsPerSession}: {errorType}{statusText}");
|
||||
|
||||
var report = new DistressReport
|
||||
{
|
||||
TenantId = _tenantId,
|
||||
ErrorType = errorType,
|
||||
Manufacturer = _manufacturer,
|
||||
Model = _model,
|
||||
SerialNumber = _serialNumber,
|
||||
AgentVersion = _agentVersion,
|
||||
HttpStatusCode = httpStatusCode,
|
||||
Message = Truncate(message, 256),
|
||||
Timestamp = DateTime.UtcNow,
|
||||
IsCloudPc = _isCloudPc,
|
||||
CertSourceState = _capturedCertState,
|
||||
CertThumbprint = _capturedCertThumbprint,
|
||||
CertSubject = Truncate(_capturedCertSubject, 96),
|
||||
CertIssuer = Truncate(_capturedCertIssuer, 96),
|
||||
CertNotBefore = _capturedCertNotBefore,
|
||||
CertNotAfter = _capturedCertNotAfter,
|
||||
};
|
||||
var report = BuildReport(errorType, message, httpStatusCode, tpmVersion);
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(report);
|
||||
var json = SerializeWithinCap(report);
|
||||
|
||||
using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, _distressUrl)
|
||||
{
|
||||
@@ -216,6 +203,51 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
return value.Length <= maxLength ? value : value.Substring(0, maxLength);
|
||||
}
|
||||
|
||||
/// <summary>The report as sent: construction-time device and cert context plus the send-time OS build and setup state.</summary>
|
||||
internal DistressReport BuildReport(DistressErrorType errorType, string message, int? httpStatusCode, string tpmVersion)
|
||||
{
|
||||
return new DistressReport
|
||||
{
|
||||
TenantId = _tenantId,
|
||||
ErrorType = errorType,
|
||||
Manufacturer = _manufacturer,
|
||||
Model = _model,
|
||||
SerialNumber = _serialNumber,
|
||||
AgentVersion = _agentVersion,
|
||||
HttpStatusCode = httpStatusCode,
|
||||
Message = Truncate(message, 256),
|
||||
Timestamp = DateTime.UtcNow,
|
||||
IsCloudPc = _isCloudPc,
|
||||
CertSourceState = _capturedCertState,
|
||||
CertThumbprint = _capturedCertThumbprint,
|
||||
CertSubject = Truncate(_capturedCertSubject, 96),
|
||||
CertIssuer = Truncate(_capturedCertIssuer, 96),
|
||||
CertNotBefore = _capturedCertNotBefore,
|
||||
CertNotAfter = _capturedCertNotAfter,
|
||||
OsBuild = NullIfEmpty(DeviceInfoProvider.GetOsBuild()),
|
||||
OobeState = OobeStateReader.Read(),
|
||||
TpmVersion = Truncate(tpmVersion, 48),
|
||||
};
|
||||
}
|
||||
|
||||
private static string NullIfEmpty(string value) => string.IsNullOrEmpty(value) ? null : value;
|
||||
|
||||
/// <summary>
|
||||
/// The report as JSON within <see cref="Constants.DistressReportMaxBytes"/>: the backend drops
|
||||
/// a larger body without a word, so an oversized report gives up message characters
|
||||
/// (each removed character removes at least one byte).
|
||||
/// </summary>
|
||||
internal static string SerializeWithinCap(DistressReport report)
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(report);
|
||||
var overflow = Encoding.UTF8.GetByteCount(json) - Constants.DistressReportMaxBytes;
|
||||
if (overflow <= 0 || string.IsNullOrEmpty(report.Message))
|
||||
return json;
|
||||
|
||||
report.Message = report.Message.Substring(0, Math.Max(0, report.Message.Length - overflow));
|
||||
return JsonConvert.SerializeObject(report);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient?.Dispose();
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// The native status behind a transport failure. Schannel and WinHTTP report it only on the
|
||||
/// innermost <see cref="Win32Exception"/> of the chain (e.g. <c>0x80090326</c>); the outer
|
||||
/// <c>HttpRequestException</c> and <c>WebException</c> texts are generic.
|
||||
/// </summary>
|
||||
internal static class TransportErrorCodes
|
||||
{
|
||||
/// <summary>The innermost <see cref="Win32Exception.NativeErrorCode"/> as <c>0x80090326</c>; null when the chain has none.</summary>
|
||||
public static string InnermostNativeCode(Exception ex)
|
||||
{
|
||||
string code = null;
|
||||
for (var current = ex; current != null; current = current.InnerException)
|
||||
{
|
||||
if (current is Win32Exception win32)
|
||||
code = Hex(win32.NativeErrorCode);
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
/// <summary><paramref name="ex"/>'s message with the innermost native code appended as <c>(win32 0x80090326)</c>.</summary>
|
||||
public static string MessageWithNativeCode(Exception ex)
|
||||
{
|
||||
if (ex == null) return null;
|
||||
var code = InnermostNativeCode(ex);
|
||||
return code == null ? ex.Message : $"{ex.Message} (win32 {code})";
|
||||
}
|
||||
|
||||
/// <summary>Eight-digit hex with <c>0x</c>: <c>-2146893018</c> → <c>0x80090326</c>.</summary>
|
||||
public static string Hex(int value) => "0x" + value.ToString("X8", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// One-shot read of the Schannel errors the System log recorded since a point in time:
|
||||
/// 36871 (creating the client credential failed), 36887 (fatal TLS alert received) and
|
||||
/// 36888 (fatal TLS alert sent). Only the terminal registration-failure path calls it.
|
||||
/// Another process's TLS failure in the same window shows up too, so the result is a hint.
|
||||
/// </summary>
|
||||
internal static class SchannelEventReader
|
||||
{
|
||||
internal const int MaxEvents = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Newest first, at most <see cref="MaxEvents"/> events as <see cref="FormatToken"/> tokens
|
||||
/// joined by commas (<c>36888:40/1205,36887:116</c>); null when there is none or the log
|
||||
/// cannot be read. Never throws.
|
||||
/// </summary>
|
||||
public static string ReadSince(DateTime sinceUtc, AgentLogger logger = null)
|
||||
{
|
||||
var tokens = new List<string>();
|
||||
try
|
||||
{
|
||||
var query = new EventLogQuery("System", PathType.LogName, BuildXPath(sinceUtc)) { ReverseDirection = true };
|
||||
using (var reader = new EventLogReader(query))
|
||||
{
|
||||
EventRecord record;
|
||||
while (tokens.Count < MaxEvents && (record = reader.ReadEvent()) != null)
|
||||
{
|
||||
using (record)
|
||||
{
|
||||
tokens.Add(FormatToken(record.Id, ReadProperties(record)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Debug($"SchannelEventReader: System log not readable ({ex.Message}).");
|
||||
return null;
|
||||
}
|
||||
return tokens.Count == 0 ? null : string.Join(",", tokens);
|
||||
}
|
||||
|
||||
internal static string BuildXPath(DateTime sinceUtc)
|
||||
{
|
||||
var since = sinceUtc.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
|
||||
return "*[System[Provider[@Name='Schannel'] and (EventID=36871 or EventID=36887 or EventID=36888)" +
|
||||
$" and TimeCreated[@SystemTime >= '{since}']]]";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>36888:40/1205</c>: the event id and its numeric properties (TLS alert code, Schannel
|
||||
/// error state). Text properties (target name, certificate subjects) are left out.
|
||||
/// </summary>
|
||||
internal static string FormatToken(int eventId, IEnumerable<string> properties)
|
||||
{
|
||||
var numbers = (properties ?? Enumerable.Empty<string>())
|
||||
.Where(p => !string.IsNullOrEmpty(p) && p.All(c => c >= '0' && c <= '9'))
|
||||
.Take(3)
|
||||
.ToList();
|
||||
return numbers.Count == 0
|
||||
? eventId.ToString(CultureInfo.InvariantCulture)
|
||||
: $"{eventId.ToString(CultureInfo.InvariantCulture)}:{string.Join("/", numbers)}";
|
||||
}
|
||||
|
||||
private static List<string> ReadProperties(EventRecord record)
|
||||
{
|
||||
var values = new List<string>();
|
||||
try
|
||||
{
|
||||
foreach (var property in record.Properties)
|
||||
values.Add(Convert.ToString(property?.Value, CultureInfo.InvariantCulture));
|
||||
}
|
||||
catch { /* best-effort — keep what was read */ }
|
||||
return values;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
}
|
||||
|
||||
lastError = response?.Message ?? "(null response)";
|
||||
// The newest attempt answered: an earlier attempt's exception no longer
|
||||
// describes how this run ended (onTerminalTransportFailure).
|
||||
lastException = null;
|
||||
logger.Warning($"Session registration failed: {lastError}");
|
||||
}
|
||||
catch (Exception ex) when (
|
||||
@@ -170,7 +173,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex.Message;
|
||||
// The native code (e.g. the Schannel status) is the only specific part of a
|
||||
// transport failure; it travels on with the run record.
|
||||
lastError = TransportErrorCodes.MessageWithNativeCode(ex);
|
||||
lastException = ex;
|
||||
logger.Error($"Failed to register session (attempt {attempt}/{MaxAttempts}): {ex.Message}", ex);
|
||||
|
||||
@@ -181,7 +186,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
if (emergencyReporter != null)
|
||||
_ = emergencyReporter.TrySendAsync(
|
||||
AgentErrorType.RegisterSessionFailed,
|
||||
ex.Message);
|
||||
lastError);
|
||||
}
|
||||
catch { /* emergency channel is best-effort */ }
|
||||
}
|
||||
|
||||
@@ -3,24 +3,24 @@ using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Diagnostic probe that asks the local key-storage provider to produce three RSA signatures
|
||||
/// over a fixed payload — PKCS#1 v1.5 SHA-256 (legacy, what every TPM 2.0 supports),
|
||||
/// PSS SHA-256, and PSS SHA-384 (what modern Schannel prefers for TLS 1.2/1.3 client-auth
|
||||
/// on Windows 11 25H2+).
|
||||
/// PSS SHA-256, and PSS SHA-384 (what Schannel prefers for TLS client authentication, and the
|
||||
/// only RSA signature TLS 1.3 allows).
|
||||
/// <para>
|
||||
/// The probe exists for one reason: to distinguish a generic <c>SecureChannelFailure</c>
|
||||
/// from the specific case where a TPM-backed cert is silently filtered out of TLS client-auth
|
||||
/// because its firmware can't do PSS. On 2015-era Infineon TPM 2.0 chips (Surface Book 1
|
||||
/// SLB 9665 is the canonical example) PKCS#1 succeeds but PSS reports
|
||||
/// <c>"The requested salt size for signing with RSAPSS does not match what the TPM uses"</c>
|
||||
/// or <c>"The requested operation is not supported"</c>. Schannel observes this during cert
|
||||
/// pre-filtering, drops the cert, and the handshake then completes with an empty client
|
||||
/// Certificate message — which the backend rejects, surfacing in .NET as the generic
|
||||
/// <c>WebException.Status = SecureChannelFailure</c>.
|
||||
/// from the case where a TPM-backed cert can be filtered out of TLS client-auth because its
|
||||
/// firmware can't do PSS. On older TPM 2.0 firmware (Surface Book 1's Infineon SLB 9665,
|
||||
/// Infineon 7.63 and STMicro 73.4 in 2017/18 business devices) PKCS#1 succeeds but PSS
|
||||
/// reports <c>"The requested salt size for signing with RSAPSS does not match what the TPM
|
||||
/// uses"</c> or <c>"The requested operation is not supported"</c>. Such a device can still
|
||||
/// connect at another time (failed during setup, connected after it), so the result
|
||||
/// classifies the failed start, not the device.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Hot-path discipline:</b> this probe is intentionally NOT called at agent startup.
|
||||
@@ -71,20 +71,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
providerName: providerName,
|
||||
keySizeBits: keySizeBits,
|
||||
pssSha256Error: pssSha256.ErrorMessage,
|
||||
pssSha384Error: pssSha384.ErrorMessage);
|
||||
pssSha384Error: pssSha384.ErrorMessage,
|
||||
pssSha256ErrorCode: pssSha256.ErrorCode,
|
||||
pssSha384ErrorCode: pssSha384.ErrorCode);
|
||||
}
|
||||
|
||||
private static (bool Success, string ErrorMessage) TrySignData(
|
||||
private static (bool Success, string ErrorMessage, int? ErrorCode) TrySignData(
|
||||
RSA rsa, byte[] data, HashAlgorithmName hash, RSASignaturePadding padding)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sig = rsa.SignData(data, hash, padding);
|
||||
return (sig != null && sig.Length > 0, null);
|
||||
return (sig != null && sig.Length > 0, null, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
// CryptographicException carries the NCrypt status as HResult
|
||||
// (0x80290423 = the TPM's PSS salt size does not match, 0x80090029 = not supported).
|
||||
return (false, ex.Message, ex.HResult);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +126,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// </summary>
|
||||
public sealed class TpmPssProbeResult
|
||||
{
|
||||
/// <summary>Longest <see cref="ToDistressMessage"/> — the distress message cap.</summary>
|
||||
internal const int MaxDistressMessageLength = 256;
|
||||
|
||||
public bool Pkcs1Sha256Works { get; }
|
||||
public bool PssSha256Works { get; }
|
||||
public bool PssSha384Works { get; }
|
||||
@@ -129,12 +136,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
public int KeySizeBits { get; }
|
||||
public string PssSha256Error { get; }
|
||||
public string PssSha384Error { get; }
|
||||
public int? PssSha256ErrorCode { get; }
|
||||
public int? PssSha384ErrorCode { get; }
|
||||
public bool IsProbable { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True iff PKCS#1 succeeded but PSS did not — the canonical "TPM firmware is too old
|
||||
/// for modern Schannel" footprint. Distinguishes the TPM-PSS regression from generic
|
||||
/// crypto failures (which would also fail PKCS#1).
|
||||
/// for PSS" footprint. Distinguishes the TPM-PSS case from generic crypto failures
|
||||
/// (which would also fail PKCS#1).
|
||||
/// </summary>
|
||||
public bool IsTpmPssBroken => IsProbable && Pkcs1Sha256Works && !PssSha256Works;
|
||||
|
||||
@@ -154,7 +163,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
string providerName,
|
||||
int keySizeBits,
|
||||
string pssSha256Error,
|
||||
string pssSha384Error)
|
||||
string pssSha384Error,
|
||||
int? pssSha256ErrorCode = null,
|
||||
int? pssSha384ErrorCode = null)
|
||||
{
|
||||
Pkcs1Sha256Works = pkcs1Sha256Works;
|
||||
PssSha256Works = pssSha256Works;
|
||||
@@ -163,6 +174,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
KeySizeBits = keySizeBits;
|
||||
PssSha256Error = pssSha256Error;
|
||||
PssSha384Error = pssSha384Error;
|
||||
PssSha256ErrorCode = pssSha256ErrorCode;
|
||||
PssSha384ErrorCode = pssSha384ErrorCode;
|
||||
IsProbable = true;
|
||||
}
|
||||
|
||||
@@ -176,23 +189,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
public static TpmPssProbeResult Unprobable(string reason) => new TpmPssProbeResult(reason);
|
||||
|
||||
/// <summary>
|
||||
/// Compact, distress-message-friendly summary (under 256 chars). Caller passes this to
|
||||
/// <c>DistressReporter.TrySendAsync</c>.
|
||||
/// Language-neutral summary for the distress message, codes instead of the localized
|
||||
/// error texts: <c>pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029)
|
||||
/// tls=0x80090326 sch=36888:40/1205 prov=PCP key=2048</c>. <paramref name="tlsCode"/>
|
||||
/// is the native code of the failed handshake (<see cref="TransportErrorCodes"/>),
|
||||
/// <paramref name="schannelEvents"/> the System-log tokens (<see cref="SchannelEventReader"/>);
|
||||
/// both are left out when null. At most <see cref="MaxDistressMessageLength"/> chars.
|
||||
/// </summary>
|
||||
public string ToDistressMessage()
|
||||
public string ToDistressMessage(string tlsCode = null, string schannelEvents = null)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("provider=").Append(ProviderName);
|
||||
if (KeySizeBits > 0) sb.Append(" keySize=").Append(KeySizeBits);
|
||||
sb.Append(" pkcs1=").Append(Pkcs1Sha256Works ? "ok" : "fail");
|
||||
sb.Append(" pssSha256=").Append(PssSha256Works ? "ok" : "fail");
|
||||
sb.Append(" pssSha384=").Append(PssSha384Works ? "ok" : "fail");
|
||||
if (!PssSha256Works && !string.IsNullOrEmpty(PssSha256Error))
|
||||
{
|
||||
sb.Append(" err256=").Append(PssSha256Error);
|
||||
}
|
||||
const int maxLen = 240;
|
||||
return sb.Length <= maxLen ? sb.ToString() : sb.ToString(0, maxLen);
|
||||
sb.Append("pkcs1=").Append(Pkcs1Sha256Works ? "ok" : "fail");
|
||||
AppendSignOutcome(sb, " pss256=", PssSha256Works, PssSha256ErrorCode);
|
||||
AppendSignOutcome(sb, " pss384=", PssSha384Works, PssSha384ErrorCode);
|
||||
if (!string.IsNullOrEmpty(tlsCode)) sb.Append(" tls=").Append(tlsCode);
|
||||
if (!string.IsNullOrEmpty(schannelEvents)) sb.Append(" sch=").Append(schannelEvents);
|
||||
sb.Append(" prov=").Append(ShortProviderName(ProviderName));
|
||||
if (KeySizeBits > 0) sb.Append(" key=").Append(KeySizeBits);
|
||||
return sb.Length <= MaxDistressMessageLength ? sb.ToString() : sb.ToString(0, MaxDistressMessageLength);
|
||||
}
|
||||
|
||||
private static void AppendSignOutcome(StringBuilder sb, string key, bool works, int? errorCode)
|
||||
{
|
||||
sb.Append(key);
|
||||
if (works) sb.Append("ok");
|
||||
else if (errorCode.HasValue) sb.Append("fail(").Append(TransportErrorCodes.Hex(errorCode.Value)).Append(')');
|
||||
else sb.Append("fail");
|
||||
}
|
||||
|
||||
internal static string ShortProviderName(string providerName)
|
||||
{
|
||||
if (string.Equals(providerName, "Microsoft Platform Crypto Provider", StringComparison.OrdinalIgnoreCase)) return "PCP";
|
||||
if (string.Equals(providerName, "Microsoft Software Key Storage Provider", StringComparison.OrdinalIgnoreCase)) return "KSP";
|
||||
if (string.IsNullOrEmpty(providerName)) return "(unknown)";
|
||||
return providerName.Length <= 32 ? providerName : providerName.Substring(0, 32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
@@ -66,7 +68,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
logger: logger,
|
||||
authFailureTracker: auth.AuthFailureTracker,
|
||||
emergencyReporter: auth.EmergencyReporter,
|
||||
onTerminalTransportFailure: ex => DiagnoseTerminalTransportFailure(ex, auth, logger),
|
||||
onTerminalTransportFailure: ex => DiagnoseTerminalTransportFailure(ex, auth, startedUtc, logger),
|
||||
// Reuse the single hardened hardware read from the auth bundle so the session row
|
||||
// matches the security headers the backend validated against (no second WMI query).
|
||||
deviceHardware: (auth.Manufacturer, auth.Model, auth.SerialNumber),
|
||||
@@ -280,6 +282,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>How long the TPM and System-log reads for the TPM-PSS report may take together.</summary>
|
||||
internal static readonly TimeSpan DiagnosticContextBound = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Off-hot-path diagnostic invoked once after all session-registration retries fail.
|
||||
/// Detects whether the terminal exception was a TLS-layer <c>SecureChannelFailure</c>
|
||||
@@ -287,7 +292,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// failure" from "TPM firmware can't do RSA-PSS so Schannel filters the cert out".
|
||||
/// On confirmation, emits a <see cref="DistressErrorType.TpmPssUnsupported"/> distress
|
||||
/// signal so tenant admins see this device's specific blocker rather than a generic
|
||||
/// network failure.
|
||||
/// network failure (<see cref="ReportProbeResultAsync"/>).
|
||||
/// <para>
|
||||
/// Never throws — diagnostic-only side effects.
|
||||
/// </para>
|
||||
@@ -295,6 +300,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
private static Task DiagnoseTerminalTransportFailure(
|
||||
Exception ex,
|
||||
BackendAuthBundle auth,
|
||||
DateTime registrationStartedUtc,
|
||||
AgentLogger logger)
|
||||
{
|
||||
try
|
||||
@@ -314,35 +320,106 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
var cert = new DefaultCertificateResolver().FindClientCertificate(logger);
|
||||
var probe = TpmPssCapabilityProbe.Probe(cert, logger);
|
||||
|
||||
logger.Info(
|
||||
$"TpmPssCapabilityProbe: provider={probe.ProviderName} keySize={probe.KeySizeBits} " +
|
||||
$"pkcs1Sha256={probe.Pkcs1Sha256Works} pssSha256={probe.PssSha256Works} pssSha384={probe.PssSha384Works}");
|
||||
|
||||
if (probe.IsTpmPssBroken && probe.IsTpmBacked)
|
||||
{
|
||||
logger.Error(
|
||||
"TpmPssCapabilityProbe: device's TPM-backed key cannot perform RSA-PSS — " +
|
||||
"modern Schannel filters the cert out of TLS client-auth, blocking mTLS to the backend. " +
|
||||
"Likely fix: update TPM firmware (common on 2015-era Infineon TPM 2.0).");
|
||||
|
||||
_ = auth.DistressReporter.TrySendAsync(
|
||||
DistressErrorType.TpmPssUnsupported,
|
||||
probe.ToDistressMessage());
|
||||
}
|
||||
else if (probe.IsTpmPssBroken)
|
||||
{
|
||||
// PSS broken but key isn't TPM-backed — different bug, don't mis-classify.
|
||||
logger.Warning(
|
||||
$"TpmPssCapabilityProbe: PSS signing failed on a non-TPM provider ({probe.ProviderName}). " +
|
||||
"Not emitting TpmPssUnsupported distress. Investigate as a separate crypto issue.");
|
||||
}
|
||||
return ReportProbeResultAsync(
|
||||
probe, ex, auth.DistressReporter, logger,
|
||||
() => ReadDiagnosticContextAsync(
|
||||
() => TpmInfoReader.Read()?.Label,
|
||||
() => SchannelEventReader.ReadSince(registrationStartedUtc, logger),
|
||||
Task.Delay(DiagnosticContextBound), logger));
|
||||
}
|
||||
catch (Exception probeEx)
|
||||
{
|
||||
logger?.Warning($"DiagnoseTerminalTransportFailure: unexpected error: {probeEx.Message}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
/// <summary>
|
||||
/// Sends <see cref="DistressErrorType.TpmPssUnsupported"/> when the probe shows a TPM-backed
|
||||
/// key that signs PKCS#1 but not PSS, with the native code of the failed handshake, the
|
||||
/// Schannel events and the TPM firmware from <paramref name="readContext"/>. The returned
|
||||
/// task completes only after the send: the run exits right after this callback, and
|
||||
/// nothing else waits for it. Never throws.
|
||||
/// </summary>
|
||||
internal static async Task ReportProbeResultAsync(
|
||||
TpmPssProbeResult probe,
|
||||
Exception transportFailure,
|
||||
DistressReporter reporter,
|
||||
AgentLogger logger,
|
||||
Func<Task<(string TpmVersion, string SchannelEvents)>> readContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tlsCode = TransportErrorCodes.InnermostNativeCode(transportFailure);
|
||||
logger?.Info(
|
||||
$"TpmPssCapabilityProbe: provider={probe.ProviderName} keySize={probe.KeySizeBits} " +
|
||||
$"pkcs1Sha256={probe.Pkcs1Sha256Works} pssSha256={probe.PssSha256Works} pssSha384={probe.PssSha384Works} " +
|
||||
$"tls={tlsCode ?? "n/a"} pssSha256Error={probe.PssSha256Error ?? "n/a"}");
|
||||
|
||||
if (probe.IsTpmPssBroken && probe.IsTpmBacked)
|
||||
{
|
||||
logger?.Error(
|
||||
"TpmPssCapabilityProbe: device's TPM-backed key cannot perform RSA-PSS — " +
|
||||
"Schannel can withhold the cert from TLS client-auth, blocking mTLS to the backend. " +
|
||||
"Likely fix: update TPM firmware (common on older TPM 2.0 firmware).");
|
||||
|
||||
string tpmVersion = null, schannelEvents = null;
|
||||
try
|
||||
{
|
||||
(tpmVersion, schannelEvents) = await readContext().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The context only enriches the report; it never holds it back.
|
||||
logger?.Debug($"TPM-PSS report: context read failed ({ex.Message}) — sending without it.");
|
||||
}
|
||||
await reporter.TrySendAsync(
|
||||
DistressErrorType.TpmPssUnsupported,
|
||||
probe.ToDistressMessage(tlsCode, schannelEvents),
|
||||
tpmVersion: tpmVersion).ConfigureAwait(false);
|
||||
}
|
||||
else if (probe.IsTpmPssBroken)
|
||||
{
|
||||
// PSS broken but key isn't TPM-backed — different bug, don't mis-classify.
|
||||
logger?.Warning(
|
||||
$"TpmPssCapabilityProbe: PSS signing failed on a non-TPM provider ({probe.ProviderName}). " +
|
||||
"Not emitting TpmPssUnsupported distress. Investigate as a separate crypto issue.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Warning($"TPM-PSS report failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs both reads in parallel until both finished or <paramref name="bound"/> completed
|
||||
/// (production: <see cref="DiagnosticContextBound"/>); a read that threw or had not
|
||||
/// finished by then counts as missing (null). Never throws.
|
||||
/// </summary>
|
||||
internal static async Task<(string TpmVersion, string SchannelEvents)> ReadDiagnosticContextAsync(
|
||||
Func<string> readTpmVersion,
|
||||
Func<string> readSchannelEvents,
|
||||
Task bound,
|
||||
AgentLogger logger)
|
||||
{
|
||||
var tpm = Task.Run(readTpmVersion);
|
||||
var schannel = Task.Run(readSchannelEvents);
|
||||
await Task.WhenAny(Task.WhenAll(tpm, schannel), bound).ConfigureAwait(false);
|
||||
return (ResultOrNull(tpm, "TPM", logger), ResultOrNull(schannel, "Schannel event", logger));
|
||||
}
|
||||
|
||||
private static string ResultOrNull(Task<string> read, string what, AgentLogger logger)
|
||||
{
|
||||
if (read.Status == TaskStatus.RanToCompletion)
|
||||
return read.Result;
|
||||
|
||||
// Observe a later fault so it never surfaces as an unobserved task exception.
|
||||
read.ContinueWith(t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted);
|
||||
logger?.Debug(read.IsFaulted
|
||||
? $"TPM-PSS report: {what} read failed ({read.Exception?.GetBaseException().Message})."
|
||||
: $"TPM-PSS report: {what} read still running — sending without it.");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -273,4 +273,30 @@ public class AdminConfigurationChangedFieldsTests
|
||||
Assert.NotNull(typeError);
|
||||
Assert.DoesNotContain("secret.example", typeError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ops_channels_refuse_a_second_push_channel_but_keep_a_stored_pair_saveable()
|
||||
{
|
||||
var one = OpsPushChannels("phones");
|
||||
var pair = OpsPushChannels("phones", "phones-2");
|
||||
|
||||
var draft = AdminConfiguration.CreateDefault();
|
||||
draft.OpsNotificationChannelsJson = one;
|
||||
Assert.Equal(
|
||||
"Invalid ops notification channels: only one Push channel is allowed — a single channel already reaches every paired device.",
|
||||
PatchAdminConfigurationFunction.ApplyFields(draft, new JObject { ["opsNotificationChannelsJson"] = pair }));
|
||||
|
||||
// A pair stored before the rule saves unchanged and may be reduced.
|
||||
var stored = AdminConfiguration.CreateDefault();
|
||||
stored.OpsNotificationChannelsJson = pair;
|
||||
Assert.Null(PatchAdminConfigurationFunction.ApplyFields(stored, new JObject { ["opsNotificationChannelsJson"] = pair }));
|
||||
Assert.Null(PatchAdminConfigurationFunction.ApplyFields(stored, new JObject { ["opsNotificationChannelsJson"] = one }));
|
||||
}
|
||||
|
||||
private static string OpsPushChannels(params string[] ids)
|
||||
=> AutopilotMonitor.Shared.Models.Notifications.NotificationChannel.SerializeList(ids.Select(id =>
|
||||
new AutopilotMonitor.Shared.Models.Notifications.NotificationChannel
|
||||
{
|
||||
Id = id, Name = id, ProviderType = (int)AutopilotMonitor.Shared.Models.Notifications.WebhookProviderType.Push, Enabled = true,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Apps;
|
||||
using AutopilotMonitor.Functions.Services.Apps;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The diagnosis page's fleet line: one app's outcomes in the tenant's other enrollments of the last
|
||||
/// week, classified like the apps analytics view, without the asking session and without another app
|
||||
/// of the same name.
|
||||
/// </summary>
|
||||
public class AppFleetContextTests
|
||||
{
|
||||
private const string TenantId = "00000000-0000-4000-8000-0000000000b1";
|
||||
private const string NotesId = "00000000-0000-4000-8000-0000000000a1";
|
||||
private const string OtherAppId = "00000000-0000-4000-8000-0000000000a9";
|
||||
private const string AskingSession = "00000000-0000-4000-8000-00000000000a";
|
||||
|
||||
private static AppInstallSummary Row(
|
||||
string session, string status, string terminal = "", string code = "", string appId = NotesId,
|
||||
bool collision = false, double hoursAgo = 2, int? exitCode = null, string message = "") => new()
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = session,
|
||||
AppName = "Contoso Notes",
|
||||
AppId = appId,
|
||||
Status = status,
|
||||
TerminalState = terminal,
|
||||
FailureCode = code,
|
||||
FailureMessage = message,
|
||||
ExitCode = exitCode,
|
||||
AppIdCollision = collision,
|
||||
StartedAt = DateTime.UtcNow.AddHours(-hoursAgo),
|
||||
};
|
||||
|
||||
private static GetAppFleetContextResponse Build(IEnumerable<AppInstallSummary> rows, string? appId = NotesId, string? exclude = AskingSession)
|
||||
=> AppsAnalyticsHelper.BuildFleetContextResponse(rows, "Contoso Notes", AppInstallSources.Ime, appId, exclude, DateTime.UtcNow.AddDays(-7));
|
||||
|
||||
[Fact]
|
||||
public void Counts_the_other_enrollments_like_the_analytics_view()
|
||||
{
|
||||
var context = Build(new[]
|
||||
{
|
||||
Row("s1", "Succeeded", "Installed"),
|
||||
Row("s2", "Succeeded"), // legacy row without a terminal state: an install
|
||||
Row("s3", "Succeeded", "Skipped"),
|
||||
Row("s4", "Succeeded", "Postponed"),
|
||||
Row("s5", "Failed", "Error", "0x80d03805"),
|
||||
Row("s6", "Failed", "Error", "0x80d03805"),
|
||||
Row("s7", "Failed", "Error", "esp_apps_timeout"),
|
||||
Row("s8", "Incomplete"),
|
||||
Row("s9", "InProgress"),
|
||||
});
|
||||
|
||||
Assert.Equal(
|
||||
(2, 2, 3, 1, 1, 0),
|
||||
(context.Installed, context.Skipped, context.Failed, context.Incomplete, context.InProgress, context.CollisionExcluded));
|
||||
Assert.Equal(new[] { ("0x80d03805", 2), ("esp_apps_timeout", 1) }, context.TopFailureCodes.Select(c => (c.Code, c.Count)));
|
||||
Assert.Equal((7, "Contoso Notes", "ime"), (context.WindowDays, context.AppName, context.Source));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Leaves_out_the_asking_session_another_app_and_older_rows_and_counts_collisions()
|
||||
{
|
||||
var context = Build(new[]
|
||||
{
|
||||
Row(AskingSession.ToUpperInvariant(), "Failed", code: "0x80d03805"),
|
||||
Row("s1", "Failed", code: "0x80d03805", appId: OtherAppId),
|
||||
Row("s2", "Succeeded", "Installed", appId: ""), // no app id recorded: same name, kept
|
||||
Row("s3", "Succeeded", "Installed", appId: NotesId.ToUpperInvariant()),
|
||||
Row("s4", "Failed", code: "0x87d1041c", collision: true),
|
||||
Row("s5", "Failed", code: "0x87d1041c", hoursAgo: 24 * 8),
|
||||
});
|
||||
|
||||
Assert.Equal((2, 0, 1), (context.Installed, context.Failed, context.CollisionExcluded));
|
||||
Assert.Empty(context.TopFailureCodes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_an_app_id_every_row_of_the_name_counts()
|
||||
{
|
||||
var context = Build(new[] { Row("s1", "Failed", code: "0x1", appId: OtherAppId), Row("s2", "Succeeded", "Installed") }, appId: null, exclude: null);
|
||||
|
||||
Assert.Equal((1, 1), (context.Installed, context.Failed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keeps_the_three_most_frequent_codes_with_their_exit_code_and_message()
|
||||
{
|
||||
var context = Build(new[]
|
||||
{
|
||||
Row("s1", "Failed", code: "0x1"),
|
||||
Row("s2", "Failed", code: "0x2", exitCode: 1603, message: "Fatal error during installation"),
|
||||
Row("s3", "Failed", code: "0x2"),
|
||||
Row("s4", "Failed", code: "0x3"),
|
||||
Row("s5", "Failed", code: "0x3"),
|
||||
Row("s6", "Failed", code: "0x3"),
|
||||
Row("s7", "Failed", code: "0x4"),
|
||||
});
|
||||
|
||||
Assert.Equal(new[] { "0x3", "0x2", "0x1" }, context.TopFailureCodes.Select(c => c.Code));
|
||||
var second = context.TopFailureCodes[1];
|
||||
Assert.Equal((1603, "Fatal error during installation"), (second.ExitCode, second.SampleMessage));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_the_rows_once_per_app_and_minute_for_every_caller()
|
||||
{
|
||||
var repo = new Mock<IMetricsRepository>();
|
||||
repo.Setup(r => r.GetAppsDashboardSummariesAsync(It.IsAny<DateTime>(), TenantId))
|
||||
.ReturnsAsync(() => new List<AppInstallSummary>
|
||||
{
|
||||
Row(AskingSession, "Failed", code: "0x80d03805"),
|
||||
Row("s1", "Failed", code: "0x80d03805"),
|
||||
Row("s2", "Succeeded", "Installed"),
|
||||
});
|
||||
var service = new AppFleetContextService(repo.Object);
|
||||
|
||||
var asking = await service.GetAsync(TenantId, "Contoso Notes", AppInstallSources.Ime, NotesId, AskingSession);
|
||||
var another = await service.GetAsync(TenantId, "contoso notes", AppInstallSources.Ime, NotesId, "s1");
|
||||
|
||||
Assert.Equal((1, 1), (asking.Failed, asking.Installed));
|
||||
Assert.Equal((1, 1), (another.Failed, another.Installed));
|
||||
repo.Verify(r => r.GetAppsDashboardSummariesAsync(It.IsAny<DateTime>(), TenantId), Times.Once);
|
||||
repo.Verify(r => r.GetAppsDashboardSummariesAsync(
|
||||
It.Is<DateTime>(d => d <= DateTime.UtcNow.AddDays(-7) && d > DateTime.UtcNow.AddDays(-7).AddMinutes(-2)), TenantId), Times.Once);
|
||||
|
||||
await service.GetAsync(TenantId, "Contoso Agent", AppInstallSources.Ime, null, AskingSession);
|
||||
repo.Verify(r => r.GetAppsDashboardSummariesAsync(It.IsAny<DateTime>(), TenantId), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("?source=ime")]
|
||||
[InlineData("?appName=%20")]
|
||||
[InlineData("?appName=Contoso+Notes&source=bogus")]
|
||||
[InlineData("?appName=Contoso+Notes&excludeSessionId=not-a-guid")]
|
||||
public async Task A_request_without_a_name_or_with_a_bad_parameter_is_a_bad_request(string query)
|
||||
{
|
||||
var repo = new Mock<IMetricsRepository>();
|
||||
var function = Function(repo.Object);
|
||||
|
||||
var res = await function.Run(EndpointHarness.Request(TenantId, queryString: query).Req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
|
||||
repo.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_target_tenant_that_is_no_guid_is_a_bad_request()
|
||||
{
|
||||
var repo = new Mock<IMetricsRepository>();
|
||||
|
||||
var res = await Function(repo.Object).Run(EndpointHarness.Request("not-a-guid", queryString: "?appName=Contoso+Notes").Req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
|
||||
repo.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Answers_for_the_target_tenant_of_the_request()
|
||||
{
|
||||
var repo = new Mock<IMetricsRepository>();
|
||||
repo.Setup(r => r.GetAppsDashboardSummariesAsync(It.IsAny<DateTime>(), TenantId))
|
||||
.ReturnsAsync(new List<AppInstallSummary> { Row("s1", "Failed", code: "0x80d03805"), Row(AskingSession, "Failed", code: "0x80d03805") });
|
||||
|
||||
var res = await Function(repo.Object).Run(EndpointHarness.Request(
|
||||
TenantId, queryString: $"?appName=Contoso+Notes&appId={NotesId}&excludeSessionId={AskingSession}&tenantId={TenantId}").Req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
res.Body.Position = 0;
|
||||
using var doc = JsonDocument.Parse(res.Body);
|
||||
Assert.Equal(1, doc.RootElement.GetProperty("failed").GetInt32());
|
||||
Assert.Equal("0x80d03805", doc.RootElement.GetProperty("topFailureCodes")[0].GetProperty("code").GetString());
|
||||
}
|
||||
|
||||
private static GetAppFleetContextFunction Function(IMetricsRepository repo)
|
||||
=> new(NullLogger<GetAppFleetContextFunction>.Instance, new AppFleetContextService(repo));
|
||||
}
|
||||
@@ -155,6 +155,9 @@ public class AppsAnalyticsFailureRateTests
|
||||
|
||||
var version = root.GetProperty("versionBreakdown")[0];
|
||||
Assert.Equal(4, version.GetProperty("installs").GetInt32());
|
||||
// The rate's base travels with the row, so the UI can show 1 / (1 + 1) next to 4 installs.
|
||||
Assert.Equal(1, version.GetProperty("succeeded").GetInt32());
|
||||
Assert.Equal(1, version.GetProperty("failed").GetInt32());
|
||||
Assert.Equal(50d, version.GetProperty("failureRate").GetDouble());
|
||||
|
||||
// All four installs land in today's bucket → same terminal-only rate there.
|
||||
@@ -228,21 +231,35 @@ public class AppsAnalyticsFailureRateTests
|
||||
Model = sessionId.StartsWith("s-a") ? "ModelA" : "ModelB",
|
||||
});
|
||||
|
||||
static AppInstallSummary Skip(string sessionId)
|
||||
{
|
||||
var skip = A("Succeeded", sessionId: sessionId);
|
||||
skip.TerminalState = "Skipped";
|
||||
return skip;
|
||||
}
|
||||
|
||||
var summaries = new List<AppInstallSummary>();
|
||||
// ModelA: 5 finished (1 failed → 20%) + 3 in-flight → ranked, rate undiluted.
|
||||
// ModelA: 5 finished (1 failed → 20%) + 3 in-flight + 1 skip → ranked, rate undiluted.
|
||||
summaries.Add(A("Failed", sessionId: "s-a-0"));
|
||||
for (var i = 1; i <= 4; i++) summaries.Add(A("Succeeded", sessionId: $"s-a-{i}"));
|
||||
for (var i = 5; i <= 7; i++) summaries.Add(A("InProgress", sessionId: $"s-a-{i}"));
|
||||
// ModelB: 4 finished + 6 in-flight — 10 installs, but below the 5-finished floor.
|
||||
summaries.Add(Skip("s-a-8"));
|
||||
// ModelB: 4 finished + 6 in-flight + 2 skips — 12 installs and 6 rows with status Failed
|
||||
// or Succeeded, but the rate's base is 4: below the floor.
|
||||
for (var i = 0; i <= 3; i++) summaries.Add(A("Failed", sessionId: $"s-b-{i}"));
|
||||
for (var i = 4; i <= 9; i++) summaries.Add(A("InProgress", sessionId: $"s-b-{i}"));
|
||||
summaries.Add(Skip("s-b-10"));
|
||||
summaries.Add(Skip("s-b-11"));
|
||||
|
||||
var root = await BuildAnalyticsAsync(summaries, sessionRepo.Object);
|
||||
|
||||
var breakdown = root.GetProperty("deviceModelBreakdown");
|
||||
var model = Assert.Single(breakdown.EnumerateArray());
|
||||
Assert.Equal("ModelA", model.GetProperty("model").GetString());
|
||||
Assert.Equal(8, model.GetProperty("installs").GetInt32());
|
||||
Assert.Equal(9, model.GetProperty("installs").GetInt32());
|
||||
// The rate's base: 1 / (1 + 4); the in-flight rows and the skip stay outside it.
|
||||
Assert.Equal(4, model.GetProperty("succeeded").GetInt32());
|
||||
Assert.Equal(1, model.GetProperty("failed").GetInt32());
|
||||
Assert.Equal(20d, model.GetProperty("failureRate").GetDouble());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using AutopilotMonitor.Functions.Tests.Offboarding;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models.Offboarding;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
@@ -41,6 +42,8 @@ public class AuthFunctionSideEffectTests
|
||||
private readonly FakeSignalRNotificationService _signalR;
|
||||
private readonly FakeOffboardingAuditRepository _offboardingRepo = new();
|
||||
private readonly StoredTenantConfig _store;
|
||||
private readonly Mock<GlobalAdminService> _globalAdminMock;
|
||||
private readonly Func<PushOwnerSignInService?, AuthFunction> _buildSut;
|
||||
private readonly AuthFunction _sut;
|
||||
|
||||
public AuthFunctionSideEffectTests()
|
||||
@@ -56,9 +59,10 @@ public class AuthFunctionSideEffectTests
|
||||
{ CallBase = false };
|
||||
|
||||
var bindings = new StubAdminIdentityBindingService(bound: true);
|
||||
var globalAdminMock = new Mock<GlobalAdminService>(
|
||||
_globalAdminMock = new Mock<GlobalAdminService>(
|
||||
adminRepo, bindings, cache, Mock.Of<ILogger<GlobalAdminService>>())
|
||||
{ CallBase = false };
|
||||
var globalAdminMock = _globalAdminMock;
|
||||
|
||||
var delegatedAdminMock = new Mock<DelegatedAdminService>(
|
||||
adminRepo,
|
||||
@@ -102,7 +106,7 @@ public class AuthFunctionSideEffectTests
|
||||
_opsEventsMock = new Mock<OpsEventService>(null!, null!, null!) { CallBase = false };
|
||||
_opsEventsMock.Setup(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>())).Returns(Task.CompletedTask);
|
||||
|
||||
_sut = new AuthFunction(
|
||||
_buildSut = pushSignIn => new AuthFunction(
|
||||
Mock.Of<ILogger<AuthFunction>>(),
|
||||
globalAdminMock.Object,
|
||||
delegatedAdminMock.Object,
|
||||
@@ -122,7 +126,9 @@ public class AuthFunctionSideEffectTests
|
||||
_signalR,
|
||||
adminConfigService.Object,
|
||||
_offboardingRepo,
|
||||
pushSignIn: pushSignIn,
|
||||
opsEvents: _opsEventsMock.Object);
|
||||
_sut = _buildSut(null);
|
||||
|
||||
// Writes go through UpdateAsync / CreateOrUpdateAsync over one stored row (none by default:
|
||||
// a brand-new tenant). Default: all fire-and-forget calls succeed.
|
||||
@@ -860,7 +866,7 @@ public class AuthFunctionSideEffectTests
|
||||
/// A validated token as the middleware hands it on: tid, oid and idp only under their mapped names. The
|
||||
/// member's idp names its own tenant, the guest's another one — the same principal shape otherwise.
|
||||
/// </summary>
|
||||
private static ClaimsPrincipal SignedIn(bool external) => new(new ClaimsIdentity(new[]
|
||||
private static ClaimsPrincipal SignedIn(bool external, params string[] appRoles) => new(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim("http://schemas.microsoft.com/identity/claims/tenantid", TenantId),
|
||||
new Claim("http://schemas.microsoft.com/identity/claims/objectidentifier", ObjectId),
|
||||
@@ -868,7 +874,7 @@ public class AuthFunctionSideEffectTests
|
||||
new Claim("http://schemas.microsoft.com/identity/claims/identityprovider", external
|
||||
? "https://sts.windows.net/bbbbbbbb-1111-2222-3333-444444444444/"
|
||||
: $"https://sts.windows.net/{TenantId}/"),
|
||||
}, "test"));
|
||||
}.Concat(appRoles.Select(role => new Claim("roles", role))), "test"));
|
||||
|
||||
private void ArrangeAuthMe(TenantConfiguration config, bool exists, bool approved)
|
||||
{
|
||||
@@ -933,4 +939,69 @@ public class AuthFunctionSideEffectTests
|
||||
external ? Times.Never() : Times.Once());
|
||||
Assert.Contains(external ? "\"isTenantAdmin\":false" : "\"isTenantAdmin\":true", await ReadBodyAsync(response));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GetCurrentUser: the push sign-in (stamp with the app-role claim, resume)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>An onboarded, activated tenant with app roles on and an admin already present (no auto-admin).</summary>
|
||||
private void ArrangeActivatedTenantWithAppRoles(TableMemberState memberRow)
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.DomainName = "contoso.com";
|
||||
config.EntraAppRolesEnabled = true;
|
||||
ArrangeAuthMe(config, exists: true, approved: true);
|
||||
_tenantAdminsMock.Setup(x => x.GetTenantAdminsAsync(TenantId))
|
||||
.ReturnsAsync(new List<TenantAdminRow> { new() { TenantId = TenantId, Upn = "first@contoso.com" } });
|
||||
_tenantAdminsMock.Setup(x => x.GetTableMembershipAsync(TenantId, Upn)).ReturnsAsync((memberRow,
|
||||
memberRow == TableMemberState.Enabled ? new MemberRoleInfo { Role = Constants.TenantRoles.Operator } : null));
|
||||
}
|
||||
|
||||
private (AuthFunction Sut, Push.PushDeliveryServiceTests.Harness Push) BuildWithPushSignIn()
|
||||
{
|
||||
var push = new Push.PushDeliveryServiceTests.Harness();
|
||||
var signIn = new PushOwnerSignInService(push.Repo, new Mock<IAdminRepository>().Object, push.Settings, push.Sut,
|
||||
Mock.Of<ILogger<PushOwnerSignInService>>());
|
||||
return (_buildSut(signIn), push);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TableMemberState.NotPresent, Constants.TenantRoles.Admin)]
|
||||
[InlineData(TableMemberState.Enabled, null)]
|
||||
[InlineData(TableMemberState.Disabled, null)]
|
||||
public async Task GetCurrentUser_PushStamp_CarriesTheAppRoleClaimOnlyWithoutAMemberRow(TableMemberState memberRow, string? expected)
|
||||
{
|
||||
ArrangeActivatedTenantWithAppRoles(memberRow);
|
||||
var (sut, push) = BuildWithPushSignIn();
|
||||
var (req, ctx) = BuildRequest(SignedIn(external: false, Constants.TenantRoles.Admin));
|
||||
|
||||
var response = await sut.GetCurrentUser(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var owner = push.Repo.Owners[(TenantId, ObjectId)];
|
||||
Assert.Equal(expected, owner.ClaimRole);
|
||||
Assert.Equal(Upn, owner.Upn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrentUser_GlobalAdminBackFromInactivity_ResumesTheirPlatformPause()
|
||||
{
|
||||
ArrangeActivatedTenantWithAppRoles(TableMemberState.Enabled);
|
||||
_globalAdminMock.Setup(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync(Constants.GlobalRoles.GlobalAdmin);
|
||||
var (sut, push) = BuildWithPushSignIn();
|
||||
push.Repo.Owners[(TenantId, ObjectId)] = new PushOwner
|
||||
{
|
||||
HomeTenantId = TenantId, ObjectId = ObjectId, Upn = Upn, LastSignInUtc = DateTime.UtcNow.AddDays(-45),
|
||||
};
|
||||
var device = push.AddDevice("ga-phone", Constants.Push.DeviceStatus.Paused, upn: Upn,
|
||||
scope: Constants.Push.PlatformScope, statusReason: "owner_inactive");
|
||||
device.OwnerObjectId = ObjectId;
|
||||
device.OwnerHomeTenantId = TenantId;
|
||||
var (req, ctx) = BuildRequest(SignedIn(external: false));
|
||||
|
||||
var response = await sut.GetCurrentUser(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, push.Repo.Devices[(Constants.Push.PlatformScope, "ga-phone")].Status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The shipped rules on a session the platform blocked, driven through the real RuleEngine with
|
||||
/// the embedded rule JSON: ANALYZE-ENRL-006 explains the block — right after it, through the
|
||||
/// interim run the block recorder enqueues — and ANALYZE-ENRL-002 no longer blames a dead agent
|
||||
/// or an offline device for the silence the block caused.
|
||||
/// </summary>
|
||||
public class BlockedSessionRuleTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
[Fact]
|
||||
public void BlockRule_RunsRightAfterTheBlock()
|
||||
{
|
||||
var rule = Rule("ANALYZE-ENRL-006");
|
||||
Assert.True(rule.Enabled);
|
||||
Assert.Contains("on_event:device_blocked", rule.EvaluateOn!);
|
||||
Assert.Contains("enrollment_end", rule.EvaluateOn!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BlockRule_Fires_OnTheBlockEvent()
|
||||
{
|
||||
var outcome = await RunAsync("ANALYZE-ENRL-006", new List<EnrollmentEvent> { DeviceBlocked(eventLimit: true) });
|
||||
|
||||
Assert.Contains(outcome.Results, r => r.RuleId == "ANALYZE-ENRL-006");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BlockRule_Fires_ForAnOperatorBlockWithoutCounts()
|
||||
{
|
||||
var outcome = await RunAsync("ANALYZE-ENRL-006", new List<EnrollmentEvent> { DeviceBlocked(eventLimit: false) });
|
||||
|
||||
Assert.Contains(outcome.Results, r => r.RuleId == "ANALYZE-ENRL-006");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BlockRule_Silent_WithoutABlock()
|
||||
{
|
||||
var outcome = await RunAsync("ANALYZE-ENRL-006", new List<EnrollmentEvent> { SessionTimeout() });
|
||||
|
||||
Assert.DoesNotContain(outcome.Results, r => r.RuleId == "ANALYZE-ENRL-006");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TimeoutRule_Suppressed_WhenThePlatformBlockedTheSession()
|
||||
{
|
||||
var outcome = await RunAsync("ANALYZE-ENRL-002", new List<EnrollmentEvent> { DeviceBlocked(eventLimit: true), SessionTimeout() });
|
||||
|
||||
Assert.DoesNotContain(outcome.Results, r => r.RuleId == "ANALYZE-ENRL-002");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TimeoutRule_StillFires_WithoutABlock()
|
||||
{
|
||||
var outcome = await RunAsync("ANALYZE-ENRL-002", new List<EnrollmentEvent> { SessionTimeout() });
|
||||
|
||||
Assert.Contains(outcome.Results, r => r.RuleId == "ANALYZE-ENRL-002");
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
private static AnalyzeRule Rule(string ruleId)
|
||||
{
|
||||
var rule = BuiltInAnalyzeRules.GetAll().FirstOrDefault(r => r.RuleId == ruleId);
|
||||
Assert.NotNull(rule);
|
||||
return rule!;
|
||||
}
|
||||
|
||||
private static EnrollmentEvent DeviceBlocked(bool eventLimit)
|
||||
{
|
||||
var record = new SessionBlockRecord
|
||||
{
|
||||
BlockedAt = new DateTime(2026, 10, 14, 12, 0, 0, DateTimeKind.Utc),
|
||||
Action = DeviceBlockActions.Block,
|
||||
BlockedUntil = new DateTime(2026, 10, 15, 12, 0, 0, DateTimeKind.Utc),
|
||||
Cause = eventLimit ? SessionBlockCauses.EventLimit : SessionBlockCauses.Operator,
|
||||
EventCount = eventLimit ? 2553 : null,
|
||||
EventThreshold = eventLimit ? 2500 : null,
|
||||
};
|
||||
var session = new SessionSummary { TenantId = TenantId, SessionId = SessionId };
|
||||
return SessionBlockRecorder.BuildDeviceBlockedEvent(session, record, new List<EnrollmentEvent>());
|
||||
}
|
||||
|
||||
private static EnrollmentEvent SessionTimeout() => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = "session_timeout",
|
||||
Source = "System.Maintenance",
|
||||
Severity = EventSeverity.Error,
|
||||
Timestamp = new DateTime(2026, 10, 14, 17, 0, 0, DateTimeKind.Utc),
|
||||
Sequence = 100,
|
||||
Data = new Dictionary<string, object> { ["timeoutHours"] = 5, ["source"] = "maintenance_sweep" }
|
||||
};
|
||||
|
||||
private static async Task<AnalysisOutcome> RunAsync(string ruleId, List<EnrollmentEvent> events)
|
||||
{
|
||||
var ruleRepo = new Mock<IRuleRepository>();
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule> { Rule(ruleId) });
|
||||
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.GetSessionEventsStrictAsync(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<BlockedSessionRuleTests>.Instance);
|
||||
|
||||
return await engine.AnalyzeSessionAsync(TenantId, SessionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The SessionsIndex filters behind the TPM panel's "connected later" status. Table Storage
|
||||
/// rejects a $filter with more than 15 comparisons, so the serials are split across queries.
|
||||
/// </summary>
|
||||
public class CertificateIngestLookupFilterTests
|
||||
{
|
||||
private const string TenantId = "11111111-2222-3333-4444-555555555555";
|
||||
private static readonly DateTime Since = new(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static int Comparisons(string filter) =>
|
||||
Regex.Matches(filter, @" (eq|ne|lt|le|gt|ge) ").Count;
|
||||
|
||||
[Fact]
|
||||
public void EveryFilter_StaysWithinTheTableStorageComparisonLimit()
|
||||
{
|
||||
// Mixed case doubles each serial's comparisons (as stored + upper-case).
|
||||
var serials = Enumerable.Range(1, 40).Select(i => $"sn{i:D3}").ToList();
|
||||
|
||||
var filters = TableStorageService.BuildCertificateIngestFilters(TenantId, serials, Since);
|
||||
|
||||
Assert.Equal(7, filters.Count); // 40 serials, 6 per query
|
||||
Assert.All(filters, f => Assert.True(Comparisons(f) <= 15, $"{Comparisons(f)} comparisons: {f}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Filter_BoundsPartitionAndStartWindow()
|
||||
{
|
||||
var filter = Assert.Single(TableStorageService.BuildCertificateIngestFilters(TenantId, ["SN1"], Since));
|
||||
|
||||
Assert.StartsWith($"PartitionKey eq '{TenantId}' and RowKey lt '{RowKeyCodec.InvertedTicks(Since)}' and (", filter);
|
||||
Assert.Contains("SerialNumber eq 'SN1'", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serials_TrimmedDeduplicatedAndBlankDropped()
|
||||
{
|
||||
var filters = TableStorageService.BuildCertificateIngestFilters(
|
||||
TenantId, [" SN1 ", "sn1", "SN1", "", " ", "SN2"], Since);
|
||||
|
||||
var filter = Assert.Single(filters);
|
||||
Assert.Single(Regex.Matches(filter, "SerialNumber eq 'SN1'"));
|
||||
Assert.Contains("SerialNumber eq 'SN2'", filter);
|
||||
Assert.DoesNotContain("SerialNumber eq ''", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoUsableSerial_NoQuery()
|
||||
{
|
||||
Assert.Empty(TableStorageService.BuildCertificateIngestFilters(TenantId, ["", " "], Since));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quote_InSerial_IsEscaped()
|
||||
{
|
||||
var filter = Assert.Single(TableStorageService.BuildCertificateIngestFilters(TenantId, ["AB'C"], Since));
|
||||
|
||||
Assert.Contains("SerialNumber eq 'AB''C'", filter);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,17 @@ public class CodeSigningParityTests
|
||||
Assert.DoesNotContain("[", Constants.CodeSigningPublisherPattern);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Installer_catalog_name_matches_the_constant()
|
||||
{
|
||||
var installer = File.ReadAllText(Path.Combine(FindRepoRoot(), "scripts", "Bootstrap", "Install-AutopilotMonitor.ps1"));
|
||||
|
||||
var match = Regex.Match(installer, "^\\$AgentCatalogName = \"(?<name>[^\"]+)\"\\r?$", RegexOptions.Multiline);
|
||||
|
||||
Assert.True(match.Success, "Install-AutopilotMonitor.ps1 no longer declares $AgentCatalogName");
|
||||
Assert.Equal(Constants.AgentPayloadCatalogFileName, match.Groups["name"].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Release_workflow_catalog_name_matches_the_constant()
|
||||
{
|
||||
|
||||
@@ -249,13 +249,27 @@ public class DelegatedAdminServiceTests
|
||||
[Fact]
|
||||
public async Task Revoke_InvalidatesCache()
|
||||
{
|
||||
// RemoveAsync is the revoke the operator route runs (DelegatedAdminManagementFunction.RevokeCoreAsync).
|
||||
var (svc, repo) = Build();
|
||||
Returns(repo, Row(TenantA));
|
||||
repo.Setup(r => r.SetDelegatedAdminStatusAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(true);
|
||||
repo.Setup(r => r.RemoveDelegatedAdminAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
|
||||
await svc.GetScopeAsync(Id(Upn));
|
||||
await svc.SetStatusAsync(Upn, TenantA, Constants.DelegatedStatus.Revoked);
|
||||
await svc.RemoveAsync(Upn, TenantA);
|
||||
await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
repo.Verify(r => r.GetDelegatedTenantsAsync(It.IsAny<string>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disable_InvalidatesCache()
|
||||
{
|
||||
var (svc, repo) = Build();
|
||||
Returns(repo, Row(TenantA));
|
||||
repo.Setup(r => r.SetDelegatedAdminEnabledAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>())).ReturnsAsync(true);
|
||||
|
||||
await svc.GetScopeAsync(Id(Upn));
|
||||
await svc.SetEnabledAsync(Upn, TenantA, false);
|
||||
await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
repo.Verify(r => r.GetDelegatedTenantsAsync(It.IsAny<string>()), Times.Exactly(2));
|
||||
@@ -598,4 +612,160 @@ public class DelegatedAdminServiceTests
|
||||
repo.Verify(r => r.UpdateTenantGroupAsync(GroupId1, "Renamed", "Service Desk"), Times.Once);
|
||||
repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>()), Times.Once);
|
||||
}
|
||||
|
||||
// ── Self-service groups: an assignment counts only while its assignee is an enabled member of the owner ──
|
||||
|
||||
private static readonly string SelfServiceGroup = Constants.TenantGroupIds.ForHomeTenant(HomeTenant);
|
||||
|
||||
/// <summary>The assignee's TenantAdmins row in the owning tenant: enabled, disabled, or none (null).</summary>
|
||||
private static void OwnerMember(Mock<IAdminRepository> repo, string upn, bool? enabled) =>
|
||||
repo.Setup(r => r.GetTenantMemberAsync(HomeTenant, upn)).ReturnsAsync(enabled is bool isEnabled
|
||||
? new TenantMember { TenantId = HomeTenant, Upn = upn, Role = Constants.TenantRoles.Viewer, IsEnabled = isEnabled }
|
||||
: null);
|
||||
|
||||
[Fact]
|
||||
public async Task GetScope_SelfServiceGroup_EnabledOwnerMember_ConfersTheGroupsTenants()
|
||||
{
|
||||
var (svc, repo) = Build();
|
||||
ReturnsGroups(repo, Assignment(SelfServiceGroup));
|
||||
GroupTenants(repo, SelfServiceGroup, TenantA, TenantB);
|
||||
OwnerMember(repo, Upn, enabled: true);
|
||||
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantA));
|
||||
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantB));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)] // disabled under Access Management
|
||||
[InlineData(null)] // removed — a failed read reaches the gate the same way (the repository returns null)
|
||||
public async Task GetScope_SelfServiceGroup_WithoutAnEnabledOwnerMembership_ConfersNothing(bool? enabled)
|
||||
{
|
||||
var (svc, repo) = Build();
|
||||
ReturnsGroups(repo, Assignment(SelfServiceGroup));
|
||||
GroupTenants(repo, SelfServiceGroup, TenantA);
|
||||
OwnerMember(repo, Upn, enabled);
|
||||
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.True(scope.IsEmpty);
|
||||
repo.Verify(r => r.GetGroupMembershipAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScope_SelfServiceGroup_MembershipInAnotherTenant_DoesNotCount()
|
||||
{
|
||||
var (svc, repo) = Build();
|
||||
ReturnsGroups(repo, Assignment(SelfServiceGroup));
|
||||
GroupTenants(repo, SelfServiceGroup, TenantA);
|
||||
repo.Setup(r => r.GetTenantMemberAsync(TenantC, Upn)).ReturnsAsync(new TenantMember { TenantId = TenantC, Upn = Upn });
|
||||
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.True(scope.IsEmpty);
|
||||
repo.Verify(r => r.GetTenantMemberAsync(HomeTenant, Upn), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScope_SelfServiceGroup_ApplicationPrincipal_IsGatedOnItsAppKeyRow()
|
||||
{
|
||||
const string appKey = "app:0a0a0a0a-0000-0000-0000-000000000001";
|
||||
var (svc, repo) = Build();
|
||||
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(appKey)).ReturnsAsync(new List<TenantGroupAssignment>
|
||||
{
|
||||
new() { Upn = appKey, GroupId = SelfServiceGroup, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true },
|
||||
});
|
||||
GroupTenants(repo, SelfServiceGroup, TenantA);
|
||||
var app = new AdminIdentity(appKey, HomeTenant, Oid);
|
||||
|
||||
Assert.True((await svc.GetScopeAsync(app)).IsEmpty);
|
||||
|
||||
OwnerMember(repo, appKey, enabled: true);
|
||||
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, (await svc.GetFreshScopeAsync(app)).RoleFor(TenantA));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScope_OperatorGroup_NeverReadsTenantMembership()
|
||||
{
|
||||
// Operator groups (GUID ids) are granted by a Global Admin to people who are not members of any owner.
|
||||
var (svc, repo) = Build();
|
||||
ReturnsGroups(repo, Assignment(GroupId1));
|
||||
GroupTenants(repo, GroupId1, TenantA);
|
||||
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantA));
|
||||
repo.Verify(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScope_DirectGrantAndOperatorGroup_SurviveASuppressedSelfServiceGroup()
|
||||
{
|
||||
var (svc, repo) = Build();
|
||||
Returns(repo, Row(TenantC));
|
||||
ReturnsGroups(repo, Assignment(SelfServiceGroup), Assignment(GroupId1));
|
||||
GroupTenants(repo, SelfServiceGroup, TenantA);
|
||||
GroupTenants(repo, GroupId1, TenantB);
|
||||
OwnerMember(repo, Upn, enabled: false);
|
||||
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.Null(scope.RoleFor(TenantA));
|
||||
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantB));
|
||||
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantC));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScope_OwnerMembershipRead_IsPartOfTheCachedRowScope()
|
||||
{
|
||||
var (svc, repo) = Build();
|
||||
ReturnsGroups(repo, Assignment(SelfServiceGroup));
|
||||
GroupTenants(repo, SelfServiceGroup, TenantA);
|
||||
OwnerMember(repo, Upn, enabled: true);
|
||||
|
||||
await svc.GetScopeAsync(Id(Upn));
|
||||
await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
repo.Verify(r => r.GetTenantMemberAsync(HomeTenant, Upn), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFreshScope_BypassesAWarmCache_AndRefreshesIt()
|
||||
{
|
||||
// A SignalR re-join right after a revoke must not be admitted by a scope cached before it.
|
||||
var (svc, repo) = Build();
|
||||
ReturnsGroups(repo, Assignment(SelfServiceGroup));
|
||||
GroupTenants(repo, SelfServiceGroup, TenantA);
|
||||
OwnerMember(repo, Upn, enabled: true);
|
||||
Assert.NotNull((await svc.GetScopeAsync(Id(Upn))).RoleFor(TenantA)); // warms this instance's cache
|
||||
|
||||
OwnerMember(repo, Upn, enabled: null); // removed elsewhere — this cache was not invalidated
|
||||
|
||||
Assert.NotNull((await svc.GetScopeAsync(Id(Upn))).RoleFor(TenantA)); // the cached answer (≤ 30 s)
|
||||
Assert.True((await svc.GetFreshScopeAsync(Id(Upn))).IsEmpty);
|
||||
Assert.True((await svc.GetScopeAsync(Id(Upn))).IsEmpty); // written back: the cache now agrees
|
||||
repo.Verify(r => r.GetTenantMemberAsync(HomeTenant, Upn), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFreshScope_NullIdentity_IsEmpty_WithoutTouchingStorage()
|
||||
{
|
||||
var (svc, repo) = Build();
|
||||
|
||||
Assert.True((await svc.GetFreshScopeAsync(null)).IsEmpty);
|
||||
repo.Verify(r => r.GetDelegatedTenantsAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("msp-99999999-9999-9999-9999-999999999999", "99999999-9999-9999-9999-999999999999")]
|
||||
[InlineData("tpl-aaaaaaaa", null)]
|
||||
[InlineData("0f8fad5bd9cb469fa16570867728950e", null)]
|
||||
[InlineData("msp-", null)]
|
||||
[InlineData("MSP-99999999-9999-9999-9999-999999999999", null)]
|
||||
[InlineData(null, null)]
|
||||
public void TenantGroupIds_OwnerOf_IsTheInverseOfForHomeTenant_ForSelfServiceIdsOnly(string? groupId, string? expectedOwner)
|
||||
{
|
||||
Assert.Equal(expectedOwner, Constants.TenantGroupIds.OwnerOf(groupId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,6 +500,119 @@ public class DelegationSelfServiceTests
|
||||
Assert.Equal(404, r.Failure!.Status);
|
||||
}
|
||||
|
||||
// ── Assignees are ENABLED members of the managing tenant — at grant time, and when the membership ends ──
|
||||
|
||||
private static void AssignedToTheGroup(Harness h) =>
|
||||
h.Repo.Setup(x => x.GetGroupAssignmentsForUpnAsync(MspUser))
|
||||
.ReturnsAsync(new List<TenantGroupAssignment> { new() { Upn = MspUser, GroupId = GroupId } });
|
||||
|
||||
private static void VerifyNoAuditOf(Harness h, string action) =>
|
||||
h.Audit.Verify(a => a.LogAuditEntryAsync(It.IsAny<string>(), action, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<Dictionary<string, string>?>()), Times.Never);
|
||||
|
||||
[Fact]
|
||||
public async Task Assign_DisabledMember_400_NothingWritten()
|
||||
{
|
||||
var h = Build();
|
||||
h.GroupTenants.Add(Customer);
|
||||
h.Repo.Setup(x => x.GetTenantMemberAsync(Home, MspUser))
|
||||
.ReturnsAsync(new TenantMember { Upn = MspUser, TenantId = Home, Role = "Operator", IsEnabled = false });
|
||||
|
||||
var r = await h.Svc.AssignAsync(Home, MspUser, MspAdmin);
|
||||
|
||||
Assert.Equal(400, r.Failure!.Status);
|
||||
Assert.Equal(Constants.DelegationCodes.NotATenantMember, r.Failure.Code);
|
||||
h.Repo.Verify(x => x.AssignGroupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<string>()), Times.Never);
|
||||
h.Repo.Verify(x => x.EnsureOwnedTenantGroupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
VerifyNoAuditOf(h, "CREATE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Assign_MemberRemovedWhileAssigning_IsRolledBack_400_NoCreateAudit()
|
||||
{
|
||||
// The removal wrote the member row before this assignment existed, so its cleanup found nothing to end;
|
||||
// the re-read after the write catches it instead.
|
||||
var h = Build();
|
||||
h.GroupTenants.Add(Customer);
|
||||
h.Repo.SetupSequence(x => x.GetTenantMemberAsync(Home, MspUser))
|
||||
.ReturnsAsync(new TenantMember { Upn = MspUser, TenantId = Home, Role = "Operator" })
|
||||
.ReturnsAsync((TenantMember?)null);
|
||||
AssignedToTheGroup(h);
|
||||
|
||||
var r = await h.Svc.AssignAsync(Home, MspUser, MspAdmin);
|
||||
|
||||
Assert.Equal(400, r.Failure!.Status);
|
||||
Assert.Equal(Constants.DelegationCodes.NotATenantMember, r.Failure.Code);
|
||||
h.Repo.Verify(x => x.AssignGroupAsync(MspUser, GroupId, Constants.DelegatedRoles.DelegatedReader, true, MspAdmin), Times.Once);
|
||||
h.Repo.Verify(x => x.UnassignGroupAsync(MspUser, GroupId), Times.Once);
|
||||
VerifyNoAuditOf(h, "CREATE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignMember_ReasonAndActorReachEveryCustomersAudit()
|
||||
{
|
||||
var h = Build();
|
||||
h.GroupTenants.Add(Customer);
|
||||
h.GroupTenants.Add(Other);
|
||||
|
||||
var r = await h.Svc.AssignMemberAsync(Home, MspUser, "GA@Vendor.Example", DelegationSelfService.AssigneeReasons.GroupAssigned);
|
||||
|
||||
Assert.True(r.Ok);
|
||||
foreach (var tenant in new[] { Customer, Other })
|
||||
{
|
||||
h.Audit.Verify(a => a.LogAuditEntryAsync(tenant, "CREATE", "DelegatedGroupAccess", MspUser, "ga@vendor.example",
|
||||
It.Is<Dictionary<string, string>?>(d => d != null && d["Reason"] == "group-assigned" && d["HomeTenantId"] == Home)), Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EndAssignment_Assigned_UnassignsAndAuditsEveryCustomerWithTheReason_WithoutDisconnecting()
|
||||
{
|
||||
var h = Build();
|
||||
h.GroupTenants.Add(Customer);
|
||||
h.GroupTenants.Add(Other);
|
||||
AssignedToTheGroup(h);
|
||||
|
||||
var ended = await h.Svc.EndAssignmentAsync(Home, "Analyst@Partner.Example", "Admin@Partner.Example", DelegationSelfService.AssigneeReasons.MemberRemoved);
|
||||
|
||||
Assert.True(ended);
|
||||
h.Repo.Verify(x => x.UnassignGroupAsync(MspUser, GroupId), Times.Once);
|
||||
foreach (var tenant in new[] { Customer, Other })
|
||||
{
|
||||
h.Audit.Verify(a => a.LogAuditEntryAsync(tenant, "DELETE", "DelegatedGroupAccess", MspUser, MspAdmin,
|
||||
It.Is<Dictionary<string, string>?>(d => d != null && d["Reason"] == "member-removed" && d["GroupId"] == GroupId)), Times.Once);
|
||||
}
|
||||
Assert.Empty(h.SignalR.DisconnectedUsers); // the caller cuts the streams after its own writes
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EndAssignment_NotAssigned_IsANoOp_WithoutAudit()
|
||||
{
|
||||
var h = Build();
|
||||
h.GroupTenants.Add(Customer);
|
||||
|
||||
var ended = await h.Svc.EndAssignmentAsync(Home, MspUser, MspAdmin, DelegationSelfService.AssigneeReasons.MemberDisabled);
|
||||
|
||||
Assert.False(ended);
|
||||
h.Repo.Verify(x => x.UnassignGroupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
VerifyNoAuditOf(h, "DELETE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unassign_Assigned_AuditsTheSelfServiceReason_AndDisconnectsOnce()
|
||||
{
|
||||
var h = Build();
|
||||
h.GroupTenants.Add(Customer);
|
||||
AssignedToTheGroup(h);
|
||||
|
||||
var r = await h.Svc.UnassignAsync(Home, MspUser, MspAdmin);
|
||||
|
||||
Assert.True(r.Ok);
|
||||
h.Audit.Verify(a => a.LogAuditEntryAsync(Customer, "DELETE", "DelegatedGroupAccess", MspUser, MspAdmin,
|
||||
It.Is<Dictionary<string, string>?>(d => d != null && d["Reason"] == "self-service-unassigned")), Times.Once);
|
||||
Assert.Equal(new[] { MspUser }, h.SignalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
// ── Managers (customer side) ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
using System.Reflection;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Functions.Services.Diagnosis;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Scenario tests for the diagnosis headline: one session row plus synthetic events shaped like the
|
||||
/// agent's payloads (app events carry strings, ESP and config events typed values) per verdict path
|
||||
/// and failure class. The outcome is the status; the cause comes from the verdict path, and for an
|
||||
/// explicit failure from the ESP evidence.
|
||||
/// </summary>
|
||||
public class DiagnosisHeadlineBuilderTests
|
||||
{
|
||||
private const string NotesId = "00000000-0000-4000-8000-0000000000a1";
|
||||
private const string VpnId = "00000000-0000-4000-8000-0000000000a2";
|
||||
private const string AgentId = "00000000-0000-4000-8000-0000000000a3";
|
||||
private const string PortalId = "00000000-0000-4000-8000-0000000000a4";
|
||||
private static readonly DateTime T0 = new(2026, 10, 13, 13, 55, 0, DateTimeKind.Utc);
|
||||
|
||||
// ── Events ───────────────────────────────────────────────────────────
|
||||
|
||||
private sealed class Timeline
|
||||
{
|
||||
private readonly List<EnrollmentEvent> _events = new();
|
||||
private long _sequence;
|
||||
|
||||
public Timeline Add(string type, Dictionary<string, object>? data = null, double minute = -1)
|
||||
{
|
||||
_sequence++;
|
||||
_events.Add(new EnrollmentEvent
|
||||
{
|
||||
EventId = $"evt-{_sequence}",
|
||||
EventType = type,
|
||||
Sequence = _sequence,
|
||||
Timestamp = T0.AddMinutes(minute >= 0 ? minute : _sequence),
|
||||
Data = data ?? new Dictionary<string, object>(),
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public IReadOnlyList<EnrollmentEvent> Events => _events;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> App(string appId, string appName, params (string Key, object Value)[] extra)
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["appId"] = appId, ["appName"] = appName, ["intent"] = "Install", ["targeted"] = "User", ["appType"] = "WinGet",
|
||||
};
|
||||
foreach (var (key, value) in extra) data[key] = value;
|
||||
return data;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> EspConfig(bool continueAnyway = false, params string[] blockingIds) => new()
|
||||
{
|
||||
["allowContinueAnyway"] = continueAnyway,
|
||||
["allowTryAgain"] = true,
|
||||
["allowReset"] = false,
|
||||
["syncFailureTimeoutMinutes"] = 40L,
|
||||
["skipUserStatusPage"] = false,
|
||||
["skipDeviceStatusPage"] = false,
|
||||
["espTrackedWin32AppIds"] = blockingIds.Cast<object>().ToList(),
|
||||
["espTrackedWin32Count"] = (long)blockingIds.Length,
|
||||
};
|
||||
|
||||
private static Dictionary<string, object> Failed(string category, string subcategory, string? errorCode = null, string? culprits = null)
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["reason"] = "esp_terminal_failure",
|
||||
["failureType"] = $"Provisioning_{category}_{subcategory}_Failed",
|
||||
["category"] = category,
|
||||
["failedSubcategory"] = subcategory,
|
||||
};
|
||||
if (errorCode != null) data["errorCode"] = errorCode;
|
||||
if (culprits != null) data["likelyCulpritApps"] = culprits;
|
||||
return data;
|
||||
}
|
||||
|
||||
private static SessionSummary Session(SessionStatus status, string? verdictPath, string failureReason = "", string reconcileReason = "")
|
||||
{
|
||||
var terminal = status is SessionStatus.Succeeded or SessionStatus.Failed or SessionStatus.Incomplete;
|
||||
return new SessionSummary
|
||||
{
|
||||
SessionId = "00000000-0000-4000-8000-000000000001",
|
||||
TenantId = "00000000-0000-4000-8000-000000000002",
|
||||
Status = status,
|
||||
VerdictPath = verdictPath,
|
||||
FailureReason = failureReason,
|
||||
ReconcileReason = reconcileReason,
|
||||
StartedAt = T0,
|
||||
CompletedAt = terminal ? T0.AddMinutes(50) : null,
|
||||
LastEventAt = T0.AddMinutes(49),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Explicit ESP failures ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void An_app_that_failed_with_the_esp_error_is_the_cause_with_its_facts()
|
||||
{
|
||||
var events = new Timeline()
|
||||
.Add("esp_config_detected", EspConfig(false, NotesId))
|
||||
.Add("app_install_started", App(PortalId, "Contoso Portal", ("targeted", "Device")))
|
||||
.Add("app_install_completed", App(PortalId, "Contoso Portal", ("state", "Installed"), ("targeted", "Device")))
|
||||
.Add("app_install_started", App(NotesId, "Contoso Notes", ("state", "Installing")))
|
||||
.Add("app_install_started", App(NotesId, "Contoso Notes", ("state", "Installing")))
|
||||
.Add("app_install_started", App(NotesId, "Contoso Notes", ("state", "Installing")))
|
||||
.Add("app_install_started", App(NotesId, "Contoso Notes", ("state", "Installing")))
|
||||
.Add("app_install_failed", App(NotesId, "Contoso Notes", ("state", "Error"), ("errorCode", "0x80d03805"), ("detectionResult", "NotDetected")))
|
||||
.Add("esp_provisioning_status", new()
|
||||
{
|
||||
["category"] = "AccountSetup",
|
||||
["categorySucceeded"] = "False",
|
||||
["subcategories"] = new Dictionary<string, object>
|
||||
{
|
||||
["Certificates"] = new Dictionary<string, object> { ["state"] = "succeeded", ["statusText"] = "Complete" },
|
||||
["Apps"] = new Dictionary<string, object> { ["state"] = "failed", ["statusText"] = "Failed: 0x80d03805" },
|
||||
},
|
||||
["failedSubcategories"] = "Apps",
|
||||
["failedSubcategoryErrorCode"] = "0x80d03805",
|
||||
})
|
||||
.Add("esp_provisioning_status", new()
|
||||
{
|
||||
["category"] = "DeviceSetup",
|
||||
["categorySucceeded"] = "True",
|
||||
["subcategories"] = new Dictionary<string, object> { ["Apps"] = new Dictionary<string, object> { ["state"] = "succeeded" } },
|
||||
})
|
||||
.Add("esp_failure_settle_started", new() { ["category"] = "AccountSetup", ["failedSubcategory"] = "Apps", ["errorCode"] = "0x80d03805" })
|
||||
.Add("enrollment_failed", Failed("AccountSetup", "Apps", "0x80d03805", "Contoso Notes"));
|
||||
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, VerdictPaths.AgentFailed, "enrollment_failed: esp_terminal_failure (0x80d03805)"), events.Events);
|
||||
|
||||
Assert.Equal(DiagnosisOutcomes.Failed, h.Outcome);
|
||||
Assert.Equal(DiagnosisCauses.EspAppFailure, h.Cause);
|
||||
Assert.Equal(VerdictPaths.AgentFailed, h.VerdictPath);
|
||||
Assert.Equal("enrollment_failed: esp_terminal_failure (0x80d03805)", h.ReasonText);
|
||||
Assert.Equal(T0.AddMinutes(50), h.At);
|
||||
|
||||
Assert.NotNull(h.FailedStep);
|
||||
Assert.Equal("AccountSetup", h.FailedStep!.Category);
|
||||
Assert.Equal("Apps", h.FailedStep.Subcategory);
|
||||
Assert.Equal("0x80d03805", h.FailedStep.ErrorCode);
|
||||
Assert.Equal("esp_terminal_failure", h.FailedStep.Reason);
|
||||
|
||||
// The ESP's own culprit name is the same app — one row, the stronger evidence.
|
||||
var culprit = Assert.Single(h.Culprits!);
|
||||
Assert.Equal(DiagnosisCulpritKinds.App, culprit.Kind);
|
||||
Assert.Equal(DiagnosisCulpritEvidence.InstallFailed, culprit.Evidence);
|
||||
Assert.Equal(NotesId, culprit.Key);
|
||||
Assert.Equal("Contoso Notes", culprit.Name);
|
||||
Assert.Equal(4, culprit.InstallStarts);
|
||||
Assert.Equal("Install", culprit.Intent);
|
||||
Assert.Equal("User", culprit.Targeted);
|
||||
Assert.Equal("WinGet", culprit.AppType);
|
||||
Assert.Equal("0x80d03805", culprit.ErrorCode);
|
||||
Assert.Equal("NotDetected", culprit.DetectionResult);
|
||||
Assert.True(culprit.EspBlocking);
|
||||
|
||||
// Phases in ESP order; the failed step carries the code.
|
||||
Assert.Equal(new[] { "DeviceSetup", "AccountSetup" }, h.Esp!.Select(p => p.Category));
|
||||
var account = h.Esp![1];
|
||||
Assert.Equal("failed", account.State);
|
||||
Assert.Equal("0x80d03805", account.Subcategories.Single(s => s.Name == "Apps").ErrorCode);
|
||||
Assert.Null(account.Subcategories.Single(s => s.Name == "Certificates").ErrorCode);
|
||||
Assert.Equal("succeeded", h.Esp![0].State);
|
||||
|
||||
Assert.False(h.EspConfig!.ContinueAnyway);
|
||||
Assert.True(h.EspConfig.TryAgain);
|
||||
Assert.Equal(40, h.EspConfig.TimeoutMinutes);
|
||||
Assert.Equal(1, h.EspConfig.BlockingAppCount);
|
||||
|
||||
Assert.Null(h.Waiting);
|
||||
Assert.Null(h.Caveats);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_a_failed_app_the_esp_timed_out_on_the_unfinished_ones()
|
||||
{
|
||||
var events = new Timeline()
|
||||
.Add("app_install_started", App(VpnId, "Contoso VPN", ("targeted", "Device")))
|
||||
.Add("app_install_starved", App(AgentId, "Contoso Agent", ("state", "NotInstalled"), ("targeted", "Device")))
|
||||
.Add("app_install_failed", App("00000000-0000-4000-8000-0000000000a9", "Contoso Catalog", ("intent", "Available"), ("errorCode", "1603")))
|
||||
.Add("enrollment_failed", Failed("DeviceSetup", "Apps"))
|
||||
.Add("esp_apps_failure_correlation", new()
|
||||
{
|
||||
["failedSubcategory"] = "Apps",
|
||||
["category"] = "DeviceSetup",
|
||||
["errorCode"] = "",
|
||||
["likelyCauseApps"] = new List<object>
|
||||
{
|
||||
new Dictionary<string, object> { ["appId"] = VpnId, ["appName"] = "Contoso VPN", ["state"] = "Installing", ["progressPercent"] = 40L },
|
||||
},
|
||||
})
|
||||
.Add("app_install_failed", App(VpnId, "Contoso VPN", ("targeted", "Device"), ("confidence", "presumed"), ("failureType", "esp_apps_timeout")));
|
||||
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, VerdictPaths.AgentFailed), events.Events);
|
||||
|
||||
Assert.Equal(DiagnosisCauses.EspAppTimeout, h.Cause);
|
||||
Assert.Collection(h.Culprits!,
|
||||
c =>
|
||||
{
|
||||
// Promoted at the failure; the in-flight row for the same app folds into it.
|
||||
Assert.Equal(VpnId, c.Key);
|
||||
Assert.Equal(DiagnosisCulpritEvidence.PresumedStuck, c.Evidence);
|
||||
},
|
||||
c =>
|
||||
{
|
||||
Assert.Equal(AgentId, c.Key);
|
||||
Assert.Equal(DiagnosisCulpritEvidence.NeverStarted, c.Evidence);
|
||||
Assert.Equal("NotInstalled", c.State);
|
||||
});
|
||||
// An Available app's failure never blocks the ESP.
|
||||
Assert.DoesNotContain(h.Culprits!, c => c.Name == "Contoso Catalog");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_apps_rank_the_esp_error_first_and_an_app_that_installed_later_is_no_culprit()
|
||||
{
|
||||
var events = new Timeline()
|
||||
.Add("app_install_failed", App(VpnId, "Contoso VPN", ("exitCode", "1603"), ("errorCode", "1603")))
|
||||
.Add("app_install_failed", App(NotesId, "Contoso Notes", ("errorCode", "-2147023278")))
|
||||
.Add("app_install_failed", App(PortalId, "Contoso Portal", ("errorCode", "1618")))
|
||||
.Add("app_install_started", App(PortalId, "Contoso Portal"))
|
||||
.Add("app_install_completed", App(PortalId, "Contoso Portal", ("state", "Installed")))
|
||||
.Add("enrollment_failed", Failed("AccountSetup", "Apps", "0x80070652"));
|
||||
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, VerdictPaths.AgentFailed), events.Events);
|
||||
|
||||
Assert.Equal(DiagnosisCauses.EspAppFailure, h.Cause);
|
||||
// -2147023278 is 0x80070652 in decimal: the same error as the ESP's.
|
||||
Assert.Equal(new[] { NotesId, VpnId }, h.Culprits!.Select(c => c.Key));
|
||||
Assert.Equal("1603", h.Culprits![1].ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_detection_failure_without_an_app_code_matches_the_esp_not_detected_error()
|
||||
{
|
||||
// The IME shape of a detection failure: no errorCode, hresultFromWin32 and exitCode "0".
|
||||
var events = new Timeline()
|
||||
.Add("app_install_failed", App(VpnId, "Contoso VPN", ("errorCode", "1603")))
|
||||
.Add("app_install_failed", App(NotesId, "Contoso Notes", ("state", "Error"), ("detectionResult", "NotDetected"), ("exitCode", "0"), ("hresultFromWin32", "0")))
|
||||
.Add("app_install_failed", App(PortalId, "Contoso Portal", ("errorCode", "1618")))
|
||||
.Add("enrollment_failed", Failed("AccountSetup", "Apps", "0x87d1041c"));
|
||||
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, VerdictPaths.AgentFailed), events.Events);
|
||||
|
||||
Assert.Equal(DiagnosisCauses.EspAppFailure, h.Cause);
|
||||
var first = h.Culprits![0];
|
||||
Assert.Equal(NotesId, first.Key);
|
||||
Assert.Null(first.ErrorCode);
|
||||
Assert.Equal("0", first.ExitCode);
|
||||
Assert.Equal("NotDetected", first.DetectionResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_step_other_than_apps_fails_without_app_culprits()
|
||||
{
|
||||
var events = new Timeline()
|
||||
.Add("app_install_failed", App(NotesId, "Contoso Notes", ("errorCode", "1603")))
|
||||
.Add("enrollment_failed", Failed("DeviceSetup", "Certificates", "0x80070005"));
|
||||
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, VerdictPaths.AgentFailed), events.Events);
|
||||
|
||||
Assert.Equal(DiagnosisCauses.EspStepFailure, h.Cause);
|
||||
Assert.Equal("Certificates", h.FailedStep!.Subcategory);
|
||||
Assert.Equal("0x80070005", h.FailedStep.ErrorCode);
|
||||
Assert.Null(h.Culprits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_expired_observation_failure_takes_its_step_from_the_advisory()
|
||||
{
|
||||
var events = new Timeline()
|
||||
.Add("esp_failure_advisory", Failed("AccountSetup", "Apps", "0x87D1041C", "Contoso Notes"))
|
||||
.Add("enrollment_failed", new() { ["reason"] = "esp_terminal_failure", ["advisoryReason"] = "advisory_completion_window_expired_without_completion_evidence" });
|
||||
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, VerdictPaths.AgentFailed), events.Events);
|
||||
|
||||
Assert.Equal("AccountSetup", h.FailedStep!.Category);
|
||||
Assert.Equal("Apps", h.FailedStep.Subcategory);
|
||||
Assert.Equal("0x87d1041c", h.FailedStep.ErrorCode);
|
||||
// Only the ESP's own list names the app: unfinished, not failed.
|
||||
Assert.Equal(DiagnosisCauses.EspAppTimeout, h.Cause);
|
||||
var culprit = Assert.Single(h.Culprits!);
|
||||
Assert.Equal(DiagnosisCulpritEvidence.NamedByEsp, culprit.Evidence);
|
||||
Assert.Null(culprit.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_shell_core_failure_without_enrollment_failed_is_an_esp_step_failure()
|
||||
{
|
||||
var events = new Timeline()
|
||||
.Add("esp_failure", new() { ["failureType"] = "ESPProgress_Timeout" });
|
||||
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, VerdictPaths.AgentEspFailureFallback, "ESP (Enrollment Status Page) reported a failure: ESPProgress_Timeout"), events.Events);
|
||||
|
||||
Assert.Equal(DiagnosisCauses.EspStepFailure, h.Cause);
|
||||
Assert.Equal("ESPProgress_Timeout", h.FailedStep!.FailureType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_failure_without_an_esp_step_is_the_agents_own()
|
||||
{
|
||||
var events = new Timeline()
|
||||
.Add("enrollment_failed", new() { ["reason"] = "oobe_update_not_finished", ["advisoryReason"] = "oobe_update_cap_expired_without_restart" });
|
||||
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, VerdictPaths.AgentFailed, "enrollment_failed: oobe_update_not_finished"), events.Events);
|
||||
|
||||
Assert.Equal(DiagnosisCauses.AgentFailure, h.Cause);
|
||||
Assert.Equal("oobe_update_not_finished", h.FailedStep!.Reason);
|
||||
Assert.Null(h.Culprits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_pre_provisioning_failure_is_a_whiteglove_failure_with_its_culprits()
|
||||
{
|
||||
var failed = Failed("DeviceSetup", "Apps", "0x80070652");
|
||||
failed["failureType"] = "WhiteGlove_Failed";
|
||||
var events = new Timeline()
|
||||
.Add("app_install_failed", App(VpnId, "Contoso VPN", ("targeted", "Device"), ("errorCode", "0x80070652")))
|
||||
.Add("enrollment_failed", failed);
|
||||
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, VerdictPaths.AgentFailed), events.Events);
|
||||
|
||||
Assert.Equal(DiagnosisCauses.WhiteGloveFailure, h.Cause);
|
||||
Assert.Equal(VpnId, Assert.Single(h.Culprits!).Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_sweep_r1_explicit_failure_is_read_from_the_evidence_like_the_agents()
|
||||
{
|
||||
var events = new Timeline()
|
||||
.Add("enrollment_failed", Failed("DeviceSetup", "SecurityPolicies", "0x80070002"));
|
||||
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, "maxlife:r1"), events.Events);
|
||||
|
||||
Assert.Equal(DiagnosisCauses.EspStepFailure, h.Cause);
|
||||
Assert.Equal("SecurityPolicies", h.FailedStep!.Subcategory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_app_policy_provider_that_never_finished_is_a_culprit()
|
||||
{
|
||||
var events = new Timeline()
|
||||
.Add("esp_policy_provider_stalled", new()
|
||||
{
|
||||
["reason"] = "provider_incomplete",
|
||||
["stalledProviders"] = new List<object>
|
||||
{
|
||||
new Dictionary<string, object> { ["name"] = "Sidecar", ["scope"] = "device", ["kind"] = "setupApps", ["stalledForMinutes"] = 15.5 },
|
||||
},
|
||||
})
|
||||
.Add("enrollment_failed", Failed("DeviceSetup", "Apps"));
|
||||
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, VerdictPaths.AgentFailed), events.Events);
|
||||
|
||||
var provider = Assert.Single(h.Culprits!);
|
||||
Assert.Equal(DiagnosisCulpritKinds.PolicyProvider, provider.Kind);
|
||||
Assert.Equal("Sidecar", provider.Name);
|
||||
Assert.Equal(15.5, provider.StalledForMinutes);
|
||||
// A provider is no app: the step failed without an app to blame.
|
||||
Assert.Equal(DiagnosisCauses.EspStepFailure, h.Cause);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_appx_log_candidate_joins_the_culprits()
|
||||
{
|
||||
var events = new Timeline()
|
||||
.Add("enrollment_failed", Failed("AccountSetup", "Apps", "0x80073cf3"))
|
||||
.Add("esp_appx_failure_analysis", new()
|
||||
{
|
||||
["verdict"] = "appx_candidate_identified",
|
||||
["confidence"] = "high",
|
||||
["topCandidatePackage"] = "Contoso.Notes_1.0.0.0_x64__abcdefghijklm",
|
||||
["topCandidatePackageName"] = "Contoso.Notes",
|
||||
["topCandidateHresult"] = "0x80073cf3",
|
||||
});
|
||||
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, VerdictPaths.AgentFailed), events.Events);
|
||||
|
||||
var package = Assert.Single(h.Culprits!);
|
||||
Assert.Equal(DiagnosisCulpritKinds.AppxPackage, package.Kind);
|
||||
Assert.Equal("Contoso.Notes", package.Name);
|
||||
Assert.Equal("high", package.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_administrator_preempting_through_the_agent_is_admin_marked()
|
||||
{
|
||||
var events = new Timeline()
|
||||
.Add("enrollment_failed", new() { ["reason"] = "Session failed by administrator (detected on register-session)." });
|
||||
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, VerdictPaths.AgentFailed), events.Events);
|
||||
|
||||
Assert.Equal(DiagnosisCauses.AdminMarked, h.Cause);
|
||||
Assert.Null(h.FailedStep);
|
||||
}
|
||||
|
||||
// ── Verdict paths that state the cause ───────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(SessionStatus.Failed, "rule:ANALYZE-APP-013", DiagnosisCauses.RuleKo)]
|
||||
[InlineData(SessionStatus.Failed, VerdictPaths.ManualFailed, DiagnosisCauses.AdminMarked)]
|
||||
[InlineData(SessionStatus.Succeeded, VerdictPaths.ManualSucceeded, DiagnosisCauses.AdminMarked)]
|
||||
[InlineData(SessionStatus.Succeeded, VerdictPaths.AgentComplete, DiagnosisCauses.Completed)]
|
||||
[InlineData(SessionStatus.Succeeded, VerdictPaths.AgentGatherComplete, DiagnosisCauses.Completed)]
|
||||
[InlineData(SessionStatus.Succeeded, "sweep:r4", DiagnosisCauses.Reconciled)]
|
||||
[InlineData(SessionStatus.Succeeded, "late:r2", DiagnosisCauses.Reconciled)]
|
||||
[InlineData(SessionStatus.Succeeded, "sweep:r5_assumed", DiagnosisCauses.Reconciled)]
|
||||
[InlineData(SessionStatus.Succeeded, "sweep:r1b_succeeded", DiagnosisCauses.Reconciled)]
|
||||
[InlineData(SessionStatus.Succeeded, "sweep:r1c", DiagnosisCauses.Reconciled)]
|
||||
[InlineData(SessionStatus.Succeeded, VerdictPaths.SweepSelfDeployingReconcile, DiagnosisCauses.Reconciled)]
|
||||
[InlineData(SessionStatus.Succeeded, VerdictPaths.RetroSelfDeployingReconcile, DiagnosisCauses.Reconciled)]
|
||||
[InlineData(SessionStatus.Incomplete, "sweep:r6", DiagnosisCauses.SweepTimeout)]
|
||||
[InlineData(SessionStatus.Incomplete, "retro:r5_incomplete", DiagnosisCauses.SweepTimeout)]
|
||||
[InlineData(SessionStatus.Incomplete, "legacy:r6", DiagnosisCauses.SweepTimeout)]
|
||||
[InlineData(SessionStatus.Incomplete, "maxlife:r5_incomplete", DiagnosisCauses.AgentGaveUp)]
|
||||
[InlineData(SessionStatus.Incomplete, "stopwait:r6", DiagnosisCauses.AgentGaveUp)]
|
||||
[InlineData(SessionStatus.Incomplete, "sweep:r3", DiagnosisCauses.AgentGaveUp)]
|
||||
[InlineData(SessionStatus.Incomplete, VerdictPaths.RegisterSuperseded, DiagnosisCauses.Superseded)]
|
||||
[InlineData(SessionStatus.Incomplete, VerdictPaths.RetroSuperseded, DiagnosisCauses.Superseded)]
|
||||
[InlineData(SessionStatus.Stalled, VerdictPaths.SweepStalled, DiagnosisCauses.AgentSilent)]
|
||||
[InlineData(SessionStatus.Stalled, VerdictPaths.AgentStallProbe, DiagnosisCauses.NoProgress)]
|
||||
[InlineData(SessionStatus.AwaitingUser, VerdictPaths.IngestWhiteGloveAwaiting, DiagnosisCauses.AwaitingUser)]
|
||||
[InlineData(SessionStatus.AwaitingUser, VerdictPaths.SweepWhiteGloveAwaiting, DiagnosisCauses.AwaitingUser)]
|
||||
[InlineData(SessionStatus.AwaitingUser, "sweep:r5_awaiting", DiagnosisCauses.AwaitingUser)]
|
||||
[InlineData(SessionStatus.AwaitingUser, "sweep:r1b_awaiting", DiagnosisCauses.AwaitingUser)]
|
||||
[InlineData(SessionStatus.Pending, VerdictPaths.AgentWhiteGlovePending, DiagnosisCauses.WhiteGloveSealed)]
|
||||
[InlineData(SessionStatus.InProgress, VerdictPaths.RegisterNew, DiagnosisCauses.None)]
|
||||
[InlineData(SessionStatus.InProgress, VerdictPaths.AgentStallHeal, DiagnosisCauses.None)]
|
||||
[InlineData(SessionStatus.InProgress, VerdictPaths.AgentWhiteGloveResumed, DiagnosisCauses.None)]
|
||||
public void The_verdict_path_states_the_cause(SessionStatus status, string path, string cause)
|
||||
{
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(status, path), Array.Empty<EnrollmentEvent>());
|
||||
|
||||
Assert.Equal(cause, h.Cause);
|
||||
Assert.Equal(path, h.VerdictPath);
|
||||
Assert.Null(h.FailedStep);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_cause_and_outcome_is_in_its_vocabulary()
|
||||
{
|
||||
Assert.Equal(DiagnosisOutcomes.All.Count, DiagnosisOutcomes.All.Distinct().Count());
|
||||
foreach (SessionStatus status in Enum.GetValues(typeof(SessionStatus)))
|
||||
Assert.Contains(DiagnosisHeadlineBuilder.OutcomeOf(status), DiagnosisOutcomes.All);
|
||||
Assert.Equal(DiagnosisCauses.All.Count, DiagnosisCauses.All.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_rule_ko_names_the_rule()
|
||||
{
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, "rule:ANALYZE-APP-013", "Rule: App Detection Failure During ESP"), Array.Empty<EnrollmentEvent>());
|
||||
|
||||
Assert.Equal("ANALYZE-APP-013", h.RuleId);
|
||||
Assert.Equal("Rule: App Detection Failure During ESP", h.ReasonText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_superseded_session_names_its_successor()
|
||||
{
|
||||
var h = DiagnosisHeadlineBuilder.Build(
|
||||
Session(SessionStatus.Incomplete, VerdictPaths.RegisterSuperseded,
|
||||
"Superseded by session 00000000-0000-4000-8000-0000000000FF: the device registered a new enrollment."),
|
||||
Array.Empty<EnrollmentEvent>());
|
||||
|
||||
Assert.Equal(DiagnosisCauses.Superseded, h.Cause);
|
||||
Assert.Equal("00000000-0000-4000-8000-0000000000ff", h.SupersededBySessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_agent_timeout_is_the_agent_giving_up_and_lists_what_it_waited_for()
|
||||
{
|
||||
var events = new Timeline()
|
||||
.Add("completion_waiting", new() { ["missingPrerequisites"] = "desktop_arrival,hello_resolution" })
|
||||
.Add("app_tracking_summary", new()
|
||||
{
|
||||
["installingNames"] = new List<object> { "Contoso Notes" },
|
||||
["pendingNames"] = new List<object> { "Contoso Agent", "Contoso Notes" },
|
||||
})
|
||||
.Add("enrollment_failed", new() { ["failureType"] = "agent_timeout", ["failureSource"] = "max_lifetime_timer" });
|
||||
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Incomplete, "maxlife:r5_incomplete", "No completion signal within 48h grace"), events.Events);
|
||||
|
||||
Assert.Equal(DiagnosisOutcomes.Incomplete, h.Outcome);
|
||||
Assert.Equal(DiagnosisCauses.AgentGaveUp, h.Cause);
|
||||
Assert.Null(h.FailedStep);
|
||||
Assert.Collection(h.Waiting!,
|
||||
w => { Assert.Equal(DiagnosisWaitKinds.Prerequisite, w.Kind); Assert.Equal(DiagnosisPrerequisites.DesktopArrival, w.Name); },
|
||||
w => Assert.Equal(DiagnosisPrerequisites.HelloResolution, w.Name),
|
||||
w => { Assert.Equal(DiagnosisWaitKinds.App, w.Kind); Assert.Equal("Contoso Notes", w.Name); Assert.Equal("installing", w.State); },
|
||||
w => { Assert.Equal("Contoso Agent", w.Name); Assert.Equal("pending", w.State); });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_prerequisite_names_are_the_decision_engines_literals()
|
||||
{
|
||||
// The engine's literal class is internal to DecisionCore; the portal's wait labels key on this list.
|
||||
var literals = typeof(DecisionEngine).GetNestedType("CompletionPrerequisites", BindingFlags.NonPublic)!
|
||||
.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(f => f.IsLiteral)
|
||||
.Select(f => (string)f.GetRawConstantValue()!)
|
||||
.OrderBy(s => s, StringComparer.Ordinal);
|
||||
|
||||
Assert.Equal(literals, DiagnosisPrerequisites.All.OrderBy(s => s, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_stalled_session_is_dated_by_its_stall_and_lists_stuck_providers()
|
||||
{
|
||||
var session = Session(SessionStatus.Stalled, VerdictPaths.SweepStalled, "Agent silent for 130min (detected by maintenance sweep)");
|
||||
session.StalledAt = T0.AddMinutes(130);
|
||||
var events = new Timeline()
|
||||
.Add("esp_policy_provider_stalled", new()
|
||||
{
|
||||
["stalledProviders"] = new List<object> { new Dictionary<string, object> { ["name"] = "Sidecar", ["scope"] = "device" } },
|
||||
});
|
||||
|
||||
var h = DiagnosisHeadlineBuilder.Build(session, events.Events);
|
||||
|
||||
Assert.Equal(DiagnosisCauses.AgentSilent, h.Cause);
|
||||
Assert.Equal(T0.AddMinutes(130), h.At);
|
||||
var item = Assert.Single(h.Waiting!);
|
||||
Assert.Equal(DiagnosisWaitKinds.PolicyProvider, item.Kind);
|
||||
Assert.Equal("Sidecar", item.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_reconciled_success_keeps_the_reconcile_reason()
|
||||
{
|
||||
var h = DiagnosisHeadlineBuilder.Build(
|
||||
Session(SessionStatus.Succeeded, "sweep:r4", reconcileReason: "Reconciled at timeout: user completed setup (desktop + Windows Hello)"),
|
||||
Array.Empty<EnrollmentEvent>());
|
||||
|
||||
Assert.Equal(DiagnosisCauses.Reconciled, h.Cause);
|
||||
Assert.Equal("Reconciled at timeout: user completed setup (desktop + Windows Hello)", h.ReasonText);
|
||||
Assert.Null(h.Waiting);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_soft_failure_shows_the_step_the_user_continued_past()
|
||||
{
|
||||
var session = Session(SessionStatus.Succeeded, VerdictPaths.AgentCompleteSoft);
|
||||
session.EspSoftFailure = true;
|
||||
var events = new Timeline()
|
||||
.Add("app_install_failed", App(NotesId, "Contoso Notes", ("errorCode", "0x80070652")))
|
||||
.Add("esp_failure_advisory", Failed("AccountSetup", "Apps", "0x80070652", "Contoso Notes"))
|
||||
.Add("enrollment_complete");
|
||||
|
||||
var h = DiagnosisHeadlineBuilder.Build(session, events.Events);
|
||||
|
||||
Assert.Equal(DiagnosisCauses.SoftFailure, h.Cause);
|
||||
Assert.Equal("Apps", h.FailedStep!.Subcategory);
|
||||
Assert.Equal(DiagnosisCulpritEvidence.InstallFailed, Assert.Single(h.Culprits!).Evidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_legacy_row_derives_its_path_and_says_so()
|
||||
{
|
||||
var events = new Timeline()
|
||||
.Add("app_install_failed", App(NotesId, "Contoso Notes", ("errorCode", "0x87d1041c")))
|
||||
.Add("enrollment_failed", Failed("AccountSetup", "Apps", "0x87d1041c"));
|
||||
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, null, "enrollment_failed: esp_terminal_failure (0x87d1041c)"), events.Events);
|
||||
|
||||
Assert.Equal(VerdictPaths.AgentFailed, h.VerdictPath);
|
||||
Assert.Equal(DiagnosisCauses.EspAppFailure, h.Cause);
|
||||
Assert.Equal(new[] { DiagnosisCaveats.VerdictDerived }, h.Caveats);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unattributable_legacy_verdict_is_unknown_and_keeps_its_reason()
|
||||
{
|
||||
var h = DiagnosisHeadlineBuilder.Build(
|
||||
Session(SessionStatus.Failed, null, "Session timed out after 5 hours (started at 2026-01-01 08:00 UTC)"),
|
||||
Array.Empty<EnrollmentEvent>());
|
||||
|
||||
Assert.Equal(DiagnosisCauses.Unknown, h.Cause);
|
||||
Assert.Equal("Session timed out after 5 hours (started at 2026-01-01 08:00 UTC)", h.ReasonText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Observation_limits_become_caveats()
|
||||
{
|
||||
var config = EspConfig(false, NotesId);
|
||||
config["espTrackedWin32Count"] = 60L;
|
||||
var events = new Timeline()
|
||||
.Add("agent_started", new() { ["previousExitType"] = "exception_crash" })
|
||||
.Add("agent_late_start", new() { ["bootToAgentStartSeconds"] = 900.0 })
|
||||
.Add("esp_config_detected", config);
|
||||
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Succeeded, VerdictPaths.AgentComplete), events.Events);
|
||||
|
||||
Assert.Equal(new[] { DiagnosisCaveats.AgentStartedLate, DiagnosisCaveats.AgentCrashed, DiagnosisCaveats.BlockingListTruncated }, h.Caveats);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_later_config_without_lists_keeps_the_earlier_blocking_evidence()
|
||||
{
|
||||
var events = new Timeline()
|
||||
.Add("esp_config_detected", EspConfig(false, NotesId))
|
||||
.Add("esp_config_detected", new() { ["allowContinueAnyway"] = "true", ["syncFailureTimeoutMinutes"] = "60" })
|
||||
.Add("app_install_failed", App(NotesId, "Contoso Notes", ("errorCode", "1603")))
|
||||
.Add("enrollment_failed", Failed("AccountSetup", "Apps"));
|
||||
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, VerdictPaths.AgentFailed), events.Events);
|
||||
|
||||
Assert.True(h.EspConfig!.ContinueAnyway);
|
||||
Assert.Equal(60, h.EspConfig.TimeoutMinutes);
|
||||
Assert.Equal(1, h.EspConfig.BlockingAppCount);
|
||||
Assert.True(Assert.Single(h.Culprits!).EspBlocking);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_session_without_events_still_has_a_verdict()
|
||||
{
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, VerdictPaths.AgentFailed, "enrollment_failed: esp_terminal_failure"), Array.Empty<EnrollmentEvent>());
|
||||
|
||||
Assert.Equal(DiagnosisOutcomes.Failed, h.Outcome);
|
||||
Assert.Equal(DiagnosisCauses.Unknown, h.Cause);
|
||||
Assert.Null(h.Esp);
|
||||
Assert.Null(h.EspConfig);
|
||||
}
|
||||
}
|
||||
@@ -509,12 +509,95 @@ public class DistressValidationTests
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Constants_MaxContentLength_Is1536()
|
||||
public void Constants_MaxContentLength_IsTheSharedCapOf2048()
|
||||
{
|
||||
// Bumped from 1024 to 1536 to accommodate V2 cert-context fields (Thumbprint, Subject,
|
||||
// Issuer, NotBefore/After, SourceState). Rate-limit + sanitize + truncate + enum-validation
|
||||
// remain the primary DoS defenses; the size cap is a payload-shape gate, not the throttle.
|
||||
Assert.Equal(1536, ReportDistressFunction.MaxContentLength);
|
||||
// 1024 → 1536 for the V2 cert-context fields, → 2048 (shared with the agent, which trims
|
||||
// its message to fit) for the device-context fields. Rate-limit + sanitize + truncate +
|
||||
// enum-validation remain the primary DoS defenses; the size cap is a payload-shape gate.
|
||||
Assert.Equal(AutopilotMonitor.Shared.Constants.DistressReportMaxBytes, ReportDistressFunction.MaxContentLength);
|
||||
Assert.Equal(2048, ReportDistressFunction.MaxContentLength);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Device-context tokens — dropped when off format, never truncated
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("26200.6899", "26200.6899")]
|
||||
[InlineData("26100", "26100")]
|
||||
[InlineData(" 26300.1234 ", "26300.1234")]
|
||||
[InlineData("26200.6899\n", "26200.6899")]
|
||||
[InlineData("26200.", null)]
|
||||
[InlineData(".6899", null)]
|
||||
[InlineData("26200..6899", null)]
|
||||
[InlineData("26200.6899 (beta)", null)]
|
||||
[InlineData("26200\n.6899", null)]
|
||||
[InlineData("1234567890.1234567890.12345678901", null)] // 33 chars, one over the cap
|
||||
[InlineData("", null)]
|
||||
[InlineData(null, null)]
|
||||
public void SanitizeToken_OsBuild(string? input, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, ReportDistressFunction.SanitizeToken(
|
||||
input, ReportDistressFunction.MaxOsBuildLength, ReportDistressFunction.OsBuildPattern));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("in_progress", "in_progress")]
|
||||
[InlineData("completed", "completed")]
|
||||
[InlineData("unknown_7", "unknown_7")]
|
||||
[InlineData("unavailable", "unavailable")]
|
||||
[InlineData("Completed", null)]
|
||||
[InlineData("in progress", null)]
|
||||
[InlineData("in-progress", null)]
|
||||
[InlineData("a_very_long_state_x", null)] // 19 chars > 16
|
||||
[InlineData("", null)]
|
||||
[InlineData(null, null)]
|
||||
public void SanitizeToken_OobeState(string? input, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, ReportDistressFunction.SanitizeToken(
|
||||
input, ReportDistressFunction.MaxOobeStateLength, ReportDistressFunction.OobeStatePattern));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeToken_OsBuildExactlyAtCap_Kept()
|
||||
{
|
||||
var atCap = new string('9', ReportDistressFunction.MaxOsBuildLength);
|
||||
Assert.Equal(atCap, ReportDistressFunction.SanitizeToken(
|
||||
atCap, ReportDistressFunction.MaxOsBuildLength, ReportDistressFunction.OsBuildPattern));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_TpmVersion_StrippedAndCapped()
|
||||
{
|
||||
var value = "IFX\u0001 7.63.3353.0" + new string('x', 60);
|
||||
var clean = ReportDistressFunction.Sanitize(value, ReportDistressFunction.MaxTpmVersionLength);
|
||||
Assert.NotNull(clean);
|
||||
Assert.StartsWith("IFX 7.63.3353.0", clean);
|
||||
Assert.Equal(ReportDistressFunction.MaxTpmVersionLength, clean!.Length);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TPM-PSS bell text
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void TpmPssBellMessage_NamesDeviceAndTheWayOut()
|
||||
{
|
||||
var message = ReportDistressFunction.BuildTpmPssBellMessage("SN-TEST-1", "HP", "EliteBook 840 G5");
|
||||
|
||||
Assert.Equal(
|
||||
"Device SN-TEST-1 (HP EliteBook 840 G5) could not connect because its TPM cannot sign with RSA-PSS. " +
|
||||
"It may connect after setup and a restart; Hardware settings show whether it did. " +
|
||||
"Otherwise update the TPM firmware or replace the device.",
|
||||
message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TpmPssBellMessage_WithoutManufacturerAndModel_NamesSerialOnly()
|
||||
{
|
||||
var message = ReportDistressFunction.BuildTpmPssBellMessage("SN-TEST-2", null, "");
|
||||
|
||||
Assert.StartsWith("Device SN-TEST-2 could not connect because", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+14
@@ -208,6 +208,20 @@ public class EndpointPolicyCatalogCompletenessTests
|
||||
Assert.Equal(TenantScoping.Jwt, entry.TenantScoping);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Both feedback answers raise an operator alert (FeedbackReceived, FeedbackRatingReceived), so both
|
||||
/// admit members only: AuthenticatedUser would let any Entra sign-in reach the alert channels. The
|
||||
/// bubble's eligibility poll stays open to every signed-in user and answers "not eligible" to a
|
||||
/// non-member.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FeedbackAnswers_AreMemberOnly_StatusPollStaysOpen()
|
||||
{
|
||||
Assert.Equal(EndpointPolicy.MemberRead, EndpointAccessPolicyCatalog.FindPolicy("POST", "/api/feedback")?.Policy);
|
||||
Assert.Equal(EndpointPolicy.MemberRead, EndpointAccessPolicyCatalog.FindPolicy("POST", "/api/feedback/general")?.Policy);
|
||||
Assert.Equal(EndpointPolicy.AuthenticatedUser, EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/feedback/status")?.Policy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregistered routes must return null (fail-closed).
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Globalization;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Typed reads of event data: stored values arrive as native types or as the strings the agent
|
||||
/// sends, and no read may depend on the server's culture.
|
||||
/// </summary>
|
||||
public class EventDataReaderTests
|
||||
{
|
||||
private static EnrollmentEvent Evt(Dictionary<string, object> data) => new() { EventId = "e1", EventType = "x", Data = data };
|
||||
|
||||
[Fact]
|
||||
public void Text_is_culture_free_for_numbers_dates_and_bools()
|
||||
{
|
||||
var previous = CultureInfo.CurrentCulture;
|
||||
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE");
|
||||
try
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["minutes"] = 15.5,
|
||||
["at"] = new DateTime(2026, 10, 13, 14, 19, 46, DateTimeKind.Utc),
|
||||
["flag"] = true,
|
||||
};
|
||||
Assert.Equal("15.5", EventDataReader.String(data, "minutes"));
|
||||
Assert.Equal("2026-10-13T14:19:46.0000000Z", EventDataReader.String(data, "at"));
|
||||
Assert.Equal("true", EventDataReader.String(data, "flag"));
|
||||
Assert.Null(EventDataReader.String(data, "absent"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = previous;
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, true)]
|
||||
[InlineData("True", true)]
|
||||
[InlineData(" false ", false)]
|
||||
[InlineData("yes", null)]
|
||||
[InlineData(1L, null)]
|
||||
public void Bool_accepts_a_bool_or_its_string(object raw, bool? expected)
|
||||
=> Assert.Equal(expected, EventDataReader.Bool(Evt(new() { ["v"] = raw }), "v"));
|
||||
|
||||
[Theory]
|
||||
[InlineData(40L, 40L)]
|
||||
[InlineData(40, 40L)]
|
||||
[InlineData(40.0, 40L)]
|
||||
[InlineData(40.5, null)]
|
||||
[InlineData("60", 60L)]
|
||||
[InlineData("0x3c", null)]
|
||||
public void Long_reads_whole_numbers_only(object raw, long? expected)
|
||||
=> Assert.Equal(expected, EventDataReader.Long(new Dictionary<string, object> { ["v"] = raw }, "v"));
|
||||
|
||||
[Fact]
|
||||
public void A_plain_string_is_no_list_and_an_absent_list_stays_null()
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["names"] = new List<object> { " Contoso Notes ", "", "Contoso VPN" },
|
||||
["typed"] = new[] { "a", "b" },
|
||||
["joined"] = "Contoso Notes, Contoso VPN",
|
||||
};
|
||||
Assert.Equal(new[] { "Contoso Notes", "Contoso VPN" }, EventDataReader.StringList(data, "names"));
|
||||
Assert.Equal(new[] { "a", "b" }, EventDataReader.StringList(data, "typed"));
|
||||
Assert.Null(EventDataReader.StringList(data, "joined"));
|
||||
Assert.Null(EventDataReader.StringList(data, "absent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Maps_read_stored_and_typed_objects_and_skip_the_rest()
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["apps"] = new List<object>
|
||||
{
|
||||
new Dictionary<string, object> { ["appName"] = "Contoso Notes" },
|
||||
new Dictionary<string, string> { ["appName"] = "Contoso VPN" },
|
||||
"not an object",
|
||||
},
|
||||
["nested"] = new Dictionary<string, object> { ["state"] = "failed" },
|
||||
};
|
||||
Assert.Equal(new[] { "Contoso Notes", "Contoso VPN" }, EventDataReader.Maps(data, "apps").Select(m => EventDataReader.String(m, "appName")));
|
||||
Assert.Equal("failed", EventDataReader.String(EventDataReader.Map(data, "nested"), "state"));
|
||||
Assert.Empty(EventDataReader.Maps(data, "nested"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="ISelfServiceAssignees"/> for the function tests: records every call, and — paired with a
|
||||
/// <see cref="FakeSignalRNotificationService"/> — how many disconnects had happened when it was called, which is
|
||||
/// the proof that a handler ends the assignment BEFORE it cuts the live streams.
|
||||
/// </summary>
|
||||
public class FakeSelfServiceAssignees : ISelfServiceAssignees
|
||||
{
|
||||
private readonly FakeSignalRNotificationService? _signalR;
|
||||
|
||||
public FakeSelfServiceAssignees(FakeSignalRNotificationService? signalR = null) => _signalR = signalR;
|
||||
|
||||
public List<(string HomeTenantId, string Upn, string Actor, string Reason, int DisconnectsBefore)> Ended { get; } = new();
|
||||
public List<(string HomeTenantId, string Upn, string Actor, string Reason)> Assigned { get; } = new();
|
||||
|
||||
/// <summary>When set, <see cref="EndAssignmentAsync"/> throws it (fail-soft path of the caller).</summary>
|
||||
public Exception? EndThrows { get; set; }
|
||||
|
||||
/// <summary>When set, <see cref="AssignMemberAsync"/> returns it instead of a success.</summary>
|
||||
public DelegationFailure? AssignFails { get; set; }
|
||||
|
||||
public Task<bool> EndAssignmentAsync(string homeTenantId, string upn, string actor, string reason)
|
||||
{
|
||||
Ended.Add((homeTenantId, upn, actor, reason, _signalR?.DisconnectedUsers.Count ?? 0));
|
||||
if (EndThrows != null)
|
||||
throw EndThrows;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<DelegationResult<TenantGroupAssignment>> AssignMemberAsync(string homeTenantId, string upn, string actor, string reason)
|
||||
{
|
||||
Assigned.Add((homeTenantId, upn, actor, reason));
|
||||
return Task.FromResult(AssignFails is { } failure
|
||||
? DelegationResult<TenantGroupAssignment>.Fail(failure.Status, failure.Code, failure.Message)
|
||||
: DelegationResult<TenantGroupAssignment>.Success(new TenantGroupAssignment { Upn = upn, GroupId = homeTenantId }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Functions.Feedback;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The rating bubble's answer route, driven through <c>ProcessSubmitAsync</c> (the HTTP shell only
|
||||
/// reads the body and maps the validation error). Pins that a recorded rating, and nothing else,
|
||||
/// raises <c>FeedbackRatingReceived</c>, the Global Admin bell and the Telegram ping, and that the
|
||||
/// alert carries neither the comment nor the sender.
|
||||
/// </summary>
|
||||
public sealed class FeedbackFunctionSubmitTests
|
||||
{
|
||||
private const string TenantId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE";
|
||||
private const string NormalizedTenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
private const string Upn = "alice@contoso.invalid";
|
||||
private const string DisplayName = "Alice (Contoso)";
|
||||
private const string Comment = "The timeline filter could remember my last choice.";
|
||||
private static readonly DateTime Now = new(2026, 10, 15, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public async Task Rating_IsStored_RaisesTheEvent_RingsTheBell_AndPings()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
var error = await h.Submit(4, " " + Comment + "\n");
|
||||
|
||||
Assert.Null(error);
|
||||
var row = Assert.Single(h.Repo.Written);
|
||||
Assert.True(row.Submitted);
|
||||
Assert.Equal(4, row.Rating);
|
||||
Assert.Equal(Comment, row.Comment);
|
||||
Assert.Equal(Now, row.InteractedAt);
|
||||
|
||||
var ops = Assert.Single(h.OpsEvents);
|
||||
Assert.Equal(OpsEventTypes.FeedbackRatingReceived, ops.EventType);
|
||||
Assert.Equal(OpsEventCategory.Tenant, ops.Category);
|
||||
Assert.Equal(OpsEventSeverity.Info, ops.Severity);
|
||||
Assert.Equal(NormalizedTenantId, ops.TenantId);
|
||||
Assert.Equal(Upn, ops.UserId);
|
||||
Assert.Equal($"Feedback rating 4/5 received from contoso.invalid ({NormalizedTenantId})", ops.Message);
|
||||
|
||||
using var details = JsonDocument.Parse(ops.Details!);
|
||||
Assert.Equal(4, details.RootElement.GetProperty("rating").GetInt32());
|
||||
Assert.Equal(Comment.Length, details.RootElement.GetProperty("commentLength").GetInt32());
|
||||
Assert.Equal("contoso.invalid", details.RootElement.GetProperty("domainName").GetString());
|
||||
Assert.Equal(Constants.PortalUserFeedbackPath, details.RootElement.GetProperty("portalPath").GetString());
|
||||
|
||||
h.Bell.Verify(b => b.CreateNotificationAsync(
|
||||
"feedback", "New Rating", $"{Upn} (contoso.invalid) · 4/5", Constants.PortalUserFeedbackPath), Times.Once);
|
||||
h.Telegram.Verify(t => t.SendFeedbackAsync(TenantId, Upn, DisplayName, 4, Comment), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheAlert_CarriesNeitherCommentNorSender()
|
||||
{
|
||||
// Channels bound to FeedbackRatingReceived (Telegram, Slack, webhooks, e-mail) receive message
|
||||
// and details; the comment is read in the portal. Personal data must never ride along.
|
||||
var h = new Harness();
|
||||
|
||||
await h.Submit(2, Comment);
|
||||
|
||||
var ops = Assert.Single(h.OpsEvents);
|
||||
foreach (var leaked in new[] { Comment, Upn, DisplayName })
|
||||
{
|
||||
Assert.DoesNotContain(leaked, ops.Message);
|
||||
Assert.DoesNotContain(leaked, ops.Details ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dismissal_IsCounted_ButAnnouncesNothing()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
var error = await h.Submit(null, null, dismissed: true);
|
||||
|
||||
Assert.Null(error);
|
||||
var row = Assert.Single(h.Repo.Written);
|
||||
Assert.False(row.Submitted);
|
||||
Assert.Equal(1, row.DismissCount);
|
||||
Assert.Empty(h.OpsEvents);
|
||||
h.AssertNothingAnnounced();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnswerOnARatedRow_ChangesNothing_AndAnnouncesNothing()
|
||||
{
|
||||
// A second tab answering after the rating: the row is final, so nothing is written or pinged twice.
|
||||
var h = new Harness(current: new FeedbackEntry
|
||||
{
|
||||
Upn = Upn, TenantId = TenantId, DisplayName = DisplayName,
|
||||
Rating = 5, Submitted = true, InteractedAt = Now.AddMinutes(-5),
|
||||
});
|
||||
|
||||
var error = await h.Submit(1, "second tab");
|
||||
|
||||
Assert.Null(error);
|
||||
Assert.Empty(h.Repo.Written);
|
||||
Assert.Empty(h.OpsEvents);
|
||||
h.AssertNothingAnnounced();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(6)]
|
||||
[InlineData(null)]
|
||||
public async Task InvalidRating_IsRejected_BeforeAnythingIsWritten(int? rating)
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
var error = await h.Submit(rating, Comment);
|
||||
|
||||
Assert.Equal("Rating must be between 1 and 5", error);
|
||||
Assert.False(h.Repo.UpdateCalled);
|
||||
Assert.Empty(h.OpsEvents);
|
||||
h.AssertNothingAnnounced();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownTenant_KeepsTheGuidLabel_AndAnEmptyCommentCountsZero()
|
||||
{
|
||||
var h = new Harness(configExists: false);
|
||||
|
||||
await h.Submit(5, " ");
|
||||
|
||||
var ops = Assert.Single(h.OpsEvents);
|
||||
Assert.Equal($"Feedback rating 5/5 received from {NormalizedTenantId}", ops.Message);
|
||||
using var details = JsonDocument.Parse(ops.Details!);
|
||||
Assert.Equal(0, details.RootElement.GetProperty("commentLength").GetInt32());
|
||||
h.Bell.Verify(b => b.CreateNotificationAsync(
|
||||
"feedback", "New Rating", $"{Upn} ({NormalizedTenantId}) · 5/5", Constants.PortalUserFeedbackPath), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpsEventStorageOutage_DoesNotFailTheRating()
|
||||
{
|
||||
var h = new Harness(opsEventThrows: true);
|
||||
|
||||
var error = await h.Submit(3, Comment);
|
||||
|
||||
Assert.Null(error);
|
||||
Assert.Single(h.Repo.Written);
|
||||
Assert.Empty(h.OpsEvents);
|
||||
h.Bell.Verify(b => b.CreateNotificationAsync(
|
||||
"feedback", "New Rating", It.IsAny<string>(), Constants.PortalUserFeedbackPath), Times.Once);
|
||||
h.Telegram.Verify(t => t.SendFeedbackAsync(TenantId, Upn, DisplayName, 3, Comment), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OverlongComment_IsCutToTheLimit_AndTheAlertCountsTheStoredLength()
|
||||
{
|
||||
var max = Constants.SubmissionLimits.FeedbackTextMaxChars;
|
||||
var h = new Harness();
|
||||
|
||||
await h.Submit(4, new string('x', max + 50));
|
||||
|
||||
var row = Assert.Single(h.Repo.Written);
|
||||
Assert.Equal(max, row.Comment!.Length);
|
||||
using var details = JsonDocument.Parse(Assert.Single(h.OpsEvents).Details!);
|
||||
Assert.Equal(max, details.RootElement.GetProperty("commentLength").GetInt32());
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public FeedbackFunction Sut { get; }
|
||||
public FakeFeedbackRepository Repo { get; }
|
||||
public List<OpsEventEntry> OpsEvents { get; } = new();
|
||||
public Mock<GlobalNotificationService> Bell { get; }
|
||||
public Mock<TelegramNotificationService> Telegram { get; }
|
||||
|
||||
public Harness(FeedbackEntry? current = null, bool configExists = true, bool opsEventThrows = false)
|
||||
{
|
||||
Repo = new FakeFeedbackRepository(current);
|
||||
var configRepo = Mock.Of<IConfigRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
var tenantConfig = new Mock<TenantConfigurationService>(
|
||||
configRepo, Mock.Of<ILogger<TenantConfigurationService>>(), Mock.Of<IMemoryCache>())
|
||||
{ CallBase = false };
|
||||
var config = TenantConfiguration.CreateDefault(NormalizedTenantId);
|
||||
config.DomainName = "contoso.invalid";
|
||||
tenantConfig.Setup(c => c.TryGetConfigurationAsync(NormalizedTenantId))
|
||||
.ReturnsAsync((config, configExists));
|
||||
|
||||
Bell = new Mock<GlobalNotificationService>(
|
||||
Mock.Of<INotificationRepository>(), new FakeSignalRNotificationService(),
|
||||
Mock.Of<ILogger<GlobalNotificationService>>())
|
||||
{ CallBase = false };
|
||||
Telegram = new Mock<TelegramNotificationService>(
|
||||
new HttpClient(), configRepo, Mock.Of<ILogger<TelegramNotificationService>>())
|
||||
{ CallBase = false };
|
||||
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
configRepo, NullLogger<AdminConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
var tenantAdmins = new Mock<TenantAdminsService>(
|
||||
Mock.Of<IAdminRepository>(), cache, Mock.Of<ILogger<TenantAdminsService>>())
|
||||
{ CallBase = false };
|
||||
|
||||
Sut = new FeedbackFunction(
|
||||
NullLogger<FeedbackFunction>.Instance, tenantConfig.Object, adminConfig.Object, tenantAdmins.Object,
|
||||
Telegram.Object, Mock.Of<ISessionRepository>(), Repo,
|
||||
BuildOpsEventService(adminConfig.Object, opsEventThrows), Bell.Object);
|
||||
}
|
||||
|
||||
public Task<string?> Submit(int? rating, string? comment, bool dismissed = false)
|
||||
=> Sut.ProcessSubmitAsync(TenantId, Upn, DisplayName,
|
||||
new FeedbackRequest { Rating = rating, Comment = comment, Dismissed = dismissed }, Now);
|
||||
|
||||
public void AssertNothingAnnounced()
|
||||
{
|
||||
Bell.Verify(b => b.CreateNotificationAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()), Times.Never);
|
||||
Telegram.Verify(t => t.SendFeedbackAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
/// <summary>A real OpsEventService over a capturing repository, so the actual record method runs.</summary>
|
||||
private OpsEventService BuildOpsEventService(AdminConfigurationService adminConfig, bool opsEventThrows)
|
||||
{
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(e =>
|
||||
{
|
||||
if (opsEventThrows)
|
||||
throw new InvalidOperationException("simulated OpsEvent storage outage");
|
||||
OpsEvents.Add(e);
|
||||
})
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
return new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance,
|
||||
TestNotifications.InertOpsAlertDispatch(adminConfig));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>In-memory feedback repo holding one person's bubble row; only the in-app members are expected.</summary>
|
||||
private sealed class FakeFeedbackRepository : IFeedbackRepository
|
||||
{
|
||||
private FeedbackEntry? _current;
|
||||
|
||||
public FakeFeedbackRepository(FeedbackEntry? current) => _current = current;
|
||||
|
||||
public List<FeedbackEntry> Written { get; } = new();
|
||||
public bool UpdateCalled { get; private set; }
|
||||
|
||||
public Task<FeedbackEntry?> UpdateInAppFeedbackAsync(string upn, Func<FeedbackEntry?, FeedbackEntry?> decide)
|
||||
{
|
||||
UpdateCalled = true;
|
||||
var next = decide(_current);
|
||||
if (next != null)
|
||||
{
|
||||
next.Type = FeedbackEntryType.InApp;
|
||||
_current = next;
|
||||
Written.Add(next);
|
||||
}
|
||||
return Task.FromResult(next);
|
||||
}
|
||||
|
||||
public Task<FeedbackEntry?> GetInAppFeedbackAsync(string upn) => throw new NotSupportedException();
|
||||
public Task<FeedbackEntry?> GetOffboardingFeedbackAsync(string historyRowKey) => throw new NotSupportedException();
|
||||
public Task SaveOffboardingFeedbackAsync(FeedbackEntry entry) => throw new NotSupportedException();
|
||||
public Task SaveGeneralFeedbackAsync(FeedbackEntry entry) => throw new NotSupportedException();
|
||||
public Task<int> CountGeneralFeedbackSinceAsync(string upn, DateTime sinceUtc) => throw new NotSupportedException();
|
||||
public Task<List<FeedbackEntry>> GetAllAsync() => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ public class FreeTextNameQueryParamTests
|
||||
[Theory]
|
||||
[InlineData("GET", "/api/apps/analytics", "apps/analytics", EndpointPolicy.MemberRead, TenantScoping.None)]
|
||||
[InlineData("GET", "/api/apps/sessions", "apps/sessions", EndpointPolicy.MemberRead, TenantScoping.None)]
|
||||
[InlineData("GET", "/api/apps/fleet-context", "apps/fleet-context", EndpointPolicy.MemberRead, TenantScoping.QueryParam)]
|
||||
[InlineData("GET", "/api/global/apps/analytics", "global/apps/analytics", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam)]
|
||||
[InlineData("GET", "/api/global/apps/sessions", "global/apps/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam)]
|
||||
[InlineData("DELETE", "/api/devices/block", "devices/block", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
@@ -97,6 +98,21 @@ public class FreeTextNameQueryParamTests
|
||||
Assert.Equal(1, Json(res).GetProperty("total").GetInt32());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Names))]
|
||||
public async Task App_fleet_context_matches_the_exact_name(string name, string encoded)
|
||||
{
|
||||
var function = new GetAppFleetContextFunction(
|
||||
NullLogger<GetAppFleetContextFunction>.Instance, new AutopilotMonitor.Functions.Services.Apps.AppFleetContextService(MetricsWith(name)));
|
||||
|
||||
var res = await function.Run(EndpointHarness.Request(TenantId, queryString: $"?appName={encoded}&tenantId={TenantId}").Req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
var root = Json(res);
|
||||
Assert.Equal(name, root.GetProperty("appName").GetString());
|
||||
Assert.Equal(1, root.GetProperty("installed").GetInt32());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Names))]
|
||||
public async Task Global_app_analytics_and_sessions_match_the_exact_name(string name, string encoded)
|
||||
@@ -214,7 +230,8 @@ public class FreeTextNameQueryParamTests
|
||||
NullLogger<DeviceBlockFunction>.Instance,
|
||||
new BlockedDeviceService(securityRepo, Mock.Of<ISessionRepository>(), NullLogger<BlockedDeviceService>.Instance),
|
||||
maintenance.Object,
|
||||
opsEvents);
|
||||
opsEvents,
|
||||
new SessionBlockRecorder(Mock.Of<ISessionRepository>(), Mock.Of<AutopilotMonitor.Functions.Services.Analyze.IAnalyzeOnEnrollmentEndProducer>(), NullLogger<SessionBlockRecorder>.Instance));
|
||||
}
|
||||
|
||||
private static JsonElement Json(HttpResponseData res)
|
||||
|
||||
@@ -64,6 +64,21 @@ public class GenericWebhookTests
|
||||
Assert.Equal(WebhookProviderType.GenericJson, new GenericJsonRenderer().ProviderType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenericJsonRenderer_SlaAlert_CarriesTheTenantNameAndTheTenantIdAsFacts()
|
||||
{
|
||||
var alert = NotificationAlertBuilder.BuildSlaResolvedAlert("11111111-1111-1111-1111-111111111111", "Contoso",
|
||||
"Duration", 42, 60, firstBreachAt: null, resolvedAt: new DateTime(2026, 10, 20, 9, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
using var doc = JsonDocument.Parse(new GenericJsonRenderer().RenderToJson(alert));
|
||||
var facts = doc.RootElement.GetProperty("facts");
|
||||
|
||||
Assert.Equal("Tenant", facts[0].GetProperty("name").GetString());
|
||||
Assert.Equal("Contoso", facts[0].GetProperty("value").GetString());
|
||||
Assert.Equal("Tenant ID", facts[1].GetProperty("name").GetString());
|
||||
Assert.Equal("11111111-1111-1111-1111-111111111111", facts[1].GetProperty("value").GetString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, "enrollment_succeeded")]
|
||||
[InlineData(false, "enrollment_failed")]
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Sessions;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// GET sessions/{sessionId}/diagnosis: a malformed id is rejected before any read, an unknown
|
||||
/// session is a 404, and the verdict rests on the strict event read — a storage error is an error,
|
||||
/// never a verdict built from no evidence.
|
||||
/// </summary>
|
||||
public class GetSessionDiagnosisFunctionTests
|
||||
{
|
||||
private const string TenantId = "00000000-0000-4000-8000-000000000002";
|
||||
private const string SessionId = "00000000-0000-4000-8000-000000000001";
|
||||
|
||||
private static (GetSessionDiagnosisFunction Function, Mock<ISessionRepository> Repo) Build()
|
||||
{
|
||||
var repo = new Mock<ISessionRepository>();
|
||||
return (new GetSessionDiagnosisFunction(NullLogger<GetSessionDiagnosisFunction>.Instance, repo.Object), repo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_session_id_that_is_no_guid_is_a_bad_request_without_a_read()
|
||||
{
|
||||
var (function, repo) = Build();
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await function.Run(req, "not-a-guid");
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
repo.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unknown_session_is_not_found_and_its_events_are_never_read()
|
||||
{
|
||||
var (function, repo) = Build();
|
||||
repo.Setup(r => r.GetSessionAsync(TenantId, SessionId)).ReturnsAsync((SessionSummary?)null);
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await function.Run(req, SessionId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
repo.Verify(r => r.GetSessionEventsStrictAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_headline_comes_from_the_row_and_the_strict_event_read()
|
||||
{
|
||||
var (function, repo) = Build();
|
||||
repo.Setup(r => r.GetSessionAsync(TenantId, SessionId)).ReturnsAsync(new SessionSummary
|
||||
{
|
||||
SessionId = SessionId,
|
||||
TenantId = TenantId,
|
||||
Status = SessionStatus.Failed,
|
||||
VerdictPath = VerdictPaths.AgentFailed,
|
||||
FailureReason = "enrollment_failed: esp_terminal_failure (0x80070005)",
|
||||
});
|
||||
repo.Setup(r => r.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(new List<EnrollmentEvent>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EventId = "evt-1", EventType = "enrollment_failed", Sequence = 1, Timestamp = new DateTime(2026, 10, 13, 14, 0, 0, DateTimeKind.Utc),
|
||||
Data = new Dictionary<string, object> { ["reason"] = "esp_terminal_failure", ["category"] = "DeviceSetup", ["failedSubcategory"] = "Certificates", ["errorCode"] = "0x80070005" },
|
||||
},
|
||||
});
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await function.Run(req, SessionId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
response.Body.Position = 0;
|
||||
using var doc = JsonDocument.Parse(response.Body);
|
||||
var headline = doc.RootElement.GetProperty("headline");
|
||||
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
|
||||
Assert.Equal(SessionId, doc.RootElement.GetProperty("sessionId").GetString());
|
||||
Assert.Equal(DiagnosisOutcomes.Failed, headline.GetProperty("outcome").GetString());
|
||||
Assert.Equal(DiagnosisCauses.EspStepFailure, headline.GetProperty("cause").GetString());
|
||||
Assert.Equal("Certificates", headline.GetProperty("failedStep").GetProperty("subcategory").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_storage_error_on_the_events_is_a_server_error()
|
||||
{
|
||||
var (function, repo) = Build();
|
||||
repo.Setup(r => r.GetSessionAsync(TenantId, SessionId)).ReturnsAsync(new SessionSummary
|
||||
{
|
||||
SessionId = SessionId, TenantId = TenantId, Status = SessionStatus.Failed, VerdictPath = VerdictPaths.AgentFailed, FailureReason = "x",
|
||||
});
|
||||
repo.Setup(r => r.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ThrowsAsync(new InvalidOperationException("storage unavailable"));
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await function.Run(req, SessionId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -51,9 +51,9 @@ public class GetTpmPssUnsupportedFunctionTests
|
||||
{
|
||||
var reports = new List<DistressReportEntry>
|
||||
{
|
||||
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "S4SQ8685", T0),
|
||||
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "s4sq8685", T0.AddMinutes(1)),
|
||||
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", " S4SQ8685 ", T0.AddMinutes(2)),
|
||||
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "TPMCASE01", T0),
|
||||
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "tpmcase01", T0.AddMinutes(1)),
|
||||
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", " TPMCASE01 ", T0.AddMinutes(2)),
|
||||
};
|
||||
|
||||
var (aggregated, totalRawReports) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
|
||||
@@ -164,6 +164,137 @@ public class GetTpmPssUnsupportedFunctionTests
|
||||
Assert.Equal(3, (int)item.AttemptCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Device context from the reports
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void BuildAggregatedResult_DeviceContext_FromNewestReportCarryingIt()
|
||||
{
|
||||
var older = MakeEntry("TpmPssUnsupported", "HP", "EliteBook", "SN1", T0);
|
||||
older.TpmVersion = "IFX 7.63.3353.0";
|
||||
older.OsBuild = "26100.1000";
|
||||
older.OobeState = "in_progress";
|
||||
var newer = MakeEntry("TpmPssUnsupported", "HP", "EliteBook", "SN1", T0.AddHours(1));
|
||||
newer.OsBuild = "26200.2000";
|
||||
newer.OobeState = "completed";
|
||||
// Newest report from an agent that predates the fields: carries none of them.
|
||||
var newestLegacy = MakeEntry("TpmPssUnsupported", "HP", "EliteBook", "SN1", T0.AddHours(2));
|
||||
|
||||
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult([older, newestLegacy, newer]);
|
||||
|
||||
var item = Assert.Single(aggregated);
|
||||
Assert.Equal("IFX 7.63.3353.0", item.TpmVersion);
|
||||
Assert.Equal("26200.2000", item.OsBuild);
|
||||
Assert.Equal("completed", item.OobeState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAggregatedResult_LegacyReportsOnly_LeaveDeviceContextNull()
|
||||
{
|
||||
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(
|
||||
[MakeEntry("TpmPssUnsupported", "HP", "EliteBook", "SN1", T0)]);
|
||||
|
||||
var item = Assert.Single(aggregated);
|
||||
Assert.Null(item.TpmVersion);
|
||||
Assert.Null(item.OsBuild);
|
||||
Assert.Null(item.OobeState);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ConnectedAt — a certificate-authenticated upload after the last report
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void BuildAggregatedResult_UploadAfterLastReport_SetsConnectedAt()
|
||||
{
|
||||
var reports = new List<DistressReportEntry>
|
||||
{
|
||||
MakeEntry("TpmPssUnsupported", "HP", "EliteBook", "SN1", T0),
|
||||
MakeEntry("TpmPssUnsupported", "HP", "EliteBook", "SN1", T0.AddHours(1)),
|
||||
};
|
||||
var lastIngest = new Dictionary<string, DateTime> { ["sn1"] = T0.AddHours(2) };
|
||||
|
||||
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports, lastIngest);
|
||||
|
||||
Assert.Equal(T0.AddHours(2), Assert.Single(aggregated).ConnectedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAggregatedResult_UploadBeforeLastReport_LeavesDeviceUnresolved()
|
||||
{
|
||||
// The device connected once, then failed again: the newer report wins.
|
||||
var reports = new List<DistressReportEntry>
|
||||
{
|
||||
MakeEntry("TpmPssUnsupported", "HP", "EliteBook", "SN1", T0),
|
||||
MakeEntry("TpmPssUnsupported", "HP", "EliteBook", "SN1", T0.AddHours(3)),
|
||||
};
|
||||
var lastIngest = new Dictionary<string, DateTime> { ["sn1"] = T0.AddHours(2) };
|
||||
|
||||
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports, lastIngest);
|
||||
|
||||
Assert.Null(Assert.Single(aggregated).ConnectedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAggregatedResult_UploadAtLastReportTime_LeavesDeviceUnresolved()
|
||||
{
|
||||
var reports = new List<DistressReportEntry> { MakeEntry("TpmPssUnsupported", "HP", "EliteBook", "SN1", T0) };
|
||||
var lastIngest = new Dictionary<string, DateTime> { ["sn1"] = T0 };
|
||||
|
||||
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports, lastIngest);
|
||||
|
||||
Assert.Null(Assert.Single(aggregated).ConnectedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAggregatedResult_SerialCaseAndWhitespace_StillMatchesLookupKey()
|
||||
{
|
||||
var reports = new List<DistressReportEntry> { MakeEntry("TpmPssUnsupported", "HP", "EliteBook", " SnMixed1 ", T0) };
|
||||
var lastIngest = new Dictionary<string, DateTime> { ["snmixed1"] = T0.AddMinutes(30) };
|
||||
|
||||
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports, lastIngest);
|
||||
|
||||
Assert.Equal(T0.AddMinutes(30), Assert.Single(aggregated).ConnectedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAggregatedResult_NoLookup_LeavesEveryDeviceUnresolved()
|
||||
{
|
||||
var reports = new List<DistressReportEntry>
|
||||
{
|
||||
MakeEntry("TpmPssUnsupported", "HP", "EliteBook", "SN1", T0),
|
||||
MakeEntry("TpmPssUnsupported", "HP", "EliteBook", "SN2", T0),
|
||||
};
|
||||
|
||||
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports, lastCertificateIngestBySerial: null);
|
||||
|
||||
Assert.All(aggregated, item => Assert.Null(item.ConnectedAt));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAggregatedResult_OtherSerialConnected_DoesNotResolveThisOne()
|
||||
{
|
||||
var reports = new List<DistressReportEntry> { MakeEntry("TpmPssUnsupported", "HP", "EliteBook", "SN1", T0) };
|
||||
var lastIngest = new Dictionary<string, DateTime> { ["sn2"] = T0.AddHours(1) };
|
||||
|
||||
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports, lastIngest);
|
||||
|
||||
Assert.Null(Assert.Single(aggregated).ConnectedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAggregatedResult_UnknownSerialBucket_NeverConnected()
|
||||
{
|
||||
// A lookup key "" must never resolve the bucket of reports without a serial.
|
||||
var reports = new List<DistressReportEntry> { MakeEntry("TpmPssUnsupported", "HP", "EliteBook", null, T0) };
|
||||
var lastIngest = new Dictionary<string, DateTime> { [""] = T0.AddHours(1) };
|
||||
|
||||
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports, lastIngest);
|
||||
|
||||
Assert.Null(Assert.Single(aggregated).ConnectedAt);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user