mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41b33895c5 | |||
| 157273726d | |||
| 0d994773d8 | |||
| 7dd9f81c31 | |||
| 7d07cb7805 | |||
| 485a5b4604 | |||
| 2109133aee | |||
| 7ac7a5e8ba | |||
| 014b3e1e83 | |||
| b948d32c9a | |||
| 40d3922fe0 | |||
| 37bd4c73f5 | |||
| 9f4c1efe26 | |||
| 13a34fb7f1 | |||
| 60a41e9778 | |||
| 9aa5e46e22 | |||
| 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...c142d824e3
@@ -4,15 +4,16 @@
|
||||
|
||||
.DESCRIPTION
|
||||
CI's member of the URL registry (Shared Constants.cs, web lib/config.ts, MCP src/config.ts):
|
||||
every own host the workflows, the .github/scripts helpers and the local release build talk to,
|
||||
plus the Authenticode timestamp server, is defined here once. Assert-HostsFromRegistry.ps1
|
||||
(Workflow gates) fails on any of these hosts spelled out elsewhere under .github/.
|
||||
every own host the workflows and the .github/scripts helpers talk to, plus the Authenticode
|
||||
timestamp server, is defined here once. Assert-HostsFromRegistry.ps1 (Workflow gates) fails
|
||||
on any of these hosts spelled out elsewhere under .github/.
|
||||
|
||||
The storage URLs derive from the account name, so blob and table can never point at two
|
||||
different accounts.
|
||||
|
||||
A file in the repository rather than a GitHub variable: the local release build reads it too,
|
||||
and bearer tokens are sent to these URLs -- where a token goes stays a reviewed line in git.
|
||||
A file in the repository rather than a GitHub variable: the helpers read it themselves, so
|
||||
they behave the same in every job and in a local run, and bearer tokens are sent to these
|
||||
URLs -- where a token goes stays a reviewed line in git.
|
||||
|
||||
.PARAMETER ExportToGitHubEnv
|
||||
Write every entry to $GITHUB_ENV instead of returning it, so the following steps of the job
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
live here so they cannot drift between call sites.
|
||||
|
||||
Identity comes from whatever `az login` established: the federated GitHub OIDC principal
|
||||
in CI (azure/login, subject repo:okieselbach/AutopilotMonitor:ref:refs/heads/main), the
|
||||
operator's own account for the local build. No secret is read, stored or passed around.
|
||||
in CI (azure/login in a job of the `production` or `release` environment), the operator's
|
||||
own account when a helper runs locally. No secret is read, stored or passed around.
|
||||
Under GitHub Actions the token is masked in the run log before it is returned.
|
||||
|
||||
x-ms-version is MANDATORY here, not decoration: Entra-authorized requests are rejected
|
||||
|
||||
@@ -258,12 +258,14 @@ jobs:
|
||||
# run here -- never a restore or build. The build output arrives as data. The runner
|
||||
# image's Windows SDK signtool is used read-only, to verify third-party signatures.
|
||||
#
|
||||
# No `environment:` yet: the code-signing credential is still bound to the ref, and a job
|
||||
# carries exactly one OIDC subject, so an environment here would break the signing login.
|
||||
# Runs in the `release` environment: both identities trust its subject, and it admits main
|
||||
# only. No federated credential trusts the ref, so a job without an environment can neither
|
||||
# sign nor write storage, whatever its permissions say.
|
||||
release:
|
||||
name: Sign and publish
|
||||
needs: [reserve, build]
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
permissions:
|
||||
contents: write # tag + GitHub Release
|
||||
id-token: write # code-signing and storage logins, keyless Sigstore signing
|
||||
@@ -615,8 +617,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 +631,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 +659,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
|
||||
|
||||
@@ -39,11 +39,11 @@ jobs:
|
||||
# need the embedded loader to carry a signature of its own.
|
||||
#
|
||||
# Trust zone: only this repository's scripts, pinned actions and the pinned signing tool run
|
||||
# here. No `environment:` yet: the code-signing credential is still bound to the ref, and a
|
||||
# job carries exactly one OIDC subject.
|
||||
# here. The `release` environment is the only subject the code-signing identity trusts.
|
||||
sign-loader:
|
||||
name: Sign bootstrap loader
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # code-signing login
|
||||
@@ -198,12 +198,13 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Trust zone: signs, verifies and publishes the MSI as data; never builds. No
|
||||
# `environment:` yet, for the same reason as sign-loader.
|
||||
# Trust zone: signs, verifies and publishes the MSI as data; never builds. Environment
|
||||
# `release`, like sign-loader.
|
||||
release:
|
||||
name: Sign and publish MSI
|
||||
needs: build
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # code-signing and storage logins, keyless Sigstore signing
|
||||
|
||||
@@ -54,6 +54,8 @@ jobs:
|
||||
name: Publish to the download alias
|
||||
needs: gates
|
||||
runs-on: windows-latest
|
||||
# Both identities trust only the `release` environment, which admits main only.
|
||||
environment: release
|
||||
# Job-scoped: only the publish job federates to Azure, the gates job never holds an
|
||||
# OIDC token it does not need.
|
||||
permissions:
|
||||
@@ -74,9 +76,7 @@ jobs:
|
||||
}
|
||||
|
||||
# First, and only to fetch the Key Vault token: azure/login sets the default az
|
||||
# account and the storage login below has to be the one that stays. See D-223 --
|
||||
# both federated credentials are bound to the ref, so this job must never carry a
|
||||
# GitHub `environment:`.
|
||||
# account and the storage login below has to be the one that stays.
|
||||
- name: Login to Azure (code-signing identity)
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
|
||||
+1
-1
Submodule internal updated: c9a13d6b47...deed41e4f6
@@ -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,8 @@ 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 DistressDeviceContext? Context { get; set; }
|
||||
}
|
||||
|
||||
public List<Call> Calls { get; } = new List<Call>();
|
||||
@@ -32,9 +34,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, DistressDetail? detail = null)
|
||||
{
|
||||
Calls.Add(new Call { ErrorType = errorType, Message = message, HttpStatusCode = httpStatusCode });
|
||||
Calls.Add(new Call { ErrorType = errorType, Message = message, HttpStatusCode = httpStatusCode, TpmVersion = detail?.TpmVersion, Context = detail?.Context });
|
||||
return Send?.Invoke() ?? Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
{
|
||||
/// <summary>
|
||||
/// The ESP phase a report names, derived from the three ESP registry values. Both value shapes
|
||||
/// Windows writes are covered: flat status texts (older builds) and nested subcategory objects.
|
||||
/// </summary>
|
||||
public sealed class EspPhaseSnapshotTests
|
||||
{
|
||||
private const string DoneFlag = "{\"categorySucceeded\":true}";
|
||||
private const string FailedFlag = "{\"categorySucceeded\":false}";
|
||||
private const string NestedRunning =
|
||||
"{\"SecurityPoliciesSubcategory\":{\"subcategoryState\":\"succeeded\",\"subcategoryStatusText\":\"Complete\"}," +
|
||||
"\"AppsSubcategory\":{\"subcategoryState\":\"inProgress\",\"subcategoryStatusText\":\"Installing (1 of 3)\"}}";
|
||||
private const string NestedAllDoneNoFlag =
|
||||
"{\"SecurityPoliciesSubcategory\":{\"subcategoryState\":\"succeeded\"},\"AppsSubcategory\":{\"subcategoryState\":\"notRequired\"}}";
|
||||
private const string NestedFailedNoFlag =
|
||||
"{\"SecurityPoliciesSubcategory\":{\"subcategoryState\":\"succeeded\"},\"AppsSubcategory\":{\"subcategoryState\":\"failed\"}}";
|
||||
private const string FlatRunning = "{\"CertificatesSubcategory\":\"Certificates (0 of 1 applied)\",\"AppsSubcategory\":\"Identifying\"}";
|
||||
|
||||
private static string Derive(string? preparation, string? deviceSetup, string? accountSetup)
|
||||
=> EspPhaseSnapshot.Derive(new List<string> { preparation!, deviceSetup!, accountSetup! });
|
||||
|
||||
[Fact]
|
||||
public void No_value_is_none()
|
||||
{
|
||||
Assert.Equal("none", Derive(null, null, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Running_category_names_its_phase()
|
||||
{
|
||||
Assert.Equal("device_preparation", Derive(NestedRunning, null, null));
|
||||
Assert.Equal("device_setup", Derive(DoneFlag, NestedRunning, null));
|
||||
Assert.Equal("account_setup", Derive(DoneFlag, DoneFlag, NestedRunning));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flat_status_texts_are_read_too()
|
||||
{
|
||||
Assert.Equal("device_setup", Derive(DoneFlag, FlatRunning, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Done_categories_name_the_last_one_reached()
|
||||
{
|
||||
Assert.Equal("device_preparation_done", Derive(DoneFlag, null, null));
|
||||
Assert.Equal("device_setup_done", Derive(DoneFlag, DoneFlag, null));
|
||||
Assert.Equal("esp_done", Derive(DoneFlag, DoneFlag, DoneFlag));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void All_subcategories_done_counts_as_done_without_the_flag()
|
||||
{
|
||||
// Some builds never set categorySucceeded although every subcategory finished.
|
||||
Assert.Equal("esp_done", Derive(DoneFlag, NestedAllDoneNoFlag, NestedAllDoneNoFlag));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_failure_wins_over_everything_after_it()
|
||||
{
|
||||
Assert.Equal("device_setup_failed", Derive(DoneFlag, FailedFlag, NestedRunning));
|
||||
Assert.Equal("account_setup_failed", Derive(DoneFlag, DoneFlag, NestedFailedNoFlag));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unparseable_value_counts_as_running()
|
||||
{
|
||||
Assert.Equal("device_setup", Derive(DoneFlag, "{not json", null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_on_this_machine_returns_a_known_token()
|
||||
{
|
||||
var phase = EspPhaseSnapshot.Read();
|
||||
Assert.Matches("^[a-z_]+$", phase);
|
||||
}
|
||||
}
|
||||
}
|
||||
+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()
|
||||
{
|
||||
|
||||
+64
@@ -164,6 +164,70 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Assert.Null(persistence.LoadRegistrationFailure());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Success_after_a_failed_start_tells_reboot_certificate_and_where_the_failure_began()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var persistence = new SessionIdPersistence(tmp.Path);
|
||||
var at = new DateTime(2026, 10, 21, 7, 0, 0, DateTimeKind.Utc);
|
||||
var failedBoot = at.AddMinutes(-16);
|
||||
|
||||
BackendSessionRegistration.RecordFailedRun(
|
||||
persistence, SessionRegistrationResult.Failed("An error occurred while sending the request."),
|
||||
at, at.AddSeconds(90), true, true, "Succeeded", "fp-a", NewLogger(tmp.Path),
|
||||
new AutopilotMonitor.Agent.V2.Core.Monitoring.Transport.DistressDeviceContext("completed", "account_setup", failedBoot),
|
||||
certThumbprint: "6B98620E8BE952FEA7CD9200B899A9A8CCADD1E5");
|
||||
|
||||
// The record carries phase and OOBE flag; boot and thumbprint stay beside it.
|
||||
var summary = persistence.LoadRegistrationFailure()!;
|
||||
Assert.Equal("account_setup", summary.EspPhase);
|
||||
Assert.Equal("completed", summary.OobeState);
|
||||
|
||||
var prior = BackendSessionRegistration.ClearAfterSuccess(
|
||||
persistence, () => "fp-a", NewLogger(tmp.Path),
|
||||
currentBootUtc: at.AddHours(1), currentCertThumbprint: "6b98620e8be952fea7cd9200b899a9a8ccadd1e5");
|
||||
|
||||
Assert.NotNull(prior);
|
||||
Assert.Equal("account_setup", prior!.LastEspPhase);
|
||||
Assert.Equal("completed", prior.LastOobeState);
|
||||
Assert.True(prior.RebootedSince);
|
||||
Assert.False(prior.CertChanged);
|
||||
Assert.False(prior.NetworkChanged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Same_boot_and_unknown_sides_are_reported_as_such()
|
||||
{
|
||||
var boot = new DateTime(2026, 10, 21, 6, 40, 0, DateTimeKind.Utc);
|
||||
Assert.False(BackendSessionRegistration.RebootedSince(boot, boot.AddSeconds(30)));
|
||||
Assert.True(BackendSessionRegistration.RebootedSince(boot, boot.AddMinutes(5)));
|
||||
Assert.Null(BackendSessionRegistration.RebootedSince(null, boot));
|
||||
Assert.Null(BackendSessionRegistration.RebootedSince(boot, null));
|
||||
Assert.True(BackendSessionRegistration.CertificateChanged("AA", "BB"));
|
||||
Assert.Null(BackendSessionRegistration.CertificateChanged(null, "BB"));
|
||||
Assert.Null(BackendSessionRegistration.CertificateChanged("AA", ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Record_from_an_older_agent_leaves_the_new_comparisons_unknown()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var persistence = new SessionIdPersistence(tmp.Path);
|
||||
var at = new DateTime(2026, 10, 21, 7, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// Written without context, boot or thumbprint — the shape agents up to 2.0.1487 leave.
|
||||
BackendSessionRegistration.RecordFailedRun(
|
||||
persistence, SessionRegistrationResult.Failed("x"), at, at.AddSeconds(5), true, true, null, null, NewLogger(tmp.Path));
|
||||
|
||||
var prior = BackendSessionRegistration.ClearAfterSuccess(
|
||||
persistence, () => null, NewLogger(tmp.Path), at.AddHours(1), "AA");
|
||||
|
||||
Assert.NotNull(prior);
|
||||
Assert.Null(prior!.LastEspPhase);
|
||||
Assert.Null(prior.RebootedSince);
|
||||
Assert.Null(prior.CertChanged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recording_without_persistence_is_a_no_op()
|
||||
{
|
||||
|
||||
@@ -3,6 +3,8 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
@@ -16,14 +18,16 @@ using Xunit;
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Regression gate for the "soft admin-action" semantics change: backend
|
||||
/// <c>AdminAction=Succeeded</c> is purely informational (timeline entry only, agent keeps
|
||||
/// running its own path) while <c>AdminAction=Failed</c> drives the agent's own soft-shutdown
|
||||
/// pipeline via the injected <c>onAdminFailed</c> callback — without forcing self-destruct.
|
||||
/// Previously both were routed through the full <c>ServerActionDispatcher</c> +
|
||||
/// <c>terminationHandler.Handle</c> path, which (a) hijacked the agent's own completion flow
|
||||
/// for Succeeded and (b) crashed with <c>InvalidOperationException: SignalIngress has been
|
||||
/// stopped</c> when a terminal-drain response arrived after the ingress was torn down.
|
||||
/// Regression gate for the soft admin-action semantics: backend <c>AdminAction=Succeeded</c>
|
||||
/// and <c>AdminAction=Failed</c> (portal Mark-Succeeded / Mark-Failed) both drive the agent's
|
||||
/// own soft-shutdown pipeline via the injected <c>onAdminTerminal</c> callback with that
|
||||
/// outcome — without forcing self-destruct — and only the first AdminAction of a process is
|
||||
/// acted on. The backend repeats AdminAction on every telemetry response; acting on each echo
|
||||
/// re-emitted <c>admin_marked_session</c> with an immediate upload whose response carried the
|
||||
/// next echo. Earlier, both outcomes were routed through the full
|
||||
/// <c>ServerActionDispatcher</c> path, which crashed with
|
||||
/// <c>InvalidOperationException: SignalIngress has been stopped</c> when a terminal-drain
|
||||
/// response arrived after the ingress was torn down.
|
||||
/// </summary>
|
||||
public sealed class HandleAdminActionTests
|
||||
{
|
||||
@@ -34,13 +38,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
ApiBaseUrl = "http://localhost",
|
||||
};
|
||||
|
||||
private static global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.AdminActionLatch NewLatch() =>
|
||||
new global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.AdminActionLatch();
|
||||
|
||||
private sealed class Rig : IDisposable
|
||||
{
|
||||
public TempDirectory Tmp { get; } = new TempDirectory();
|
||||
public AgentLogger Logger { get; }
|
||||
public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink();
|
||||
public InformationalEventPost Post { get; }
|
||||
public List<EnrollmentTerminatedEventArgs> OnAdminFailedCalls { get; } = new List<EnrollmentTerminatedEventArgs>();
|
||||
public List<EnrollmentTerminatedEventArgs> OnAdminTerminalCalls { get; } = new List<EnrollmentTerminatedEventArgs>();
|
||||
|
||||
public Rig()
|
||||
{
|
||||
@@ -48,7 +55,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Post = new InformationalEventPost(Ingress, new VirtualClock(new DateTime(2026, 4, 23, 10, 0, 0, DateTimeKind.Utc)));
|
||||
}
|
||||
|
||||
public Action<EnrollmentTerminatedEventArgs> OnAdminFailed => args => OnAdminFailedCalls.Add(args);
|
||||
public Action<EnrollmentTerminatedEventArgs> OnAdminTerminal => args => OnAdminTerminalCalls.Add(args);
|
||||
|
||||
public IEnumerable<FakeSignalIngressSink.PostedSignal> EventsOfType(string eventType) =>
|
||||
Ingress.Posted.Where(p =>
|
||||
@@ -61,15 +68,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Succeeded_emits_admin_marked_session_as_Info_and_does_not_invoke_onAdminFailed()
|
||||
public void Succeeded_emits_admin_marked_session_as_Info_and_terminates_with_Succeeded()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "Succeeded",
|
||||
stageNameAccessor: () => "Completed",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => "EspAccountSetup",
|
||||
post: rig.Post,
|
||||
onAdminFailed: rig.OnAdminFailed,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
|
||||
@@ -78,24 +86,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Assert.True(emitted[0].Payload!.TryGetValue(SignalPayloadKeys.Severity, out var sev));
|
||||
Assert.Equal(EventSeverity.Info.ToString(), sev);
|
||||
|
||||
// Informational only — agent keeps running its own path.
|
||||
Assert.Empty(rig.OnAdminFailedCalls);
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
var args = rig.OnAdminTerminalCalls[0];
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Succeeded, args.Outcome);
|
||||
Assert.Equal(EnrollmentTerminationReason.DecisionTerminalStage, args.Reason);
|
||||
Assert.Equal("EspAccountSetup", args.StageName);
|
||||
Assert.Contains("Admin marked session as Succeeded", args.Details);
|
||||
|
||||
// No server_action_* noise — Succeeded isn't a ServerAction.
|
||||
// No server_action_* noise — an admin mark isn't a ServerAction.
|
||||
Assert.Empty(rig.EventsOfType("server_action_received"));
|
||||
Assert.Empty(rig.EventsOfType("server_action_executed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_emits_admin_marked_session_as_Warning_and_invokes_onAdminFailed_once()
|
||||
public void Failed_emits_admin_marked_session_as_Warning_and_terminates_with_Failed()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "Failed",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => "DeviceSetup",
|
||||
post: rig.Post,
|
||||
onAdminFailed: rig.OnAdminFailed,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
|
||||
@@ -104,8 +117,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Assert.True(emitted[0].Payload!.TryGetValue(SignalPayloadKeys.Severity, out var sev));
|
||||
Assert.Equal(EventSeverity.Warning.ToString(), sev);
|
||||
|
||||
Assert.Single(rig.OnAdminFailedCalls);
|
||||
var args = rig.OnAdminFailedCalls[0];
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
var args = rig.OnAdminTerminalCalls[0];
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Failed, args.Outcome);
|
||||
Assert.Equal(EnrollmentTerminationReason.DecisionTerminalStage, args.Reason);
|
||||
Assert.Equal("DeviceSetup", args.StageName);
|
||||
@@ -119,13 +132,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "succeeded",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => null,
|
||||
post: rig.Post,
|
||||
onAdminFailed: rig.OnAdminFailed,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
|
||||
Assert.Empty(rig.OnAdminFailedCalls);
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Succeeded, rig.OnAdminTerminalCalls[0].Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -137,28 +152,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "Quarantined",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => null,
|
||||
post: rig.Post,
|
||||
onAdminFailed: rig.OnAdminFailed,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
|
||||
Assert.Single(rig.OnAdminFailedCalls);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Failed, rig.OnAdminFailedCalls[0].Outcome);
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Failed, rig.OnAdminTerminalCalls[0].Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_with_null_onAdminFailed_does_not_throw()
|
||||
public void Null_onAdminTerminal_does_not_throw()
|
||||
{
|
||||
// Defensive path: onAdminFailed can be null if the terminate handler wasn't wired yet.
|
||||
// Defensive path: onAdminTerminal can be null if the terminate handler wasn't wired yet.
|
||||
using var rig = new Rig();
|
||||
|
||||
var ex = Record.Exception(() =>
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "Failed",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => null,
|
||||
post: rig.Post,
|
||||
onAdminFailed: null!,
|
||||
onAdminTerminal: null!,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger));
|
||||
|
||||
@@ -176,14 +193,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "Failed",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => throw new InvalidOperationException("not started"),
|
||||
post: rig.Post,
|
||||
onAdminFailed: rig.OnAdminFailed,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
|
||||
Assert.Single(rig.OnAdminFailedCalls);
|
||||
Assert.Null(rig.OnAdminFailedCalls[0].StageName);
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
Assert.Null(rig.OnAdminTerminalCalls[0].StageName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -202,18 +220,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
var ex = Record.Exception(() =>
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "Succeeded",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => null,
|
||||
post: stoppedPost,
|
||||
onAdminFailed: rig.OnAdminFailed,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger));
|
||||
|
||||
Assert.Null(ex);
|
||||
Assert.Empty(rig.OnAdminFailedCalls);
|
||||
|
||||
// A lost timeline entry never cancels the admin's decision.
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Succeeded, rig.OnAdminTerminalCalls[0].Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_emit_race_still_invokes_onAdminFailed()
|
||||
public void Failed_emit_race_still_terminates()
|
||||
{
|
||||
// If the timeline emit fails due to a stopped ingress, the agent's own soft-shutdown
|
||||
// must still run — the admin's decision to Mark-Failed outranks a lost timeline entry.
|
||||
@@ -225,14 +247,85 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "Failed",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => "DeviceSetup",
|
||||
post: stoppedPost,
|
||||
onAdminFailed: rig.OnAdminFailed,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
|
||||
Assert.Single(rig.OnAdminFailedCalls);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Failed, rig.OnAdminFailedCalls[0].Outcome);
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Failed, rig.OnAdminTerminalCalls[0].Outcome);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Succeeded")]
|
||||
[InlineData("Failed")]
|
||||
public void Repeated_echo_is_handled_once(string adminAction)
|
||||
{
|
||||
// The backend repeats AdminAction on every telemetry response. Each echo handled anew
|
||||
// emitted another immediate-upload timeline entry whose response carried the next echo.
|
||||
using var rig = new Rig();
|
||||
var latch = NewLatch();
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: adminAction,
|
||||
latch: latch,
|
||||
stageNameAccessor: () => "EspAccountSetup",
|
||||
post: rig.Post,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
}
|
||||
|
||||
Assert.Single(rig.EventsOfType("admin_marked_session"));
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void First_admin_action_of_the_process_wins()
|
||||
{
|
||||
// The first mark already started the shutdown with its outcome; a later change of
|
||||
// mind in the portal only updates the session row on the backend.
|
||||
using var rig = new Rig();
|
||||
var latch = NewLatch();
|
||||
|
||||
foreach (var adminAction in new[] { "Succeeded", "Failed" })
|
||||
{
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: adminAction,
|
||||
latch: latch,
|
||||
stageNameAccessor: () => null,
|
||||
post: rig.Post,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
}
|
||||
|
||||
var emitted = rig.EventsOfType("admin_marked_session").ToList();
|
||||
Assert.Single(emitted);
|
||||
Assert.True(emitted[0].Payload!.TryGetValue(SignalPayloadKeys.Severity, out var sev));
|
||||
Assert.Equal(EventSeverity.Info.ToString(), sev);
|
||||
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Succeeded, rig.OnAdminTerminalCalls[0].Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Latch_admits_exactly_one_of_concurrent_callers()
|
||||
{
|
||||
// Responses are raised on whichever thread ran the drain, so two can overlap.
|
||||
var latch = NewLatch();
|
||||
var admitted = 0;
|
||||
|
||||
Parallel.For(0, 64, _ =>
|
||||
{
|
||||
if (latch.TryEnter()) Interlocked.Increment(ref admitted);
|
||||
});
|
||||
|
||||
Assert.Equal(1, admitted);
|
||||
}
|
||||
|
||||
private sealed class ThrowingSink : ISignalIngressSink
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
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.Monitoring.Transport;
|
||||
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, string TlsProbe)>> Context(
|
||||
string tpm, string schannel, string tlsProbe = null!) =>
|
||||
() => Task.FromResult((tpm, schannel, tlsProbe));
|
||||
|
||||
[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", "tls12,-,cc403"));
|
||||
|
||||
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 tlsp=tls12,-,cc403 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.Contains("sch=unreadable", 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, tlsProbe) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
|
||||
() => { fastReturned.Set(); return "IFX 7.63.3353.0"; },
|
||||
() => { slowRelease.Wait(TimeSpan.FromSeconds(30)); return "late"; },
|
||||
() => { slowRelease.Wait(TimeSpan.FromSeconds(30)); return "tls12,-,cc403"; },
|
||||
bound,
|
||||
NewLogger(tmp));
|
||||
|
||||
Assert.Equal("IFX 7.63.3353.0", tpm);
|
||||
Assert.Equal(BackendSessionRegistration.SchannelReadTimeout, schannel);
|
||||
Assert.Equal(BackendSessionRegistration.SchannelReadTimeout, tlsProbe);
|
||||
}
|
||||
finally
|
||||
{
|
||||
slowRelease.Set();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Context_read_that_throws_counts_as_missing()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
|
||||
var (tpm, schannel, tlsProbe) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
|
||||
() => throw new InvalidOperationException("no WMI"),
|
||||
() => "36887:116",
|
||||
() => "tls13,-,cc403",
|
||||
Task.Delay(TimeSpan.FromSeconds(30)),
|
||||
NewLogger(tmp));
|
||||
|
||||
Assert.Null(tpm);
|
||||
Assert.Equal("36887:116", schannel);
|
||||
Assert.Equal("tls13,-,cc403", tlsProbe);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Schannel_read_that_throws_is_reported_as_unreadable_not_as_empty()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
|
||||
var (_, schannel, _) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
|
||||
() => "IFX 7.63.3353.0",
|
||||
() => throw new UnauthorizedAccessException("System log"),
|
||||
() => "tls12,-,cc403",
|
||||
Task.Delay(TimeSpan.FromSeconds(30)),
|
||||
NewLogger(tmp));
|
||||
|
||||
Assert.Equal(SchannelEventReader.Unreadable, schannel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tls_probe_that_throws_is_reported_as_error_not_left_out()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
|
||||
var (_, _, tlsProbe) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
|
||||
() => "IFX 7.63.3353.0",
|
||||
() => "none",
|
||||
() => throw new InvalidOperationException("socket"),
|
||||
Task.Delay(TimeSpan.FromSeconds(30)),
|
||||
NewLogger(tmp));
|
||||
|
||||
Assert.Equal("error", tlsProbe);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Report_carries_the_context_the_run_started_in()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var reporter = new FakeDistressReporter();
|
||||
var startContext = new DistressDeviceContext("in_progress", "device_setup",
|
||||
new DateTime(2026, 10, 21, 6, 40, 0, DateTimeKind.Utc));
|
||||
|
||||
await BackendSessionRegistration.ReportProbeResultAsync(
|
||||
Probe(), TlsFailure(), reporter, NewLogger(tmp), Context("IFX 7.63.3353.0", "none"), startContext);
|
||||
|
||||
var call = Assert.Single(reporter.Calls);
|
||||
Assert.Same(startContext, call.Context);
|
||||
Assert.Contains("sch=none", call.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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 "unreadable" through ReadBetween (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), DateTime.UtcNow)) { ReverseDirection = true }))
|
||||
{
|
||||
using (reader.ReadEvent()) { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_events_after_now()
|
||||
{
|
||||
Assert.Equal(SchannelEventReader.NoEvents,
|
||||
SchannelEventReader.ReadBetween(DateTime.UtcNow.AddHours(1), DateTime.UtcNow.AddHours(2)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Window_ends_where_the_diagnostics_begin()
|
||||
{
|
||||
var xpath = SchannelEventReader.BuildXPath(
|
||||
new DateTime(2026, 10, 21, 6, 40, 0, DateTimeKind.Utc),
|
||||
new DateTime(2026, 10, 21, 6, 55, 30, 250, DateTimeKind.Utc));
|
||||
|
||||
Assert.Contains(
|
||||
"TimeCreated[@SystemTime >= '2026-10-21T06:40:00.000Z' and @SystemTime <= '2026-10-21T06:55:30.250Z']",
|
||||
xpath);
|
||||
}
|
||||
}
|
||||
}
|
||||
+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,151 @@
|
||||
using System.Security.Authentication;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// The TLS probe's request and the reading of its answer: only an echo of the probe's own
|
||||
/// correlation id counts as the backend, the platform's "Client Certificate Required" page says
|
||||
/// the certificate did not arrive, and a device value can never split the request.
|
||||
/// </summary>
|
||||
public sealed class TlsHandshakeProbeTests
|
||||
{
|
||||
private const string SentId = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
private static TlsProbeTarget Target(string serial = "SN-1") => new TlsProbeTarget
|
||||
{
|
||||
BaseUrl = "https://api.example.test",
|
||||
TenantId = "00000000-0000-0000-0000-000000000001",
|
||||
Manufacturer = "Contoso",
|
||||
Model = "Book 1",
|
||||
SerialNumber = serial,
|
||||
AgentVersion = "2.0.1500",
|
||||
};
|
||||
|
||||
private static byte[] Raw(string statusLine, string headers, string body) =>
|
||||
Encoding.UTF8.GetBytes(statusLine + "\r\n" + headers + "\r\n" + body);
|
||||
|
||||
[Fact]
|
||||
public void Request_asks_for_the_agent_config_with_the_agent_headers()
|
||||
{
|
||||
var request = TlsHandshakeProbe.BuildRequest(Target(), "api.example.test", SentId);
|
||||
|
||||
Assert.StartsWith(
|
||||
"GET /api/agent/config?tenantId=00000000-0000-0000-0000-000000000001 HTTP/1.1\r\nHost: api.example.test\r\n",
|
||||
request);
|
||||
Assert.Contains("\r\nX-Correlation-ID: " + SentId + "\r\n", request);
|
||||
Assert.Contains("\r\nX-Device-Manufacturer: Contoso\r\n", request);
|
||||
Assert.Contains("\r\nX-Device-Model: Book 1\r\n", request);
|
||||
Assert.Contains("\r\nX-Device-SerialNumber: SN-1\r\n", request);
|
||||
Assert.Contains("\r\nX-Agent-Version: 2.0.1500\r\n", request);
|
||||
Assert.EndsWith("\r\nConnection: close\r\n\r\n", request);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_value_with_a_line_break_cannot_add_a_header()
|
||||
{
|
||||
var request = TlsHandshakeProbe.BuildRequest(Target("SN\r\nX-Injected: 1"), "api.example.test", SentId);
|
||||
|
||||
Assert.DoesNotContain("\r\nX-Injected", request);
|
||||
Assert.Contains("\r\nX-Device-SerialNumber: SN??X-Injected: 1\r\n", request);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Echoed_correlation_id_means_the_backend_answered()
|
||||
{
|
||||
var raw = Raw("HTTP/1.1 401 Unauthorized", "X-Correlation-ID: " + SentId + "\r\nContent-Type: application/json\r\n", "{}");
|
||||
|
||||
Assert.Equal("b401", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backend_success_is_read_as_backend()
|
||||
{
|
||||
var raw = Raw("HTTP/1.1 200 OK", "x-correlation-id: " + SentId + "\r\n", "{\"ok\":true}");
|
||||
|
||||
Assert.Equal("b200", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Platform_certificate_required_page_means_the_certificate_did_not_arrive()
|
||||
{
|
||||
var raw = Raw("HTTP/1.1 403 Forbidden", "Content-Type: text/html\r\n", InterceptionFixtures.CertificateRequiredPage);
|
||||
|
||||
Assert.Equal("cc403", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Certificate_required_page_with_an_echo_is_still_the_backend()
|
||||
{
|
||||
var raw = Raw("HTTP/1.1 403 Forbidden", "X-Correlation-ID: " + SentId + "\r\n", InterceptionFixtures.CertificateRequiredPage);
|
||||
|
||||
Assert.Equal("b403", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Echo_of_another_id_is_someone_else()
|
||||
{
|
||||
var raw = Raw("HTTP/1.1 403 Forbidden", "X-Correlation-ID: ffffffffffffffffffffffffffffffff\r\n", "<html>Access denied</html>");
|
||||
|
||||
Assert.Equal("x403", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stopped_app_page_is_reported_as_stopped()
|
||||
{
|
||||
var raw = Raw("HTTP/1.1 403 Forbidden", "Content-Type: text/html\r\n", InterceptionFixtures.StoppedAppPage);
|
||||
|
||||
Assert.Equal("st403", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("garbage")]
|
||||
[InlineData("HTTP/1.1 abc\r\n\r\n")]
|
||||
public void No_parsable_answer_is_reported_as_no_response(string text)
|
||||
{
|
||||
Assert.Equal("io(noresponse)", TlsHandshakeProbe.ClassifyAnswer(Encoding.ASCII.GetBytes(text), SentId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Microsoft_issued_server_certificate_adds_nothing()
|
||||
{
|
||||
using var cert = ServerCertificateObserverTests.SelfSigned(
|
||||
"CN=Microsoft Azure RSA TLS Issuing CA 03, O=Microsoft Corporation, C=US");
|
||||
|
||||
Assert.Null(TlsHandshakeProbe.ForeignIssuer(cert));
|
||||
Assert.Null(TlsHandshakeProbe.ForeignIssuer(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspection_issuer_is_named_in_a_compact_token()
|
||||
{
|
||||
using var cert = ServerCertificateObserverTests.SelfSigned("CN=Contoso Inspection CA (2026), O=Contoso Ltd");
|
||||
|
||||
Assert.Equal("Contoso_Inspection_CA__2026_", TlsHandshakeProbe.ForeignIssuer(cert));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Long_issuer_is_capped()
|
||||
{
|
||||
using var cert = ServerCertificateObserverTests.SelfSigned("CN=" + new string('A', 80) + ", O=Fabrikam");
|
||||
|
||||
Assert.Equal(new string('A', TlsHandshakeProbe.MaxIssuerLength), TlsHandshakeProbe.ForeignIssuer(cert));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SslProtocols.Tls12, "tls12")]
|
||||
[InlineData((SslProtocols)12288, "tls13")]
|
||||
[InlineData(SslProtocols.Tls11, "tls11")]
|
||||
[InlineData(SslProtocols.Tls, "tls10")]
|
||||
[InlineData(SslProtocols.None, "p0")]
|
||||
public void Protocol_token_names_the_negotiated_version(SslProtocols protocol, string expected)
|
||||
{
|
||||
Assert.Equal(expected, TlsHandshakeProbe.ProtocolToken(protocol));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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", "tls12,-,cc403");
|
||||
|
||||
Assert.Equal(
|
||||
"pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029) tls=0x80090326 tlsp=tls12,-,cc403 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!));
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
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 Without_a_context_the_report_samples_the_device_when_sent()
|
||||
{
|
||||
using (var reporter = NewReporter())
|
||||
{
|
||||
var report = reporter.BuildReport(DistressErrorType.AuthCertificateRejected, "m", 401, detail: null);
|
||||
|
||||
var osBuild = DeviceInfoProvider.GetOsBuild();
|
||||
Assert.Equal(string.IsNullOrEmpty(osBuild) ? null : osBuild, report.OsBuild);
|
||||
Assert.Equal(OobeStateReader.Read(), report.OobeState);
|
||||
Assert.Equal(EspPhaseSnapshot.Read(), report.EspPhase);
|
||||
Assert.NotNull(report.DeviceBootUtc);
|
||||
Assert.True(report.DeviceBootUtc <= DateTime.UtcNow);
|
||||
Assert.Null(report.TpmVersion);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_given_context_wins_over_the_send_time_sample()
|
||||
{
|
||||
// A failed registration samples the device before its first attempt; the report must
|
||||
// carry that, not where the device stands minutes later.
|
||||
var boot = new DateTime(2026, 10, 21, 6, 40, 0, DateTimeKind.Utc);
|
||||
using (var reporter = NewReporter())
|
||||
{
|
||||
var report = reporter.BuildReport(DistressErrorType.TpmPssUnsupported, "m", null, new DistressDetail
|
||||
{
|
||||
Context = new DistressDeviceContext("in_progress", "device_setup", boot),
|
||||
});
|
||||
|
||||
Assert.Equal("in_progress", report.OobeState);
|
||||
Assert.Equal("device_setup", report.EspPhase);
|
||||
Assert.Equal(boot, report.DeviceBootUtc);
|
||||
}
|
||||
}
|
||||
|
||||
[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, new DistressDetail { TpmVersion = "IFX 7.63.3353.0" }).TpmVersion);
|
||||
Assert.Equal(48,
|
||||
reporter.BuildReport(DistressErrorType.TpmPssUnsupported, "m", null, new DistressDetail { TpmVersion = 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 DistressDetail { TpmVersion = 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);
|
||||
report.EspPhase = "device_preparation_failed";
|
||||
report.DeviceBootUtc = DateTime.UtcNow;
|
||||
|
||||
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!));
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// One-shot read of the Autopilot ESP phase from the values <see cref="ProvisioningStatusTracker"/>
|
||||
/// watches (<c>HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings</c>), for reports taken
|
||||
/// without the tracker — a failed registration has no session and no tracker yet.
|
||||
/// <para>
|
||||
/// <b>Contract:</b> observational only, never decision-engine input. Unlike the tracker it has
|
||||
/// no settle or fallback timers: a category whose <c>categorySucceeded</c> Windows never sets
|
||||
/// counts as done once all its subcategories read succeeded or notRequired.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class EspPhaseSnapshot
|
||||
{
|
||||
internal const string None = "none";
|
||||
internal const string Unavailable = "unavailable";
|
||||
|
||||
internal enum CategoryState { Absent, Running, Done, Failed }
|
||||
|
||||
// ESP order: device preparation, device setup, account setup.
|
||||
private static readonly (string ValueName, string Phase)[] Categories =
|
||||
{
|
||||
("DevicePreparationCategory.Status", "device_preparation"),
|
||||
("DeviceSetupCategory.Status", "device_setup"),
|
||||
("AccountSetupCategory.Status", "account_setup"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// <c>device_preparation</c> / <c>device_setup</c> / <c>account_setup</c> while that
|
||||
/// category runs, <c><category>_failed</c>, <c>device_preparation_done</c> /
|
||||
/// <c>device_setup_done</c> when the next category has not appeared (yet, or skipped),
|
||||
/// <c>esp_done</c>, <c>none</c> without any ESP value, <c>unavailable</c> when the
|
||||
/// registry cannot be read. Never throws.
|
||||
/// </summary>
|
||||
public static string Read()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(ProvisioningStatusTracker.ProvisioningStatusRegistryPath, writable: false))
|
||||
{
|
||||
if (key == null) return None;
|
||||
return Derive(Categories.Select(c => key.GetValue(c.ValueName) as string).ToList());
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Unavailable;
|
||||
}
|
||||
}
|
||||
|
||||
/// <param name="statusJson">The three category values in ESP order; null where absent.</param>
|
||||
internal static string Derive(IReadOnlyList<string> statusJson)
|
||||
{
|
||||
var states = Categories.Select((_, i) => Classify(i < statusJson.Count ? statusJson[i] : null)).ToList();
|
||||
|
||||
for (var i = 0; i < Categories.Length; i++)
|
||||
if (states[i] == CategoryState.Failed) return Categories[i].Phase + "_failed";
|
||||
for (var i = 0; i < Categories.Length; i++)
|
||||
if (states[i] == CategoryState.Running) return Categories[i].Phase;
|
||||
|
||||
var lastDone = states.FindLastIndex(s => s == CategoryState.Done);
|
||||
if (lastDone < 0) return None;
|
||||
return lastDone == Categories.Length - 1 ? "esp_done" : Categories[lastDone].Phase + "_done";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One category value: done (<c>categorySucceeded=true</c>, or every subcategory succeeded
|
||||
/// or notRequired), failed (<c>false</c>, or a failed subcategory), running (anything
|
||||
/// else that is present, also unparseable JSON), absent (no value).
|
||||
/// </summary>
|
||||
internal static CategoryState Classify(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return CategoryState.Absent;
|
||||
try
|
||||
{
|
||||
using (var doc = JsonDocument.Parse(json))
|
||||
{
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object) return CategoryState.Running;
|
||||
|
||||
var succeeded = ProvisioningStatusTracker.SafeGetBool(root, "categorySucceeded");
|
||||
if (succeeded == true) return CategoryState.Done;
|
||||
if (succeeded == false) return CategoryState.Failed;
|
||||
|
||||
var subcategories = ProvisioningStatusTracker.ParseSubcategories(root);
|
||||
if (subcategories.Any(s => string.Equals(s.State, "failed", StringComparison.OrdinalIgnoreCase)))
|
||||
return CategoryState.Failed;
|
||||
if (subcategories.Count > 0 && subcategories.All(s =>
|
||||
string.Equals(s.State, "succeeded", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(s.State, "notRequired", StringComparison.OrdinalIgnoreCase)))
|
||||
return CategoryState.Done;
|
||||
return CategoryState.Running;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return CategoryState.Running;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+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++;
|
||||
}
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Termination;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// Where the device stood when a failure was observed: Windows' OOBE flag, the Autopilot ESP
|
||||
/// phase and the last boot. Observational only. Sample it when the failing work starts — a
|
||||
/// failed registration retries for minutes, and the device moves on meanwhile.
|
||||
/// </summary>
|
||||
public sealed class DistressDeviceContext
|
||||
{
|
||||
public DistressDeviceContext(string oobeState, string espPhase, DateTime? deviceBootUtc)
|
||||
{
|
||||
OobeState = oobeState;
|
||||
EspPhase = espPhase;
|
||||
DeviceBootUtc = deviceBootUtc;
|
||||
}
|
||||
|
||||
/// <summary><see cref="OobeStateReader"/>: flips to <c>completed</c> at the desktop, not ESP progress.</summary>
|
||||
public string OobeState { get; }
|
||||
|
||||
/// <summary><see cref="EspPhaseSnapshot"/> token.</summary>
|
||||
public string EspPhase { get; }
|
||||
|
||||
/// <summary>Last boot (UTC) from the monotonic uptime counter; null when it could not be read.</summary>
|
||||
public DateTime? DeviceBootUtc { get; }
|
||||
|
||||
/// <summary>Samples all three now. Registry, WinRT and one kernel call; never throws.</summary>
|
||||
public static DistressDeviceContext Capture()
|
||||
{
|
||||
DateTime? boot;
|
||||
try { boot = ObservationCoverage.DeviceBootUtc(); }
|
||||
catch { boot = null; }
|
||||
return new DistressDeviceContext(OobeStateReader.Read(), EspPhaseSnapshot.Read(), boot);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Optional parts of one distress report.</summary>
|
||||
public sealed class DistressDetail
|
||||
{
|
||||
/// <summary>TPM manufacturer id and firmware (TPM-PSS report only).</summary>
|
||||
public string TpmVersion { get; set; }
|
||||
|
||||
/// <summary>The device context of the failure; null samples it when the report is sent.</summary>
|
||||
public DistressDeviceContext Context { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
@@ -120,16 +121,21 @@ 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 a
|
||||
/// <see cref="DistressDeviceContext"/> — <paramref name="detail"/>'s, sampled when the
|
||||
/// failure was observed, or one sampled at send time; the TPM version comes only with the
|
||||
/// TPM-PSS report.
|
||||
/// </summary>
|
||||
public virtual async Task TrySendAsync(
|
||||
DistressErrorType errorType,
|
||||
string message,
|
||||
int? httpStatusCode = null)
|
||||
int? httpStatusCode = null,
|
||||
DistressDetail detail = null)
|
||||
{
|
||||
var key = $"{errorType}:{httpStatusCode}";
|
||||
int currentReportCount;
|
||||
@@ -164,29 +170,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, detail);
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(report);
|
||||
var json = SerializeWithinCap(report);
|
||||
|
||||
using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, _distressUrl)
|
||||
{
|
||||
@@ -216,6 +204,57 @@ 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, the send-time OS build,
|
||||
/// and the device context of <paramref name="detail"/> (sampled now when it has none).
|
||||
/// </summary>
|
||||
internal DistressReport BuildReport(DistressErrorType errorType, string message, int? httpStatusCode, DistressDetail detail)
|
||||
{
|
||||
var context = detail?.Context ?? DistressDeviceContext.Capture();
|
||||
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 = context.OobeState,
|
||||
EspPhase = context.EspPhase,
|
||||
DeviceBootUtc = context.DeviceBootUtc,
|
||||
TpmVersion = Truncate(detail?.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,95 @@
|
||||
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 in a time window:
|
||||
/// 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>The log was read and holds no matching event — a finding, unlike <see cref="Unreadable"/>.</summary>
|
||||
internal const string NoEvents = "none";
|
||||
|
||||
/// <summary>The System log could not be queried; nothing is known about Schannel.</summary>
|
||||
internal const string Unreadable = "unreadable";
|
||||
|
||||
/// <summary>
|
||||
/// Newest first, at most <see cref="MaxEvents"/> events as <see cref="FormatToken"/> tokens
|
||||
/// joined by commas (<c>36888:40/1205,36887:116</c>); <see cref="NoEvents"/> when there is
|
||||
/// none, <see cref="Unreadable"/> when the log cannot be read. <paramref name="untilUtc"/>
|
||||
/// keeps out what the diagnostics after the failure log themselves (the TLS probe's own
|
||||
/// handshake). Never throws.
|
||||
/// </summary>
|
||||
public static string ReadBetween(DateTime sinceUtc, DateTime untilUtc, AgentLogger logger = null)
|
||||
{
|
||||
var tokens = new List<string>();
|
||||
try
|
||||
{
|
||||
var query = new EventLogQuery("System", PathType.LogName, BuildXPath(sinceUtc, untilUtc)) { 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 Unreadable;
|
||||
}
|
||||
return tokens.Count == 0 ? NoEvents : string.Join(",", tokens);
|
||||
}
|
||||
|
||||
internal static string BuildXPath(DateTime sinceUtc, DateTime untilUtc)
|
||||
{
|
||||
return "*[System[Provider[@Name='Schannel'] and (EventID=36871 or EventID=36887 or EventID=36888)" +
|
||||
$" and TimeCreated[@SystemTime >= '{XPathTime(sinceUtc)}' and @SystemTime <= '{XPathTime(untilUtc)}']]]";
|
||||
}
|
||||
|
||||
private static string XPathTime(DateTime utc) =>
|
||||
utc.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
private readonly string _registrationFailurePath;
|
||||
private readonly object _lockObject = new object();
|
||||
|
||||
// Agent-local key in registration-failure.json; RegistrationFailureSummary has no such member.
|
||||
// Agent-local keys in registration-failure.json; RegistrationFailureSummary has no such members.
|
||||
private const string LocalNetworkFingerprintKey = "NetworkFingerprint";
|
||||
private const string LocalDeviceBootKey = "DeviceBootUtc";
|
||||
private const string LocalCertThumbprintKey = "CertThumbprint";
|
||||
|
||||
public SessionIdPersistence(string dataDirectory)
|
||||
{
|
||||
@@ -180,13 +182,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// <see cref="RegistrationFailureSummary.InterceptedRuns"/>), the first failure time is kept,
|
||||
/// everything else describes the latest run. A run that never registers exits and leaves
|
||||
/// nothing else behind — the emergency break reads this file so the backend learns why the
|
||||
/// session never appeared. <paramref name="networkFingerprint"/> (the latest run's network)
|
||||
/// is stored beside the summary, never in it, so it cannot reach the wire. Written through to
|
||||
/// disk (L.12): the process exits right after and the next boot decides on the record.
|
||||
/// session never appeared. <paramref name="networkFingerprint"/>, <paramref name="deviceBootUtc"/>
|
||||
/// and <paramref name="certThumbprint"/> (the latest run's network, boot and client
|
||||
/// certificate) are stored beside the summary, never in it, so they cannot reach the wire —
|
||||
/// the next successful start reports only whether they changed. Written through to disk
|
||||
/// (L.12): the process exits right after and the next boot decides on the record.
|
||||
/// Returns the merged summary, null when it could not be written. Never throws.
|
||||
/// </summary>
|
||||
public RegistrationFailureSummary RecordRegistrationFailure(
|
||||
RegistrationFailureSummary latestRun, AgentLogger logger = null, string networkFingerprint = null)
|
||||
RegistrationFailureSummary latestRun, AgentLogger logger = null, string networkFingerprint = null,
|
||||
DateTime? deviceBootUtc = null, string certThumbprint = null)
|
||||
{
|
||||
if (latestRun == null) return null;
|
||||
lock (_lockObject)
|
||||
@@ -206,8 +211,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
NetworkLinkUpAtEnd = latestRun.NetworkLinkUpAtEnd,
|
||||
AttemptWindowSeconds = latestRun.AttemptWindowSeconds,
|
||||
ConfigFetchOutcome = latestRun.ConfigFetchOutcome,
|
||||
EspPhase = latestRun.EspPhase,
|
||||
OobeState = latestRun.OobeState,
|
||||
};
|
||||
WriteRegistrationFailure(merged, networkFingerprint);
|
||||
WriteRegistrationFailure(merged, networkFingerprint, deviceBootUtc, certThumbprint);
|
||||
logger?.Info($"SessionIdPersistence: recorded failed registration run #{merged.FailedRuns} ({merged.InterceptedRuns} intercepted).");
|
||||
return merged;
|
||||
}
|
||||
@@ -286,7 +293,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
var json = JObject.Parse(File.ReadAllText(_registrationFailurePath));
|
||||
var summary = json.ToObject<RegistrationFailureSummary>();
|
||||
return summary != null && summary.FailedRuns > 0
|
||||
? new RegistrationFailureRecord(summary, (string)json[LocalNetworkFingerprintKey])
|
||||
? new RegistrationFailureRecord(
|
||||
summary,
|
||||
(string)json[LocalNetworkFingerprintKey],
|
||||
json[LocalDeviceBootKey]?.Type == JTokenType.Date ? json[LocalDeviceBootKey].ToObject<DateTime>().ToUniversalTime() : (DateTime?)null,
|
||||
(string)json[LocalCertThumbprintKey])
|
||||
: null;
|
||||
}
|
||||
catch
|
||||
@@ -297,14 +308,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The summary's own properties plus one agent-local key: the fingerprint sits next to the
|
||||
/// wire type, never inside it (the summary deserializes without it).
|
||||
/// The summary's own properties plus the agent-local keys: they sit next to the wire type,
|
||||
/// never inside it (the summary deserializes without them).
|
||||
/// </summary>
|
||||
private void WriteRegistrationFailure(RegistrationFailureSummary summary, string networkFingerprint)
|
||||
private void WriteRegistrationFailure(
|
||||
RegistrationFailureSummary summary, string networkFingerprint, DateTime? deviceBootUtc, string certThumbprint)
|
||||
{
|
||||
var json = JObject.FromObject(summary);
|
||||
if (!string.IsNullOrEmpty(networkFingerprint))
|
||||
json[LocalNetworkFingerprintKey] = networkFingerprint;
|
||||
if (deviceBootUtc.HasValue)
|
||||
json[LocalDeviceBootKey] = deviceBootUtc.Value.ToUniversalTime();
|
||||
if (!string.IsNullOrEmpty(certThumbprint))
|
||||
json[LocalCertThumbprintKey] = certThumbprint;
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(json.ToString(Formatting.None));
|
||||
using (var stream = new FileStream(
|
||||
@@ -357,20 +373,31 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
|
||||
/// <summary>
|
||||
/// <c>registration-failure.json</c> as the agent keeps it: the <see cref="RegistrationFailureSummary"/>
|
||||
/// the emergency break may send, plus the network fingerprint of the latest failed run, which
|
||||
/// stays on the device — only the next successful start's comparison (changed or not) is reported.
|
||||
/// the emergency break may send, plus the network fingerprint, boot time and client-certificate
|
||||
/// thumbprint of the latest failed run, which stay on the device — only the next successful
|
||||
/// start's comparisons (changed or not) are reported.
|
||||
/// </summary>
|
||||
public sealed class RegistrationFailureRecord
|
||||
{
|
||||
public RegistrationFailureRecord(RegistrationFailureSummary summary, string networkFingerprint)
|
||||
public RegistrationFailureRecord(
|
||||
RegistrationFailureSummary summary, string networkFingerprint,
|
||||
DateTime? deviceBootUtc = null, string certThumbprint = null)
|
||||
{
|
||||
Summary = summary ?? throw new ArgumentNullException(nameof(summary));
|
||||
NetworkFingerprint = networkFingerprint;
|
||||
DeviceBootUtc = deviceBootUtc;
|
||||
CertThumbprint = certThumbprint;
|
||||
}
|
||||
|
||||
public RegistrationFailureSummary Summary { get; }
|
||||
|
||||
/// <summary>Fingerprint of the latest failed run's network; null when it was unknown.</summary>
|
||||
public string NetworkFingerprint { get; }
|
||||
|
||||
/// <summary>Boot time (UTC) of the latest failed run; null when it was unknown or the record predates it.</summary>
|
||||
public DateTime? DeviceBootUtc { get; }
|
||||
|
||||
/// <summary>Thumbprint of the client certificate the latest failed run used; null when none or unknown.</summary>
|
||||
public string CertThumbprint { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Authentication;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using AutopilotMonitor.Shared;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>Who the TLS probe talks to and with which identity headers.</summary>
|
||||
internal sealed class TlsProbeTarget
|
||||
{
|
||||
public string BaseUrl { get; set; }
|
||||
public string TenantId { get; set; }
|
||||
public string Manufacturer { get; set; }
|
||||
public string Model { get; set; }
|
||||
public string SerialNumber { get; set; }
|
||||
public string AgentVersion { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic after a terminal <c>SecureChannelFailure</c>: one direct TLS connection to the
|
||||
/// backend on a raw <see cref="SslStream"/> with the client certificate (system-default
|
||||
/// protocols, default certificate selection, normal server validation) and one
|
||||
/// <c>GET /api/agent/config</c> with the agent's own headers — the platform asks for the
|
||||
/// certificate only once it has seen a path that needs one. The HTTP stack reports only
|
||||
/// <c>SecureChannelFailure</c>; this says which protocol was negotiated, whether the certificate
|
||||
/// went out and who answered, or the SSPI code of the failure. Failure path only, never a proxy.
|
||||
/// <para>
|
||||
/// Result token: <c>unreachable</c>, <c>hs(srvcert)</c> / <c>hs(0x…)</c> / <c>hs</c> (handshake
|
||||
/// failed), or <c><protocol>,<m|->,<answer></c> — protocol <c>tls12</c> /
|
||||
/// <c>tls13</c> / …, <c>m</c> when the session ended mutually authenticated, answer
|
||||
/// <c>b<status></c> (the backend answered: the certificate arrived), <c>cc<status></c>
|
||||
/// (the platform's "Client Certificate Required" page: it did not), <c>st<status></c>
|
||||
/// (stopped app), <c>x<status></c> (someone else), <c>io(0x…)</c> / <c>io</c> (the
|
||||
/// exchange failed, e.g. during renegotiation). A server certificate not issued by Microsoft
|
||||
/// (TLS inspection on the path) appends <c>,i=<issuer CN></c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class TlsHandshakeProbe
|
||||
{
|
||||
internal const string Unreachable = "unreachable";
|
||||
internal const int MaxResponseBytes = 16 * 1024;
|
||||
internal const int MaxIssuerLength = 32;
|
||||
|
||||
/// <summary>Runs the probe within <paramref name="timeout"/> per network step. Never throws.</summary>
|
||||
public static string Run(TlsProbeTarget target, X509Certificate2 clientCertificate, TimeSpan timeout, AgentLogger logger = null)
|
||||
{
|
||||
Uri baseUri;
|
||||
try { baseUri = new Uri(target.BaseUrl); }
|
||||
catch { return Unreachable; }
|
||||
var host = baseUri.Host;
|
||||
var port = baseUri.IsDefaultPort ? 443 : baseUri.Port;
|
||||
var timeoutMs = (int)timeout.TotalMilliseconds;
|
||||
|
||||
try
|
||||
{
|
||||
using (var tcp = new TcpClient())
|
||||
{
|
||||
var connect = tcp.ConnectAsync(host, port);
|
||||
// A connect that fails after the timeout must not surface as an unobserved
|
||||
// task exception: the agent writes crash artefacts for those.
|
||||
connect.ContinueWith(t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted);
|
||||
try
|
||||
{
|
||||
if (!connect.Wait(timeout))
|
||||
return Unreachable;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Unreachable;
|
||||
}
|
||||
|
||||
var serverCertificateRejected = false;
|
||||
string foreignIssuer = null;
|
||||
using (var ssl = new SslStream(tcp.GetStream(), leaveInnerStreamOpen: false,
|
||||
userCertificateValidationCallback: (sender, certificate, chain, errors) =>
|
||||
{
|
||||
foreignIssuer = ForeignIssuer(certificate);
|
||||
serverCertificateRejected = errors != SslPolicyErrors.None;
|
||||
return !serverCertificateRejected;
|
||||
}))
|
||||
{
|
||||
ssl.ReadTimeout = timeoutMs;
|
||||
ssl.WriteTimeout = timeoutMs;
|
||||
var certificates = new X509CertificateCollection();
|
||||
if (clientCertificate != null) certificates.Add(clientCertificate);
|
||||
|
||||
try
|
||||
{
|
||||
ssl.AuthenticateAsClient(host, certificates, SslProtocols.None, checkCertificateRevocation: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Debug($"TlsHandshakeProbe: handshake failed: {ex.GetBaseException().Message}");
|
||||
return (serverCertificateRejected ? "hs(srvcert)" : "hs" + CodeSuffix(ex)) + IssuerSuffix(foreignIssuer);
|
||||
}
|
||||
|
||||
var protocol = ProtocolToken(ssl.SslProtocol);
|
||||
string answer;
|
||||
try
|
||||
{
|
||||
var correlationId = Guid.NewGuid().ToString("N");
|
||||
var request = Encoding.ASCII.GetBytes(BuildRequest(target, host, correlationId));
|
||||
ssl.Write(request, 0, request.Length);
|
||||
ssl.Flush();
|
||||
answer = ClassifyAnswer(ReadResponse(ssl), correlationId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Debug($"TlsHandshakeProbe: exchange failed: {ex.GetBaseException().Message}");
|
||||
answer = "io" + CodeSuffix(ex);
|
||||
}
|
||||
|
||||
bool mutual;
|
||||
try { mutual = ssl.IsMutuallyAuthenticated; }
|
||||
catch { mutual = false; }
|
||||
return $"{protocol},{(mutual ? "m" : "-")},{answer}{IssuerSuffix(foreignIssuer)}";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Debug($"TlsHandshakeProbe: failed: {ex.Message}");
|
||||
return Unreachable;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string BuildRequest(TlsProbeTarget target, string host, string correlationId)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("GET ").Append(Constants.ApiEndpoints.GetAgentConfig)
|
||||
.Append("?tenantId=").Append(Uri.EscapeDataString(target.TenantId ?? string.Empty)).Append(" HTTP/1.1\r\n");
|
||||
sb.Append("Host: ").Append(host).Append("\r\n");
|
||||
sb.Append("User-Agent: AutopilotMonitor.Agent/").Append(HeaderValue(target.AgentVersion)).Append(" tls-probe\r\n");
|
||||
sb.Append(ResponseOriginClassifier.CorrelationIdHeader).Append(": ").Append(correlationId).Append("\r\n");
|
||||
AppendHeader(sb, "X-Device-Manufacturer", target.Manufacturer);
|
||||
AppendHeader(sb, "X-Device-Model", target.Model);
|
||||
AppendHeader(sb, "X-Device-SerialNumber", target.SerialNumber);
|
||||
AppendHeader(sb, "X-Agent-Version", target.AgentVersion);
|
||||
sb.Append("Connection: close\r\n\r\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads until the server closes or <see cref="MaxResponseBytes"/> arrived; a read that
|
||||
/// times out after the answer began keeps what came (a server that ignores
|
||||
/// <c>Connection: close</c>).
|
||||
/// </summary>
|
||||
private static byte[] ReadResponse(Stream stream)
|
||||
{
|
||||
var buffer = new byte[4096];
|
||||
using (var collected = new MemoryStream())
|
||||
{
|
||||
while (collected.Length < MaxResponseBytes)
|
||||
{
|
||||
int read;
|
||||
try
|
||||
{
|
||||
read = stream.Read(buffer, 0, buffer.Length);
|
||||
}
|
||||
catch (IOException) when (collected.Length > 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (read <= 0) break;
|
||||
collected.Write(buffer, 0, read);
|
||||
}
|
||||
return collected.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The answer token for a raw HTTP response, through the shared <see cref="ResponseOriginClassifier"/>:
|
||||
/// an echo of <paramref name="correlationId"/> means the backend wrote it.
|
||||
/// </summary>
|
||||
internal static string ClassifyAnswer(byte[] raw, string correlationId)
|
||||
{
|
||||
if (!TryParse(raw, out var status, out var headers, out var body))
|
||||
return "io(noresponse)";
|
||||
|
||||
using (var response = new HttpResponseMessage((HttpStatusCode)status))
|
||||
{
|
||||
foreach (var header in headers)
|
||||
response.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
response.Content = new StringContent(body ?? string.Empty);
|
||||
|
||||
var classification = ResponseOriginClassifier.Classify(response, correlationId, body);
|
||||
switch (classification.Origin)
|
||||
{
|
||||
case ResponseOrigin.Backend:
|
||||
return "b" + status;
|
||||
case ResponseOrigin.PlatformStopped:
|
||||
return "st" + status;
|
||||
default:
|
||||
return classification.ForeignReason == ResponseClassification.ClientCertificateRequired
|
||||
? "cc" + status
|
||||
: "x" + status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryParse(byte[] raw, out int status, out List<KeyValuePair<string, string>> headers, out string body)
|
||||
{
|
||||
status = 0;
|
||||
headers = new List<KeyValuePair<string, string>>();
|
||||
body = null;
|
||||
if (raw == null || raw.Length == 0) return false;
|
||||
|
||||
var text = Encoding.UTF8.GetString(raw);
|
||||
var headEnd = text.IndexOf("\r\n\r\n", StringComparison.Ordinal);
|
||||
var head = headEnd >= 0 ? text.Substring(0, headEnd) : text;
|
||||
body = headEnd >= 0 ? text.Substring(headEnd + 4) : string.Empty;
|
||||
|
||||
var lines = head.Split(new[] { "\r\n" }, StringSplitOptions.None);
|
||||
var statusParts = lines[0].Split(' ');
|
||||
if (statusParts.Length < 2 || !statusParts[0].StartsWith("HTTP/", StringComparison.Ordinal)
|
||||
|| !int.TryParse(statusParts[1], out status))
|
||||
return false;
|
||||
|
||||
for (var i = 1; i < lines.Length; i++)
|
||||
{
|
||||
var colon = lines[i].IndexOf(':');
|
||||
if (colon > 0)
|
||||
headers.Add(new KeyValuePair<string, string>(lines[i].Substring(0, colon).Trim(), lines[i].Substring(colon + 1).Trim()));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static string ProtocolToken(SslProtocols protocol)
|
||||
{
|
||||
switch ((int)protocol)
|
||||
{
|
||||
case 12288: return "tls13"; // SslProtocols.Tls13
|
||||
case 3072: return "tls12";
|
||||
case 768: return "tls11";
|
||||
case 192: return "tls10";
|
||||
default: return "p" + ((int)protocol).ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The issuer CN of a server certificate that Microsoft did not issue (the backend's comes
|
||||
/// from a Microsoft CA), else null: <c>[A-Za-z0-9.-]</c>, others as <c>_</c>, at most
|
||||
/// <see cref="MaxIssuerLength"/>.
|
||||
/// </summary>
|
||||
internal static string ForeignIssuer(X509Certificate certificate)
|
||||
{
|
||||
if (certificate == null) return null;
|
||||
try
|
||||
{
|
||||
if (certificate.Issuer.IndexOf("O=Microsoft Corporation", StringComparison.Ordinal) >= 0)
|
||||
return null;
|
||||
string cn;
|
||||
using (var cert2 = new X509Certificate2(certificate))
|
||||
cn = cert2.GetNameInfo(X509NameType.SimpleName, forIssuer: true);
|
||||
if (string.IsNullOrEmpty(cn)) return "unknown";
|
||||
var sb = new StringBuilder(Math.Min(cn.Length, MaxIssuerLength));
|
||||
foreach (var c in cn)
|
||||
{
|
||||
if (sb.Length == MaxIssuerLength) break;
|
||||
sb.Append((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '-' ? c : '_');
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
private static string IssuerSuffix(string foreignIssuer) =>
|
||||
foreignIssuer == null ? string.Empty : ",i=" + foreignIssuer;
|
||||
|
||||
private static string CodeSuffix(Exception ex)
|
||||
{
|
||||
var code = TransportErrorCodes.InnermostNativeCode(ex);
|
||||
return code == null ? string.Empty : "(" + code + ")";
|
||||
}
|
||||
|
||||
private static void AppendHeader(StringBuilder sb, string name, string value)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
sb.Append(name).Append(": ").Append(HeaderValue(value)).Append("\r\n");
|
||||
}
|
||||
|
||||
/// <summary>ASCII only, no line breaks — a header value must never split the request.</summary>
|
||||
private static string HeaderValue(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return string.Empty;
|
||||
var sb = new StringBuilder(value.Length);
|
||||
foreach (var c in value)
|
||||
sb.Append(c >= 0x20 && c < 0x7F ? c : '?');
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,42 @@ 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(0x80070423) pss384=fail(0x80090029)
|
||||
/// tlsp=tls12,-,cc403 sch=none prov=PCP key=2048</c>. <paramref name="tlsCode"/> is the
|
||||
/// native code of the failed HTTP-stack handshake (<see cref="TransportErrorCodes"/>),
|
||||
/// <paramref name="tlsProbe"/> the <see cref="TlsHandshakeProbe"/> token,
|
||||
/// <paramref name="schannelEvents"/> the System-log finding (<see cref="SchannelEventReader"/>);
|
||||
/// each is left out when null. At most <see cref="MaxDistressMessageLength"/> chars.
|
||||
/// </summary>
|
||||
public string ToDistressMessage()
|
||||
public string ToDistressMessage(string tlsCode = null, string schannelEvents = null, string tlsProbe = 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(tlsProbe)) sb.Append(" tlsp=").Append(tlsProbe);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
distressReporter: distressReporter,
|
||||
emergencyReporter: emergencyReporter,
|
||||
authFailureTracker: authFailureTracker,
|
||||
serverCertificates: serverCertificates);
|
||||
serverCertificates: serverCertificates,
|
||||
clientCertificateThumbprint: clientCertificate?.Thumbprint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -290,6 +291,12 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// </summary>
|
||||
public ServerCertificateObserver ServerCertificates { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Thumbprint of the client certificate this run uses; null without one. Kept beside a
|
||||
/// failed run's record so the next successful start can tell whether the certificate changed.
|
||||
/// </summary>
|
||||
public string ClientCertificateThumbprint { get; }
|
||||
|
||||
public BackendAuthBundle(
|
||||
BackendApiClient backendApiClient,
|
||||
NetworkMetrics networkMetrics,
|
||||
@@ -300,7 +307,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
DistressReporter distressReporter,
|
||||
EmergencyReporter emergencyReporter,
|
||||
AuthFailureTracker authFailureTracker,
|
||||
ServerCertificateObserver serverCertificates)
|
||||
ServerCertificateObserver serverCertificates,
|
||||
string clientCertificateThumbprint = null)
|
||||
{
|
||||
BackendApiClient = backendApiClient;
|
||||
NetworkMetrics = networkMetrics;
|
||||
@@ -312,6 +320,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
EmergencyReporter = emergencyReporter;
|
||||
AuthFailureTracker = authFailureTracker;
|
||||
ServerCertificates = serverCertificates;
|
||||
ClientCertificateThumbprint = clientCertificateThumbprint;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -58,6 +60,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// the device slept or was suspended while this run tried to register.
|
||||
var startedUtc = DateTime.UtcNow;
|
||||
var linkUpAtStart = TryIsNetworkLinkUp();
|
||||
// Where the device stands before the first attempt: the retries take minutes, and a
|
||||
// failure is reported (and recorded) with this, not with where it stands at the end.
|
||||
var startContext = DistressDeviceContext.Capture();
|
||||
|
||||
var registrationResult = SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient: auth.BackendApiClient,
|
||||
@@ -66,7 +71,18 @@ 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, startContext,
|
||||
new TlsProbeTarget
|
||||
{
|
||||
BaseUrl = agentConfig.ApiBaseUrl,
|
||||
TenantId = agentConfig.TenantId,
|
||||
Manufacturer = auth.Manufacturer,
|
||||
Model = auth.Model,
|
||||
SerialNumber = auth.SerialNumber,
|
||||
AgentVersion = agentVersion,
|
||||
},
|
||||
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),
|
||||
@@ -76,12 +92,15 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
if (registrationResult.Outcome == SessionRegistrationOutcome.Succeeded)
|
||||
{
|
||||
return SessionRegistrationOutcomeResult.Continue(
|
||||
registrationResult, ClearAfterSuccess(sessionPersistence, NetworkFingerprint.TryCapture, logger));
|
||||
registrationResult,
|
||||
ClearAfterSuccess(sessionPersistence, NetworkFingerprint.TryCapture, logger,
|
||||
startContext.DeviceBootUtc, auth.ClientCertificateThumbprint));
|
||||
}
|
||||
|
||||
var record = RecordFailedRun(
|
||||
sessionPersistence, registrationResult, startedUtc, DateTime.UtcNow,
|
||||
linkUpAtStart, TryIsNetworkLinkUp(), configFetchOutcome, NetworkFingerprint.TryCapture(), logger);
|
||||
linkUpAtStart, TryIsNetworkLinkUp(), configFetchOutcome, NetworkFingerprint.TryCapture(), logger,
|
||||
startContext, auth.ClientCertificateThumbprint);
|
||||
|
||||
logger.Error(
|
||||
$"=== SESSION REGISTRATION FAILED ({registrationResult.Outcome}: {registrationResult.ErrorMessage}) — " +
|
||||
@@ -104,8 +123,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// it — for a session that never registered, the only account of why) and returns the record
|
||||
/// including this run; null without persistence or when it could not be written. An
|
||||
/// intercepted run also counts toward <see cref="RegistrationFailureSummary.InterceptedRuns"/>,
|
||||
/// which only a successful registration resets. <paramref name="networkFingerprint"/> is this
|
||||
/// run's network; it is kept beside the record and never leaves the device. Never throws.
|
||||
/// which only a successful registration resets. <paramref name="startContext"/> supplies the
|
||||
/// ESP phase and OOBE flag the run started in. <paramref name="networkFingerprint"/>, the
|
||||
/// boot time and <paramref name="certThumbprint"/> are kept beside the record and never leave
|
||||
/// the device. Never throws.
|
||||
/// </summary>
|
||||
internal static RegistrationFailureSummary RecordFailedRun(
|
||||
SessionIdPersistence sessionPersistence,
|
||||
@@ -116,7 +137,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
bool? linkUpAtEnd,
|
||||
string configFetchOutcome,
|
||||
string networkFingerprint,
|
||||
AgentLogger logger)
|
||||
AgentLogger logger,
|
||||
DistressDeviceContext startContext = null,
|
||||
string certThumbprint = null)
|
||||
{
|
||||
if (sessionPersistence == null || registrationResult == null) return null;
|
||||
try
|
||||
@@ -137,7 +160,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
NetworkLinkUpAtEnd = linkUpAtEnd,
|
||||
AttemptWindowSeconds = Math.Round(Math.Max(0, (endedUtc - startedUtc).TotalSeconds), 1),
|
||||
ConfigFetchOutcome = configFetchOutcome,
|
||||
}, logger, networkFingerprint);
|
||||
EspPhase = startContext?.EspPhase,
|
||||
OobeState = startContext?.OobeState,
|
||||
}, logger, networkFingerprint, startContext?.DeviceBootUtc, certThumbprint);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -150,14 +175,17 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// A successful registration clears <c>registration-failure.json</c>. When earlier starts of
|
||||
/// this session had failed, the cleared record comes back for this run's <c>agent_started</c>
|
||||
/// — until now the success erased it silently and late successes were uncountable. The
|
||||
/// network fingerprints are compared here (<paramref name="captureNetworkFingerprint"/> runs
|
||||
/// only when there is a record) and never leave the device. Null when there was no record.
|
||||
/// Never throws.
|
||||
/// network fingerprints, boot times (<paramref name="currentBootUtc"/>) and client-certificate
|
||||
/// thumbprints (<paramref name="currentCertThumbprint"/>) are compared here and never leave
|
||||
/// the device (<paramref name="captureNetworkFingerprint"/> runs only when there is a record).
|
||||
/// Null when there was no record. Never throws.
|
||||
/// </summary>
|
||||
internal static PriorRegistrationFailures ClearAfterSuccess(
|
||||
SessionIdPersistence sessionPersistence,
|
||||
Func<string> captureNetworkFingerprint,
|
||||
AgentLogger logger)
|
||||
AgentLogger logger,
|
||||
DateTime? currentBootUtc = null,
|
||||
string currentCertThumbprint = null)
|
||||
{
|
||||
if (sessionPersistence == null) return null;
|
||||
try
|
||||
@@ -170,10 +198,16 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
cleared.Summary.FailedRuns,
|
||||
cleared.Summary.InterceptedRuns,
|
||||
cleared.Summary.Outcome,
|
||||
NetworkFingerprint.Differs(cleared.NetworkFingerprint, captureNetworkFingerprint?.Invoke()));
|
||||
NetworkFingerprint.Differs(cleared.NetworkFingerprint, captureNetworkFingerprint?.Invoke()),
|
||||
cleared.Summary.EspPhase,
|
||||
cleared.Summary.OobeState,
|
||||
RebootedSince(cleared.DeviceBootUtc, currentBootUtc),
|
||||
CertificateChanged(cleared.CertThumbprint, currentCertThumbprint));
|
||||
logger?.Info(
|
||||
$"Session registered after {prior.FailedRuns} failed start(s) ({prior.InterceptedRuns} intercepted, " +
|
||||
$"last: {prior.LastOutcome}, network changed: {prior.NetworkChanged?.ToString() ?? "unknown"}).");
|
||||
$"last: {prior.LastOutcome}, network changed: {prior.NetworkChanged?.ToString() ?? "unknown"}, " +
|
||||
$"last ESP phase: {prior.LastEspPhase ?? "unknown"}, rebooted since: {prior.RebootedSince?.ToString() ?? "unknown"}, " +
|
||||
$"certificate changed: {prior.CertChanged?.ToString() ?? "unknown"}).");
|
||||
return prior;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -230,6 +264,27 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
return registrationResult.Outcome == SessionRegistrationOutcome.AuthFailed ? 6 : 7;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Boot times derived from the uptime counter jitter by milliseconds to seconds between two
|
||||
/// reads of the same boot (NTP corrections shift them further), so only a gap above this
|
||||
/// counts as a reboot.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan RebootDetectionTolerance = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// <summary>True when the two boot times are further apart than <see cref="RebootDetectionTolerance"/>; null when either is unknown.</summary>
|
||||
internal static bool? RebootedSince(DateTime? failedRunBootUtc, DateTime? currentBootUtc)
|
||||
{
|
||||
if (!failedRunBootUtc.HasValue || !currentBootUtc.HasValue) return null;
|
||||
return (currentBootUtc.Value - failedRunBootUtc.Value).Duration() > RebootDetectionTolerance;
|
||||
}
|
||||
|
||||
/// <summary>True when the thumbprints differ (case-insensitive); null when either is unknown.</summary>
|
||||
internal static bool? CertificateChanged(string failedRunThumbprint, string currentThumbprint)
|
||||
{
|
||||
if (string.IsNullOrEmpty(failedRunThumbprint) || string.IsNullOrEmpty(currentThumbprint)) return null;
|
||||
return !string.Equals(failedRunThumbprint, currentThumbprint, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool? TryIsNetworkLinkUp()
|
||||
{
|
||||
try { return NetworkInterface.GetIsNetworkAvailable(); }
|
||||
@@ -280,6 +335,12 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>How long the TPM read, the System-log read and the TLS probe for the TPM-PSS report may take together.</summary>
|
||||
internal static readonly TimeSpan DiagnosticContextBound = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>Per network step of the TLS probe (connect, handshake, request, response).</summary>
|
||||
internal static readonly TimeSpan TlsProbeStepTimeout = TimeSpan.FromSeconds(4);
|
||||
|
||||
/// <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 +348,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,8 +356,13 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
private static Task DiagnoseTerminalTransportFailure(
|
||||
Exception ex,
|
||||
BackendAuthBundle auth,
|
||||
DateTime registrationStartedUtc,
|
||||
DistressDeviceContext startContext,
|
||||
TlsProbeTarget probeTarget,
|
||||
AgentLogger logger)
|
||||
{
|
||||
// The Schannel window closes here: the TLS probe below logs its own handshake.
|
||||
var failureDetectedUtc = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
if (!IsSecureChannelFailure(ex))
|
||||
@@ -314,35 +380,121 @@ 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.ReadBetween(registrationStartedUtc, failureDetectedUtc, logger),
|
||||
() => TlsHandshakeProbe.Run(probeTarget, cert, TlsProbeStepTimeout, logger),
|
||||
Task.Delay(DiagnosticContextBound), logger),
|
||||
startContext);
|
||||
}
|
||||
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 finding, the TLS probe and the TPM firmware from <paramref name="readContext"/>, and the
|
||||
/// <paramref name="startContext"/> the run began in. 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, string TlsProbe)>> readContext,
|
||||
DistressDeviceContext startContext = null)
|
||||
{
|
||||
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 = SchannelEventReader.Unreadable, tlsProbe = null;
|
||||
try
|
||||
{
|
||||
(tpmVersion, schannelEvents, tlsProbe) = 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.");
|
||||
}
|
||||
logger?.Info($"TPM-PSS report: tlsProbe={tlsProbe ?? "n/a"} schannel={schannelEvents ?? "n/a"} tpm={tpmVersion ?? "n/a"}");
|
||||
await reporter.TrySendAsync(
|
||||
DistressErrorType.TpmPssUnsupported,
|
||||
probe.ToDistressMessage(tlsCode, schannelEvents, tlsProbe),
|
||||
detail: new DistressDetail { TpmVersion = tpmVersion, Context = startContext }).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>Schannel finding or TLS-probe token when that read did not finish within the bound.</summary>
|
||||
internal const string SchannelReadTimeout = "timeout";
|
||||
|
||||
/// <summary>
|
||||
/// Runs the three reads in parallel until all finished or <paramref name="bound"/> completed
|
||||
/// (production: <see cref="DiagnosticContextBound"/>). A TPM read that threw or had not
|
||||
/// finished counts as missing (null); the Schannel finding then says
|
||||
/// <see cref="SchannelEventReader.Unreadable"/> or <see cref="SchannelReadTimeout"/>, so an
|
||||
/// empty finding is never mistaken for "no events"; the TLS probe says <c>error</c> or
|
||||
/// <see cref="SchannelReadTimeout"/>. Never throws.
|
||||
/// </summary>
|
||||
internal static async Task<(string TpmVersion, string SchannelEvents, string TlsProbe)> ReadDiagnosticContextAsync(
|
||||
Func<string> readTpmVersion,
|
||||
Func<string> readSchannelEvents,
|
||||
Func<string> runTlsProbe,
|
||||
Task bound,
|
||||
AgentLogger logger)
|
||||
{
|
||||
var tpm = Task.Run(readTpmVersion);
|
||||
var schannel = Task.Run(readSchannelEvents);
|
||||
var tlsProbe = Task.Run(runTlsProbe);
|
||||
await Task.WhenAny(Task.WhenAll(tpm, schannel, tlsProbe), bound).ConfigureAwait(false);
|
||||
return (
|
||||
ResultOr(tpm, "TPM", logger, faulted: null, unfinished: null),
|
||||
ResultOr(schannel, "Schannel event", logger, faulted: SchannelEventReader.Unreadable, unfinished: SchannelReadTimeout),
|
||||
ResultOr(tlsProbe, "TLS probe", logger, faulted: "error", unfinished: SchannelReadTimeout));
|
||||
}
|
||||
|
||||
private static string ResultOr(Task<string> read, string what, AgentLogger logger, string faulted, string unfinished)
|
||||
{
|
||||
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 read.IsFaulted ? faulted : unfinished;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -400,19 +552,37 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// <summary>
|
||||
/// What the failed starts before a successful registration left in the record it cleared,
|
||||
/// reported once on that run's <c>agent_started</c> (<c>priorRegistration*</c> keys): whether a
|
||||
/// further boot rescues intercepted or failed starts becomes countable. Only the fingerprint
|
||||
/// comparison is carried, never a fingerprint.
|
||||
/// further boot rescues intercepted or failed starts becomes countable. Only the comparisons of
|
||||
/// network, boot and client certificate are carried, never a fingerprint, boot time or thumbprint.
|
||||
/// </summary>
|
||||
internal sealed class PriorRegistrationFailures
|
||||
{
|
||||
public PriorRegistrationFailures(int failedRuns, int interceptedRuns, string lastOutcome, bool? networkChanged)
|
||||
public PriorRegistrationFailures(
|
||||
int failedRuns, int interceptedRuns, string lastOutcome, bool? networkChanged,
|
||||
string lastEspPhase = null, string lastOobeState = null, bool? rebootedSince = null, bool? certChanged = null)
|
||||
{
|
||||
FailedRuns = failedRuns;
|
||||
InterceptedRuns = interceptedRuns;
|
||||
LastOutcome = lastOutcome;
|
||||
NetworkChanged = networkChanged;
|
||||
LastEspPhase = lastEspPhase;
|
||||
LastOobeState = lastOobeState;
|
||||
RebootedSince = rebootedSince;
|
||||
CertChanged = certChanged;
|
||||
}
|
||||
|
||||
/// <summary>ESP phase the latest failed start began in; null for records that predate it.</summary>
|
||||
public string LastEspPhase { get; }
|
||||
|
||||
/// <summary>Windows' OOBE flag when the latest failed start began; null for records that predate it.</summary>
|
||||
public string LastOobeState { get; }
|
||||
|
||||
/// <summary>The device booted between the latest failed start and this one; null when either boot time is unknown.</summary>
|
||||
public bool? RebootedSince { get; }
|
||||
|
||||
/// <summary>This start uses a different client certificate than the latest failed one; null when either side is unknown.</summary>
|
||||
public bool? CertChanged { get; }
|
||||
|
||||
public int FailedRuns { get; }
|
||||
|
||||
public int InterceptedRuns { get; }
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
@@ -87,6 +88,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// this makes late starts provable: "completed" on a first_run means OOBE
|
||||
// was already over when the agent arrived (e.g. IME queue blocked).
|
||||
{ "oobeStateAtAgentStart", OobeStateReader.Read() },
|
||||
// The ESP phase from the ESP registry — unlike the OOBE flag, which flips only
|
||||
// at the desktop, it tells device setup from account setup from done.
|
||||
{ "espPhaseAtAgentStart", EspPhaseSnapshot.Read() },
|
||||
};
|
||||
|
||||
if (recovered)
|
||||
@@ -116,6 +120,16 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
data["priorRegistrationLastOutcome"] = priorRegistrationFailures.LastOutcome;
|
||||
if (priorRegistrationFailures.NetworkChanged.HasValue)
|
||||
data["priorRegistrationNetworkChanged"] = priorRegistrationFailures.NetworkChanged.Value;
|
||||
// Where the latest failed start began, and what differs now: a reboot in
|
||||
// between, another client certificate. Comparisons only, no boot time or thumbprint.
|
||||
if (!string.IsNullOrEmpty(priorRegistrationFailures.LastEspPhase))
|
||||
data["priorRegistrationLastEspPhase"] = priorRegistrationFailures.LastEspPhase;
|
||||
if (!string.IsNullOrEmpty(priorRegistrationFailures.LastOobeState))
|
||||
data["priorRegistrationLastOobeState"] = priorRegistrationFailures.LastOobeState;
|
||||
if (priorRegistrationFailures.RebootedSince.HasValue)
|
||||
data["priorRegistrationRebootedSince"] = priorRegistrationFailures.RebootedSince.Value;
|
||||
if (priorRegistrationFailures.CertChanged.HasValue)
|
||||
data["priorRegistrationCertChanged"] = priorRegistrationFailures.CertChanged.Value;
|
||||
}
|
||||
|
||||
post.Emit(new EnrollmentEvent
|
||||
|
||||
@@ -92,14 +92,14 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// <c>terminate_session</c> <see cref="ServerAction"/> with
|
||||
/// <c>forceSelfDestruct=true</c> and routes it through the dispatcher, overriding
|
||||
/// local <c>SelfDestructOnComplete=false</c>.</item>
|
||||
/// <item><b>AdminAction=Succeeded</b> — informational. Admin clicked Mark-Succeeded in
|
||||
/// the portal. Emits a single <c>admin_marked_session</c> timeline entry and lets
|
||||
/// the agent finish its own path.</item>
|
||||
/// <item><b>AdminAction=Failed</b> — soft admin-driven shutdown. Calls the agent's own
|
||||
/// <see cref="EnrollmentTerminationHandler.Handle"/> with <c>outcome=Failed</c>,
|
||||
/// which runs the same analyzers / diagnostics / summary / self-destruct sequence
|
||||
/// as a locally-detected failure. Self-destruct is governed by local
|
||||
/// <c>SelfDestructOnComplete</c> — no forced override.</item>
|
||||
/// <item><b>AdminAction=Succeeded|Failed</b> — soft admin-driven shutdown. Admin clicked
|
||||
/// Mark-Succeeded / Mark-Failed in the portal. Calls the agent's own
|
||||
/// <see cref="EnrollmentTerminationHandler.Handle"/> with that outcome, which runs the
|
||||
/// same analyzers / diagnostics / summary / self-destruct sequence as a locally-detected
|
||||
/// completion or failure. Self-destruct is governed by local
|
||||
/// <c>SelfDestructOnComplete</c> — no forced override. The backend repeats AdminAction
|
||||
/// on every response, so only the first one per process is acted on
|
||||
/// (<see cref="HandleAdminAction"/>).</item>
|
||||
/// <item><b>upload.Actions[]</b> — genuinely backend-queued <see cref="ServerAction"/>s
|
||||
/// (<c>rotate_config</c>, <c>request_diagnostics</c>). Still routed through the
|
||||
/// dispatcher because these are real actions, not admin-button echoes.</item>
|
||||
@@ -130,6 +130,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
orchestrator.Transport.SessionGone += (sender, reason) =>
|
||||
HandleSessionGone(reason, emergencyReporter, dispatcher, shutdown, shutdownComplete, logger);
|
||||
|
||||
var adminActionLatch = new AdminActionLatch();
|
||||
|
||||
orchestrator.Transport.ServerResponseReceived += (sender, upload) =>
|
||||
{
|
||||
if (shutdownComplete.IsSet)
|
||||
@@ -163,17 +165,17 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
try { return orchestrator.CurrentState?.Stage.ToString(); }
|
||||
catch (InvalidOperationException) { return null; }
|
||||
};
|
||||
Action<EnrollmentTerminatedEventArgs> onAdminFailed = args =>
|
||||
Action<EnrollmentTerminatedEventArgs> onAdminTerminal = args =>
|
||||
{
|
||||
var handler = terminationHandlerAccessor();
|
||||
if (handler == null)
|
||||
{
|
||||
logger.Warning("AdminAction=Failed received but terminationHandler not constructed yet — ignoring.");
|
||||
logger.Warning($"AdminAction={args.Outcome} received but terminationHandler not constructed yet — ignoring.");
|
||||
return;
|
||||
}
|
||||
handler.Handle(sender: null, args: args);
|
||||
};
|
||||
HandleAdminAction(upload.AdminAction, stageNameAccessor, post, onAdminFailed, agentConfig, logger);
|
||||
HandleAdminAction(upload.AdminAction, adminActionLatch, stageNameAccessor, post, onAdminTerminal, agentConfig, logger);
|
||||
}
|
||||
|
||||
if (upload.Actions != null && upload.Actions.Count > 0)
|
||||
@@ -265,16 +267,30 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// Soft admin-override handling. Portal Mark-Succeeded / Mark-Failed is an echo of an
|
||||
/// admin button click, not a ServerAction: it bypasses the dispatcher (no
|
||||
/// <c>server_action_received / _executed</c> noise) and flows straight into the agent's
|
||||
/// own timeline / termination pipeline.
|
||||
/// own timeline / termination pipeline — one <c>admin_marked_session</c> entry, then
|
||||
/// <paramref name="onAdminTerminal"/> with the admin's outcome.
|
||||
/// <para>
|
||||
/// The backend repeats AdminAction on every telemetry response while the session row
|
||||
/// carries it, so <paramref name="latch"/> admits only the first one per process. Acting
|
||||
/// on every echo looped: each entry is uploaded immediately, and its response carried
|
||||
/// the next echo.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static void HandleAdminAction(
|
||||
string adminAction,
|
||||
AdminActionLatch latch,
|
||||
Func<string> stageNameAccessor,
|
||||
InformationalEventPost post,
|
||||
Action<EnrollmentTerminatedEventArgs> onAdminFailed,
|
||||
Action<EnrollmentTerminatedEventArgs> onAdminTerminal,
|
||||
AgentConfiguration agentConfig,
|
||||
AgentLogger logger)
|
||||
{
|
||||
if (!latch.TryEnter())
|
||||
{
|
||||
logger.Debug($"Backend repeated AdminAction={adminAction} — already handled in this process, ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
var outcome = string.Equals(adminAction, "Succeeded", StringComparison.OrdinalIgnoreCase)
|
||||
? EnrollmentTerminationOutcome.Succeeded
|
||||
: EnrollmentTerminationOutcome.Failed;
|
||||
@@ -311,8 +327,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
logger.Warning($"admin_marked_session emit failed: {ex.Message}");
|
||||
}
|
||||
|
||||
if (outcome != EnrollmentTerminationOutcome.Failed) return;
|
||||
if (onAdminFailed == null) return;
|
||||
if (onAdminTerminal == null) return;
|
||||
|
||||
string stageName = null;
|
||||
try { stageName = stageNameAccessor?.Invoke(); }
|
||||
@@ -320,19 +335,31 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
|
||||
try
|
||||
{
|
||||
onAdminFailed(new EnrollmentTerminatedEventArgs(
|
||||
onAdminTerminal(new EnrollmentTerminatedEventArgs(
|
||||
reason: EnrollmentTerminationReason.DecisionTerminalStage,
|
||||
outcome: EnrollmentTerminationOutcome.Failed,
|
||||
outcome: outcome,
|
||||
stageName: stageName,
|
||||
terminatedAtUtc: DateTime.UtcNow,
|
||||
details: "Admin marked session as Failed via portal."));
|
||||
details: $"Admin marked session as {outcome} via portal."));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error("onAdminFailed (AdminAction=Failed) threw.", ex);
|
||||
logger.Error($"onAdminTerminal (AdminAction={adminAction}) threw.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Admits the first <see cref="HandleAdminAction"/> call of the process. Atomic: responses
|
||||
/// are raised on whichever thread ran the drain — the drain loop or the terminal drain —
|
||||
/// so two can arrive at once.
|
||||
/// </summary>
|
||||
internal sealed class AdminActionLatch
|
||||
{
|
||||
private int _entered;
|
||||
|
||||
public bool TryEnter() => Interlocked.Exchange(ref _entered, 1) == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <c>terminate_session</c> ServerAction's <c>adminOutcome</c> param onto an
|
||||
/// <see cref="EnrollmentTerminationOutcome"/>. Portal Mark-Succeeded was previously
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user