mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-27 14:07:35 +00:00
Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ba4a10031 | |||
| fd0ae98868 | |||
| 8101aa1e50 | |||
| cf3647da49 | |||
| cafb1e9a33 | |||
| d38265071d | |||
| 40d88ef1e0 | |||
| 14d1266b89 | |||
| 0730373e9b | |||
| 03f4470db6 | |||
| 697077c7e5 | |||
| befc101d8f | |||
| 7250baca02 | |||
| fa1809d910 | |||
| 9a718539fd | |||
| a1ba1fd1ab | |||
| 94399170d8 | |||
| 824bc64f96 | |||
| a7c3c16054 | |||
| 525be338b8 | |||
| 734ca2b8fc | |||
| 2a74395874 | |||
| 012eb8c057 | |||
| 0c1b83268c | |||
| 1088b36b8b | |||
| dfd1b86e01 | |||
| b8a663e11d | |||
| 38b499b80b | |||
| 9252b8081f | |||
| ba92811f45 | |||
| 0b813891e4 | |||
| dc4f6ed28b | |||
| 3564e5ba88 | |||
| de00471da9 | |||
| 9b3e9e3900 | |||
| 58e9f19f82 | |||
| 8e000f1849 | |||
| 77de8c92c6 | |||
| 17fc23f8cb | |||
| e046571350 | |||
| 6b5c55998f | |||
| 6df57459c2 | |||
| caeb7a3e58 | |||
| bf37fe46c1 | |||
| 3678995a9b | |||
| 7a917eeaee | |||
| d5a83374d0 | |||
| 596a9ffc2b | |||
| 0e1428922f | |||
| 10314cd8f2 | |||
| 9ee882ab68 | |||
| eefa365b6c | |||
| 21ca9eb068 | |||
| af86e8f7cc | |||
| 8c5fbd5739 | |||
| 55b667ad2c | |||
| 325632e780 | |||
| 9ad3cb2df1 | |||
| 127d5c1173 | |||
| 6df88abdb3 | |||
| 5c3d158d85 | |||
| 3bedc65ed5 | |||
| e5cb3c4f5e | |||
| d7b094852d | |||
| 516cdc71a7 | |||
| 4a822767d4 | |||
| f82eabd6eb | |||
| 1892da30e4 | |||
| fd44518e14 | |||
| d95640c597 | |||
| b5e9f45a46 | |||
| b4dabcce94 | |||
| 6bbdcc0313 | |||
| 283c094986 | |||
| 47728fbcd4 | |||
| d5f885c4b4 | |||
| dd4975b73e | |||
| 18310b05aa | |||
| 40894ed5e7 | |||
| 6894e71668 | |||
| a28fdc874f | |||
| 7480a2e218 | |||
| 96fc88462d | |||
| 6b86549fd0 |
+1
-1
Submodule .claude/commands updated: d7b195b1ae...2f276c1f9c
@@ -7,6 +7,14 @@ on:
|
||||
description: 'Build number override (leave empty to auto-increment from buildcounter.txt)'
|
||||
required: false
|
||||
type: string
|
||||
channel:
|
||||
description: 'release = versioned line (+ stable on cutover). dev = lab channel only (Agent-v2-dev.zip, version-v2-dev.json, Agent-dev.zip, version-dev.json); needs publish_as_stable=false and create_release=false'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- release
|
||||
- dev
|
||||
default: release
|
||||
upload_blob:
|
||||
description: 'Upload to versioned namespace (Agent-v2.zip + version-v2.json)'
|
||||
required: true
|
||||
@@ -60,6 +68,15 @@ env:
|
||||
VERSIONED_ZIP: AutopilotMonitor-Agent-v2.zip
|
||||
STABLE_ZIP: AutopilotMonitor-Agent.zip
|
||||
STABLE_MANIFEST: version.json
|
||||
# Lab channel (channel=dev): the names the dev renders of the bootstrap scripts read.
|
||||
CHANNEL: ${{ inputs.channel }}
|
||||
DEV_VERSIONED_ZIP: AutopilotMonitor-Agent-v2-dev.zip
|
||||
DEV_MANIFEST: version-v2-dev.json
|
||||
DEV_STABLE_ZIP: AutopilotMonitor-Agent-dev.zip
|
||||
DEV_STABLE_MANIFEST: version-dev.json
|
||||
# Signed catalog over every other file of the payload. Must equal
|
||||
# Constants.AgentPayloadCatalogFileName; CodeSigningParityTests keeps them in step.
|
||||
PAYLOAD_CATALOG: AutopilotMonitor.Agent.cat
|
||||
# Authenticode via Azure Key Vault; identity and vault come from repository variables, the
|
||||
# timestamp server (CODE_SIGN_TIMESTAMP_URL) from the endpoint registry like every host here.
|
||||
# CODE_SIGN_EXPECTED_SUBJECT is optional and pins the publisher in the verify step.
|
||||
@@ -106,6 +123,22 @@ jobs:
|
||||
}
|
||||
Write-Host "Code-signing configuration present (certificate '$env:CODE_SIGN_CERTIFICATE_NAME')"
|
||||
|
||||
# The dev channel is a lab channel: it never touches stable, AdminConfiguration, the
|
||||
# bootstrap scripts or a GitHub Release. Refused here, before a build number is burnt.
|
||||
- name: Check channel inputs
|
||||
shell: pwsh
|
||||
env:
|
||||
PUBLISH_AS_STABLE: ${{ inputs.publish_as_stable }}
|
||||
CREATE_RELEASE: ${{ inputs.create_release }}
|
||||
run: |
|
||||
if ($env:CHANNEL -notin @('release', 'dev')) {
|
||||
throw "Unknown channel '$($env:CHANNEL)'."
|
||||
}
|
||||
if ($env:CHANNEL -eq 'dev' -and ($env:PUBLISH_AS_STABLE -eq 'true' -or $env:CREATE_RELEASE -eq 'true')) {
|
||||
throw "channel=dev publishes the lab channel only: run it with publish_as_stable=false and create_release=false."
|
||||
}
|
||||
Write-Host "Channel: $env:CHANNEL"
|
||||
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
@@ -222,7 +255,8 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
# Trust zone: only this repository's scripts, pinned actions and the pinned signing tool
|
||||
# run here -- never a restore or build. The build output arrives as data.
|
||||
# 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.
|
||||
@@ -353,8 +387,7 @@ jobs:
|
||||
throw "azuresigntool failed with exit code $LASTEXITCODE -- aborting before anything is published."
|
||||
}
|
||||
|
||||
- name: Create release artifact
|
||||
id: artifact
|
||||
- name: Stage release payload
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = $env:AGENT_VERSION
|
||||
@@ -399,6 +432,98 @@ jobs:
|
||||
}
|
||||
Write-Host "Version check passed: EXE = manifest = reserved = $version"
|
||||
|
||||
# Third-party code enters the payload only with its vendor's own signature: every PE that
|
||||
# is not ours needs a valid EMBEDDED Authenticode signature from an allowed vendor. Embedded
|
||||
# only (signtool verify /pa, no /a): Get-AuthenticodeSignature prefers the runner's Windows
|
||||
# catalogs and reports "Microsoft Windows" for files Windows happens to ship as well. An
|
||||
# unsigned file or a new vendor stops the release until a person decides. This guards the
|
||||
# bytes we did not write; a build that alters our own code is D-346's remaining risk.
|
||||
- name: Check third-party signatures
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$allowedVendors = @('Microsoft Corporation', 'Json.NET (.NET Foundation)')
|
||||
|
||||
$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Directory.Parent.Name -match '^\d+(\.\d+){1,3}$' } |
|
||||
Sort-Object { [version]$_.Directory.Parent.Name } -Descending |
|
||||
Select-Object -First 1
|
||||
if (-not $signtool) { throw "signtool.exe not found in the runner image's Windows SDK." }
|
||||
Write-Host "Using $($signtool.FullName)"
|
||||
|
||||
$failed = $false
|
||||
$checked = 0
|
||||
foreach ($file in Get-ChildItem -Path 'staging/agent' -Recurse -File) {
|
||||
# A PE by content (MZ header), not by extension.
|
||||
$head = [byte[]]::new(2)
|
||||
$stream = [System.IO.File]::OpenRead($file.FullName)
|
||||
try { $read = $stream.Read($head, 0, 2) } finally { $stream.Dispose() }
|
||||
if ($read -lt 2 -or $head[0] -ne 0x4D -or $head[1] -ne 0x5A) { continue }
|
||||
if ($file.Name -like 'AutopilotMonitor.*') { continue } # ours: the verify step pins them
|
||||
|
||||
$checked++
|
||||
$output = & $signtool.FullName verify /pa $file.FullName 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "::error file=$($file.Name)::No valid embedded vendor signature. $(($output | Out-String).Trim())"
|
||||
$failed = $true
|
||||
continue
|
||||
}
|
||||
$subject = [System.Security.Cryptography.X509Certificates.X509Certificate]::CreateFromSignedFile($file.FullName).Subject
|
||||
$vendor = if ($subject -match '(?:^|,\s*)O=("[^"]*"|[^,]*)') { $Matches[1].Trim('"') } else { '' }
|
||||
if ($vendor -notin $allowedVendors) {
|
||||
Write-Host "::error file=$($file.Name)::Signed by '$subject' -- vendor '$vendor' is not on the allowlist."
|
||||
$failed = $true
|
||||
continue
|
||||
}
|
||||
Write-Host "$($file.Name): embedded signature valid, vendor $vendor"
|
||||
}
|
||||
|
||||
if ($failed) { throw "Third-party signature check failed. Nothing was published." }
|
||||
Write-Host "All $checked third-party PE files carry a valid signature from an allowed vendor."
|
||||
|
||||
# The payload contract: one catalog, signed like our binaries, holding the SHA-256 of every
|
||||
# other file in the ZIP. Installer, OOBE script and self-updater accept a payload only when
|
||||
# this catalog is ours and matches the extracted files exactly. Created AFTER signing and
|
||||
# BEFORE the ZIP -- the same load-bearing position as the signing step above.
|
||||
- name: Create and sign payload catalog
|
||||
shell: pwsh
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$stagingDir = 'staging/agent'
|
||||
$catalog = Join-Path $env:RUNNER_TEMP $env:PAYLOAD_CATALOG
|
||||
if (Test-Path (Join-Path $stagingDir $env:PAYLOAD_CATALOG)) {
|
||||
throw "$stagingDir already contains $env:PAYLOAD_CATALOG."
|
||||
}
|
||||
|
||||
New-FileCatalog -Path $stagingDir -CatalogFilePath $catalog -CatalogVersion 2.0 | Out-Null
|
||||
|
||||
& azuresigntool.exe sign --verbose `
|
||||
--azure-key-vault-url "$env:CODE_SIGN_KEY_VAULT_URL" `
|
||||
--azure-key-vault-accesstoken "$env:CODE_SIGN_AKV_ACCESS_TOKEN" `
|
||||
--azure-key-vault-certificate "$env:CODE_SIGN_CERTIFICATE_NAME" `
|
||||
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
|
||||
--file-digest sha256 `
|
||||
--description "Autopilot Monitor Agent" `
|
||||
--description-url "$env:WWW_BASE_URL" `
|
||||
$catalog
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "azuresigntool failed on the payload catalog with exit code $LASTEXITCODE -- aborting before anything is published."
|
||||
}
|
||||
|
||||
Copy-Item -Path $catalog -Destination $stagingDir
|
||||
$count = (Test-FileCatalog -Path $stagingDir -CatalogFilePath $catalog -FilesToSkip $env:PAYLOAD_CATALOG -Detailed).CatalogItems.Count
|
||||
Write-Host "Payload catalog signed: $count files"
|
||||
|
||||
- name: Create release artifact
|
||||
id: artifact
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = $env:AGENT_VERSION
|
||||
$stagingDir = "staging/agent"
|
||||
$manifest = Get-Content "$env:AGENT_OUTPUT/$env:MANIFEST_NAME" -Raw | ConvertFrom-Json
|
||||
|
||||
$versionedZip = "AutopilotMonitor-Agent-v$version.zip"
|
||||
Compress-Archive -Path "$stagingDir/*" -DestinationPath $versionedZip
|
||||
Write-Host "Created GitHub-Release artifact: $versionedZip"
|
||||
@@ -488,6 +613,52 @@ jobs:
|
||||
}
|
||||
Write-Host "All $($files.Count) binaries signed with certificate $($thumbprints.Keys -join ', ')"
|
||||
|
||||
# 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 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:
|
||||
RELEASE_ZIP: ${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
|
||||
$payload = Join-Path $env:RUNNER_TEMP 'verify-payload'
|
||||
if (Test-Path $payload) { throw "$payload already exists." }
|
||||
[System.IO.Compression.ZipFile]::ExtractToDirectory((Resolve-Path $env:RELEASE_ZIP).Path, $payload)
|
||||
|
||||
. (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'."
|
||||
}
|
||||
|
||||
# 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)
|
||||
|
||||
# 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'
|
||||
}
|
||||
$exeSigner = (Get-AuthenticodeSignature (Join-Path $payload 'AutopilotMonitor.Agent.exe')).SignerCertificate
|
||||
if ($signature.SignerCertificate -and $exeSigner -and $signature.SignerCertificate.Thumbprint -ne $exeSigner.Thumbprint) {
|
||||
$problems += 'catalog and agent exe carry different signing certificates'
|
||||
}
|
||||
|
||||
if ($problems.Count -gt 0) {
|
||||
$problems | ForEach-Object { Write-Host "::error::$_" }
|
||||
throw "Payload catalog verification failed. Nothing was published."
|
||||
}
|
||||
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
|
||||
shell: pwsh
|
||||
@@ -531,6 +702,18 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
# Dev channel: lab blobs only, under the names the dev renders of the bootstrap scripts
|
||||
# read. Nothing else is touched -- no versioned or stable line, no AdminConfiguration, no
|
||||
# scripts; the channel check in `reserve` already refused publish_as_stable/create_release.
|
||||
if ($env:CHANNEL -eq 'dev') {
|
||||
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:DEV_VERSIONED_ZIP -ContentType "application/zip"
|
||||
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:DEV_MANIFEST -ContentType "application/json"
|
||||
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:DEV_STABLE_ZIP -ContentType "application/zip"
|
||||
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:DEV_STABLE_MANIFEST -ContentType "application/json"
|
||||
Write-Host "DEV CHANNEL: lab blobs written, nothing else touched."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Versioned namespace (always)
|
||||
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:VERSIONED_ZIP -ContentType "application/zip"
|
||||
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:MANIFEST_NAME -ContentType "application/json"
|
||||
@@ -636,7 +819,7 @@ jobs:
|
||||
- **Published as stable:** ${{ inputs.publish_as_stable }}
|
||||
|
||||
### Provenance
|
||||
The executables are Authenticode-signed and timestamped. The ZIP and both executables carry a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
|
||||
The executables are Authenticode-signed and timestamped. Every file in the ZIP is listed in `AutopilotMonitor.Agent.cat`, a catalog signed with the same certificate, and third-party libraries carry their vendor's signature. The ZIP and both executables carry a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
|
||||
```
|
||||
gh attestation verify AutopilotMonitor-Agent-v${{ needs.reserve.outputs.version }}.zip --repo okieselbach/AutopilotMonitor
|
||||
```
|
||||
|
||||
+1
-1
Submodule infra updated: 0af4689368...0085deab45
+1
-1
Submodule internal updated: 5fb01d6e4d...144e8f6fd8
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when a Win32 app detection script fails or returns unexpected results.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -38,6 +38,26 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{detection_failure.appName}} was not detected after its install",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{detection_failure.appId}}",
|
||||
"name": "{{detection_failure.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Detection",
|
||||
"value": "{{detection_failure.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{detection_failure.errorCode}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"detection",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects app installation failures with specific exit codes or HRESULT error codes and suggests fixes.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -41,6 +41,27 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{install_error.appName}} failed to install",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{install_error.appId}}",
|
||||
"name": "{{install_error.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Exit code",
|
||||
"value": "{{install_error.exitCode}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{install_error.errorCode}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"msi",
|
||||
|
||||
@@ -33,9 +33,25 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{dependency_failure.appName}} failed because a dependency did not install",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{dependency_failure.appId}}",
|
||||
"name": "{{dependency_failure.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{dependency_failure.errorCode}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"dependency"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
"version": "1.2.0"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when disk space is critically low during enrollment, which can cause app installation failures. Confidence increases when app installations actually failed while disk space was low.",
|
||||
"severity": "critical",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -71,6 +71,27 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "Free disk space dropped to {{low_disk.value}} GB",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "disk",
|
||||
"name": "Disk space"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Free space (GB)",
|
||||
"value": "{{low_disk.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Measured",
|
||||
"value": "{{low_disk.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"disk-space",
|
||||
|
||||
@@ -33,10 +33,26 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "The content download of {{download_timeout.appName}} timed out or failed",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{download_timeout.appId}}",
|
||||
"name": "{{download_timeout.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{download_timeout.errorCode}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"download",
|
||||
"timeout"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
"version": "1.2.0"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the same app installation is attempted 3 or more times during one enrollment — a retry loop caused by unhonored reboot requests, repeated failures, or detection mismatches.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -35,6 +35,27 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{install_retry_loop.appName}} was started {{install_retry_loop.count}} times",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{install_retry_loop.groupKey}}",
|
||||
"name": "{{install_retry_loop.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Install starts",
|
||||
"value": "{{install_retry_loop.count}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "First start",
|
||||
"value": "{{install_retry_loop.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"reboot",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the app tracking summary reports multiple failed app installations.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -50,6 +50,17 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{summary_errors.value}} app installations failed",
|
||||
"facts": [
|
||||
{
|
||||
"label": "Failed apps",
|
||||
"value": "{{summary_errors.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"summary",
|
||||
|
||||
@@ -32,11 +32,32 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{slow_app_install.appName}} took {{slow_app_install.durationFormatted}} to install",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{slow_app_install.appId}}",
|
||||
"name": "{{slow_app_install.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Install time",
|
||||
"value": "{{slow_app_install.durationSeconds}}",
|
||||
"format": "duration"
|
||||
},
|
||||
{
|
||||
"label": "Started",
|
||||
"value": "{{slow_app_install.startTimestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"performance",
|
||||
"slow-install",
|
||||
"warning"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
"version": "1.2.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-APP-010",
|
||||
"version": "1.1.0",
|
||||
"title": "App Reverted to Error After Successful Install",
|
||||
"description": "Detects when a specific app (matched by appId) was first reported as successfully installed but subsequently reported an IME enforcement error — while enrollment completed successfully. The app may be broken post-enrollment.",
|
||||
"severity": "warning",
|
||||
@@ -56,5 +57,31 @@
|
||||
{ "title": "Troubleshoot Win32 app installations", "url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install" },
|
||||
{ "title": "Intune Management Extension logs", "url": "https://learn.microsoft.com/en-us/mem/intune/apps/intune-management-extension" }
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{app_regression.eventA_appName}} installed, then reported an error",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{app_regression.joinValue}}",
|
||||
"name": "{{app_regression.eventA_appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Installed",
|
||||
"value": "{{app_regression.eventA_timestamp}}",
|
||||
"format": "timestamp"
|
||||
},
|
||||
{
|
||||
"label": "Error after",
|
||||
"value": "{{app_regression.timeDeltaSeconds}}",
|
||||
"format": "duration"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{app_regression.eventB_errorCode}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["apps", "win32", "enforcement", "post-install", "correlation"]
|
||||
}
|
||||
|
||||
@@ -43,6 +43,32 @@
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-app-management#step-4-return-codes"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "{{enforcement_error_resolved.eventA_appName}} reported an install error but was detected as installed",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{enforcement_error_resolved.joinValue}}",
|
||||
"name": "{{enforcement_error_resolved.eventA_appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Exit code",
|
||||
"value": "{{enforcement_error_resolved.eventA_exitCode}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{enforcement_error_resolved.eventA_errorCode}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "Detected installed after",
|
||||
"value": "{{enforcement_error_resolved.timeDeltaSeconds}}",
|
||||
"format": "duration"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"enforcement",
|
||||
@@ -50,5 +76,5 @@
|
||||
"unmapped",
|
||||
"resolved"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
"version": "1.2.0"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the Enrollment Status Page failed because Intune could not verify an app was installed (HRESULT 0x87D1041C). The installer ran but the detection rule did not match afterwards.",
|
||||
"severity": "critical",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -82,6 +82,26 @@
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-add#step-4-detection-rules"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{app_with_detection_failure.appName}} installed, but its detection rule did not find it",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{app_with_detection_failure.appId}}",
|
||||
"name": "{{app_with_detection_failure.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "ESP error",
|
||||
"value": "{{esp_failure_errorcode.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "Detection",
|
||||
"value": "{{app_with_detection_failure.value}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"detection",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the Enrollment Status Page failed on the Apps subcategory and the agent's AppX deployment log scan identified a suspected MSIX/Store package. Store/MSIX apps install outside the Win32 pipeline, so the regular per-app tracking sees nothing — this correlation names the likely culprit.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -123,6 +123,36 @@
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/store-apps-microsoft"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "A Store or MSIX app failed during the ESP: {{appx_candidate.value}}",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{appx_candidate.value}}",
|
||||
"name": "{{appx_candidate.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "ESP error",
|
||||
"value": "{{appx_esp_errorcode.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "ESP phase",
|
||||
"value": "{{appx_esp_category.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Package",
|
||||
"value": "{{appx_top_candidate_package.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Candidates",
|
||||
"value": "{{appx_candidate_count.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"appx",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "The IME reported its user-session completion line, but at least one required app stayed pending — without ever failing or even starting to install — so the agent kept deferring the enrollment completion signal. Typically an assignment/requirement problem (requirement rules never met, dependency never satisfied, or the app needs a context that is not available during ESP, e.g. a VPN client waiting for user sign-in to a portal).",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
@@ -76,5 +76,20 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "Completion waited on {{apps_pending.value}} pending app(s)",
|
||||
"facts": [
|
||||
{
|
||||
"label": "Pending apps",
|
||||
"value": "{{apps_pending.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Apps",
|
||||
"value": "{{pending_app_names.value}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["apps", "esp", "completion", "assignment", "pending"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "One or more required user-ESP apps never started installing while the ESP AccountSetup apps gate waited on them — the agent observed the ESP page exit with those apps still in a non-terminal, never-started state. Unlike ANALYZE-APP-015 (which reports that completion was DEFERRED because of a pending app, and stays silent once the session reaches a terminal verdict) this rule reports the delivery gap itself and fires regardless of the final outcome: an enrollment that Windows called done while required apps never ran is a successful enrollment with missing software, and the operator must see it.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -102,6 +102,26 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "Required apps never started installing; first: {{appName.value}}",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{appName.appId}}",
|
||||
"name": "{{appName.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "First app",
|
||||
"value": "{{appName.value}}"
|
||||
},
|
||||
{
|
||||
"label": "State",
|
||||
"value": "{{state.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"esp",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects Win32 app installs that failed because the Windows Installer was busy with another installation (HRESULT 0x80070652 / MSI exit code 1618, ERROR_INSTALL_ALREADY_RUNNING). The IME retries on a fixed budget (~15-17 minutes across several attempts) and then reports the app as failed; when the app is ESP-blocking, the Device Setup Apps subcategory terminates with it.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"baseConfidence": 70,
|
||||
@@ -81,6 +81,27 @@
|
||||
"url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{mutex_blocked_install.appName}} was blocked by another installation",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{mutex_blocked_install.appId}}",
|
||||
"name": "{{mutex_blocked_install.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Exit code",
|
||||
"value": "{{installer_exit_1618.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "ESP error",
|
||||
"value": "{{esp_failed_with_mutex_code.value}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"msiexec",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects Win32 apps whose installer exit code maps to Soft reboot or Hard reboot in the app's Intune return-code table (the '[Win32App] lpExitCode is defined as ...' line). Hard reboot stops app processing and restarts the device in Device Setup - the mid-enrollment restart and second sign-in; Soft reboot leaves the app pending until the next restart while the ESP moves on. Confidence rises for Hard reboot and when a reboot was actually observed in the session.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -86,6 +86,31 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "{{reboot_return_code.appName}} asked for a restart",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{reboot_return_code.appId}}",
|
||||
"name": "{{reboot_return_code.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Return code",
|
||||
"value": "{{reboot_return_code.exitCode}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Return code class",
|
||||
"value": "{{reboot_return_code.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Restarts",
|
||||
"value": "{{reboot_observed.count}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"reboot",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Correlates an active proxy configuration with app content download failures (Delivery Optimization timeout or download error), suggesting the proxy is blocking or throttling Intune content delivery.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -67,6 +67,30 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "App downloads failed while a {{proxy_active.value}} proxy was configured",
|
||||
"subject": {
|
||||
"kind": "network",
|
||||
"key": "proxy",
|
||||
"name": "Proxy"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Proxy type",
|
||||
"value": "{{proxy_active.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Failed app",
|
||||
"value": "{{download_failure.appName}}"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{download_failure.errorCode}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"correlation",
|
||||
"apps",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when Windows Hello for Business provisioning did not start within the expected timeframe after ESP completion.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"baseConfidence": 60,
|
||||
"conditions": [
|
||||
@@ -30,5 +30,21 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "Windows Hello provisioning did not start in time",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "windows-hello",
|
||||
"name": "Windows Hello"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Timed out",
|
||||
"value": "{{hello_timeout.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["device", "hello", "timeout"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when memory usage stays above 90% across at least 3 performance snapshots during enrollment, indicating sustained memory pressure that can cause app installation failures or system instability.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -65,6 +65,22 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "Memory use was above 90% in {{sustained_high_memory.count}} snapshots",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "memory",
|
||||
"name": "Memory"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Snapshots above 90%",
|
||||
"value": "{{sustained_high_memory.count}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"memory",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects devices enrolling with an OS build that has reached end of support: Windows 11 22H2 (build 22621) or older, which includes every Windows 10 build. Maintenance note: raise the build cutoff to 22631 after Windows 11 23H2 reaches end of support on 2026-11-10.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -56,6 +56,26 @@
|
||||
"url": "https://learn.microsoft.com/en-us/lifecycle/products/windows-11-home-and-pro"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "Windows build {{old_build.value}} is out of support",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "os",
|
||||
"name": "Windows version"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "OS",
|
||||
"value": "{{os_version.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Build",
|
||||
"value": "{{old_build.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"os-version",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects a Windows quality/cumulative update that FAILED to install or download during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20 from the System log, 31 from the Operational channel). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
|
||||
"severity": "high",
|
||||
"category": "device",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -113,6 +113,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "A Windows update failed during setup: {{wu_update_title.value}}",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "windows-update",
|
||||
"name": "Windows Update"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{wu_hresult.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "Symbol",
|
||||
"value": "{{wu_hresult_symbol.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Informational: a Windows quality/cumulative update installed during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 19). Surfaces updates applied mid-enrollment — useful context when correlating enrollment duration, reboots, or later issues with a specific KB.",
|
||||
"severity": "info",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -61,6 +61,22 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "A Windows update installed during setup: {{wu_update_title.value}}",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "windows-update",
|
||||
"name": "Windows Update"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Installed",
|
||||
"value": "{{wu_installed.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Informational: the OS build (CurrentBuild.UBR) changed across a reboot during enrollment — an update was installed, regardless of which servicing path delivered it. Deterministic corroboration that works even when the Windows Update event channel shows nothing (registry comparison across restart cannot be lost to timing or logging-path differences).",
|
||||
"severity": "info",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -69,6 +69,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "Windows was updated from build {{previous_build.value}} to {{current_build.value}}",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "os",
|
||||
"name": "Windows version"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Build before",
|
||||
"value": "{{previous_build.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Build after",
|
||||
"value": "{{current_build.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "The device's real-time clock deviates from NTP by more than two minutes. Microsoft documents that a clock off by several minutes causes TPM attestation errors (self-deploying/pre-provisioning) and ESP timeouts.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -59,6 +59,26 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "clock",
|
||||
"name": "System clock"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Clock offset (s)",
|
||||
"value": "{{clock_behind.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Clock offset (s)",
|
||||
"value": "{{clock_ahead.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"ntp",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "The device's clock changed by five or more minutes mid-session (clock jump) or ran five or more minutes off server time for the whole session (sustained offset), measured against the server receive frame of every uploaded event. Complements ANALYZE-DEV-007, which is a one-shot NTP check at agent startup and misses devices where NTP was unreachable or the clock changed later. IME-log-derived events are excluded from the measurement.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -57,6 +57,26 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "clock",
|
||||
"name": "System clock"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Clock jump (min)",
|
||||
"value": "{{clock_jump.jumpMinutes}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Clock offset (min)",
|
||||
"value": "{{sustained_offset.offsetMinutes}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"clock-skew",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects that the device's battery dropped to 15% or less while enrolling on battery power (power_state_change threshold event from the agent's live power watcher). A device dying mid-enrollment leaves a half-provisioned state that typically needs a full reset; this rule surfaces the risk while there is still time to plug the device in. Uses a single required condition on the agent-stamped thresholdPercent field (the data field 'transition' on the event is unrelated to this rule's 'trigger' property).",
|
||||
"severity": "high",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -39,6 +39,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "The battery fell below {{battery_threshold_15.value}}% during setup",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "power",
|
||||
"name": "Power"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Battery below (%)",
|
||||
"value": "{{battery_threshold_15.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Reported",
|
||||
"value": "{{battery_threshold_15.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"power",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects that the device lost AC power and switched to battery while the enrollment was running (power_state_change event with transition ac_to_battery from the agent's live power watcher). Uses a single required condition so the match is pinned to one event instance; the event payload carries the battery percentage at the moment of the switch. The agent debounces power flapping (5s) so a brief unplug/replug does not produce this event.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -39,6 +39,22 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "The device switched from AC to battery power during setup",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "power",
|
||||
"name": "Power"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Switched",
|
||||
"value": "{{switched_to_battery.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"power",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects that the device completed a sleep, hibernate or Modern Standby episode of at least 5 minutes while the enrollment was running (system_sleep_episode event from the agent's system timeline watcher, recorded retroactively at wake from the Windows event log). The watcher also backfills pre-agent episodes from the last 24 h for timeline context (backfilled=true); those ended before the enrollment started and are excluded via the same-event filter backfilled equals false — a separate condition could not do that, because each condition scans all events on its own and a pre-session hibernate would still satisfy the duration test. A single required condition keeps the match pinned to one event instance so the interpolated duration is the matched value. Short Modern Standby screen-off scenarios (under 60 s of real sleep) never emit the event, and this rule adds a 5-minute floor so brief lid-close moments do not fire it.",
|
||||
"severity": "info",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -42,6 +42,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "The device went to sleep during setup",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "power",
|
||||
"name": "Power"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Asleep for",
|
||||
"value": "{{slept_during_enrollment.value}}",
|
||||
"format": "duration"
|
||||
},
|
||||
{
|
||||
"label": "Recorded",
|
||||
"value": "{{slept_during_enrollment.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"power",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects that the Windows quality update page at the end of OOBE (Enrollment Status Page setting \"Install Windows quality updates\") reported a failed download or install and recorded no successful install afterwards — the page's own failure names on the oobe_update_page events. Setup usually continues, so the failure is otherwise invisible: the device finishes OOBE without the monthly security update. An OS build change across a restart (os_build_changed) vetoes the finding, and so does the agent's names_capped marker — once the page's event names were capped, a missing success name proves nothing. A downloadInstallFailureHelper entry alone is no failure: updates that installed fine log it too.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end"
|
||||
@@ -109,6 +109,22 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "The Windows quality update at the end of OOBE failed",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "oobe-update",
|
||||
"name": "OOBE quality update"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Failed step",
|
||||
"value": "{{update_failure.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects that the Windows quality update page at the end of OOBE found no update to install although the device is out of date, on a device whose update page counts an earlier failed download or install (oobe_update_page: NDUPDownloadInstallPreviousFailureCount above 0, osUpdatesWithNDUPPI false, getDaysOutOfDateSucceeded above 0). No failure and no install success are recorded in this session — a failure in this session is ANALYZE-DEV-013 — and the agent's names_capped marker vetoes the finding, because a missing name proves nothing once the page's names were capped. The device finishes OOBE without the monthly security update.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end"
|
||||
@@ -110,6 +110,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "No quality update was offered although the device is {{days_out_of_date.value}} days out of date",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "oobe-update",
|
||||
"name": "OOBE quality update"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Days out of date",
|
||||
"value": "{{days_out_of_date.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Earlier update failures",
|
||||
"value": "{{previous_failures.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects that the Windows quality update page at the end of OOBE started its Windows Update scan but ended without a scan result (oobe_update_page: startWUScanStartedSuccedded, an OobeNDUP stop and no ExpeditedUpdate_getUpdateResults name), on a device that is out of date and allowed to update. In the observed sessions the page ended two minutes after the scan started, on devices more than a year behind. The device finishes OOBE without the monthly security update. The agent's names_capped marker vetoes the finding: once the page's event names were capped, a missing scan result proves nothing.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end"
|
||||
@@ -108,6 +108,22 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "The OOBE update check ended without a result",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "oobe-update",
|
||||
"name": "OOBE quality update"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Days out of date",
|
||||
"value": "{{days_out_of_date.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the Autopilot enrollment has explicitly failed.",
|
||||
"severity": "critical",
|
||||
"category": "enrollment",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"baseConfidence": 95,
|
||||
"conditions": [
|
||||
@@ -57,19 +57,40 @@
|
||||
"confidenceFactors": [
|
||||
{ "signal": "failed_app", "condition": "exists", "weight": 5 }
|
||||
],
|
||||
"explanation": "The Autopilot enrollment has explicitly failed.\n\n**Key facts extracted from this session's events:**\n\n- **Failure reason:** `{{reason}}`\n- **First failed app:** `{{appName}}`\n- **ESP failed subcategory:** `{{failedSubcategory}}`\n- **ESP HRESULT:** `{{errorCode}}`\n\nWhen an ESP Apps-subcategory failure is paired with HRESULT `0x87D1041C`, the device-side install most likely completed but the Intune detection rule did not match afterwards — see the **App Detection Failure During ESP** rule (ANALYZE-APP-013) in this same analysis for the actionable root cause and remediation steps. Any field still shown as a literal `{{…}}` placeholder was simply not recorded for this session — for example, a non-ESP failure path has no ESP failed-subcategory or HRESULT.",
|
||||
"explanation": "The Autopilot enrollment has explicitly failed.\n\n**Key facts from this session's events:**\n\n- **Failure reason:** `{{reason}}`\n- **First failed app:** `{{appName}}`\n- **ESP step that failed:** `{{failedSubcategory}}`\n- **ESP error code:** `{{errorCode}}`\n\nA more specific finding in this analysis usually names the cause, such as an app, ESP or timeout finding.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Investigate enrollment failure",
|
||||
"title": "Find the cause",
|
||||
"steps": [
|
||||
"Start with the more specific ANALYZE-APP-* / ANALYZE-ESP-* rules that fired alongside this one — they correlate specific HRESULTs to actionable causes (e.g. 0x87D1041C → detection rule mismatch in ANALYZE-APP-013)",
|
||||
"Read the `reason` field on the enrollment_failed event for the immediate trigger (e.g. esp_terminal_failure, hello_timeout, max_lifetime)",
|
||||
"If a HRESULT is present, look it up against the ESP error catalog (Microsoft Learn: Autopilot ESP error codes)",
|
||||
"Review other analyze rule results for correlated issues (disk space, network, app failures)",
|
||||
"Check the device event timeline for the sequence of events leading to failure",
|
||||
"Verify the Autopilot profile and ESP configuration in Intune"
|
||||
"Open the finding in this analysis that names the cause (an app, ESP or timeout finding) and follow its steps",
|
||||
"If no other finding fired, look up the ESP error code and check the failed ESP step in the session timeline",
|
||||
"Verify the Autopilot profile and the ESP settings in Intune"
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "outcome",
|
||||
"summary": "The enrollment failed at the ESP step {{esp_failure_subcategory.value}} with {{esp_failure_errorcode.value}}",
|
||||
"facts": [
|
||||
{
|
||||
"label": "Reason",
|
||||
"value": "{{enrollment_failed_reason.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "ESP step",
|
||||
"value": "{{esp_failure_subcategory.value}}"
|
||||
},
|
||||
{
|
||||
"label": "ESP error",
|
||||
"value": "{{esp_failure_errorcode.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "First failed app",
|
||||
"value": "{{failed_app.appName}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["enrollment", "failed", "critical"]
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-ENRL-002",
|
||||
"title": "Session Timed Out",
|
||||
"description": "Detects when the enrollment session was force-failed by the server-side inactivity timeout (no agent telemetry for the configured SessionTimeoutHours window) without ever completing or explicitly failing.",
|
||||
"description": "Detects when the server-side inactivity timeout closed the enrollment session (no agent telemetry for the configured SessionTimeoutHours window) before it ever completed or explicitly failed.",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.2.0",
|
||||
"version": "1.4.0",
|
||||
"enabled": true,
|
||||
"baseConfidence": 80,
|
||||
"preconditions": [
|
||||
@@ -20,6 +20,12 @@
|
||||
"eventType": "enrollment_failed",
|
||||
"operator": "not_exists",
|
||||
"description": "Suppress when an explicit enrollment_failed exists — that path is already covered by ANALYZE-ENRL-001; avoid double-reporting the same session."
|
||||
},
|
||||
{
|
||||
"source": "event_data",
|
||||
"eventType": "device_blocked",
|
||||
"operator": "not_exists",
|
||||
"description": "Suppress when the platform blocked the session's device: the silence is the refused uploads, which ANALYZE-ENRL-006 explains — 'the agent died or the device went offline' would be wrong."
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
@@ -58,7 +64,7 @@
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"explanation": "The enrollment session was force-failed by the server-side maintenance sweep after `{{timeoutHours}}` hour(s) of agent inactivity — the device stopped sending telemetry and never reported completion or an explicit failure.\n\nThis usually means the agent process died or the device went offline mid-enrollment (power off, network loss, bluescreen, reimage) rather than a clean enrollment failure. Because there is no terminal `enrollment_failed` event, the more specific app/ESP rules cannot pinpoint a cause — the evidence simply stops at the last event before the gap.\n\n**If the evidence shows the user desktop was reached and all tracked apps completed**, the device most likely got all its content but the ESP never signaled completion — on hybrid-joined devices the usual culprit is the user session never establishing Entra affinity (Account Setup re-running on every logon). Check whether the hybrid user-affinity finding also fired for this session.",
|
||||
"explanation": "The server-side maintenance sweep closed this session after `{{timeoutHours}}` hour(s) of agent inactivity — the device stopped sending telemetry and never reported completion or an explicit failure.\n\nThis usually means the agent process died or the device went offline mid-enrollment (power off, network loss, bluescreen, reimage) rather than a clean enrollment failure. Because there is no terminal `enrollment_failed` event, the more specific app/ESP rules cannot pinpoint a cause — the evidence simply stops at the last event before the gap.\n\n**If the evidence shows the user desktop was reached and all tracked apps completed**, the device most likely got all its content but the ESP never signaled completion — on hybrid-joined devices the usual culprit is the user session never establishing Entra affinity (Account Setup re-running on every logon). Check whether the hybrid user-affinity finding also fired for this session.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Investigate the silent session",
|
||||
@@ -78,5 +84,21 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "outcome",
|
||||
"summary": "No completion or failure arrived within {{timeoutHours.value}} hours",
|
||||
"facts": [
|
||||
{
|
||||
"label": "Timeout (hours)",
|
||||
"value": "{{timeoutHours.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Desktop reached",
|
||||
"value": "{{desktop_was_reached.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["enrollment", "timeout", "stalled"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "A TPM-attestation-related error code documented in the Microsoft Autopilot known-issues list was observed (0x800705B4, 0x801C03EA, 0x81039001, 0x81039023, 0x81039024, 0x80070490). These block self-deploying and pre-provisioning deployments during 'Securing your hardware'. Observability: attestation precedes MDM enrollment, so in the normal deployment model (agent installed via IME after enrollment) these codes are rarely visible — the rule primarily serves bootstrap-token deployments.",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -80,6 +80,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "TPM attestation failed with a known Autopilot error code",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "tpm",
|
||||
"name": "TPM attestation"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Error code",
|
||||
"value": "{{tpm_hresult_esp_failure.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "Error code",
|
||||
"value": "{{tpm_hresult_esp_advisory.value}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"enrollment",
|
||||
"tpm",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "An enrollment-blocking error code documented in the Microsoft Autopilot known-issues/troubleshooting pages was observed (0x80180014, 0x80180018, 0xC1036501, 0x801C03F3, 0x80070774). Each has a specific, documented cause and fix. Observability: a blocked MDM enrollment normally means no agent gets installed at all (IME install happens after enrollment) — this rule can realistically only fire for bootstrap-token deployments (pre-MDM reporting) or mixed retry scenarios.",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -78,6 +78,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "MDM enrollment was blocked with a known Autopilot error code",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "mdm-enrollment",
|
||||
"name": "MDM enrollment"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Error code",
|
||||
"value": "{{enrl_hresult_esp_failure.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "Error code",
|
||||
"value": "{{enrl_hresult_esp_advisory.value}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"enrollment",
|
||||
"mdm",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "A Microsoft Entra hybrid join Autopilot deployment hit error 0x80004005 — a documented build-dependent known issue, resolved by KB5065789 (25H2, build >= 26200.6725), KB5065426 (24H2, build >= 26100.6584), and KB5070312 (23H2, build >= 22631.6276).",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -103,6 +103,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "Hybrid join hit 0x80004005, a known timeout fixed by Windows updates",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "hybrid-join",
|
||||
"name": "Hybrid join"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Error code",
|
||||
"value": "{{e_fail_esp_failure.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "Error code",
|
||||
"value": "{{e_fail_esp_advisory.value}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"enrollment",
|
||||
"hybrid-join",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-ENRL-006",
|
||||
"title": "Monitoring Paused by a Protective Limit",
|
||||
"description": "Detects when the platform blocked the session's device — automatically after an unusually high event volume, or by the platform operator — so the session's telemetry stopped reaching Autopilot Monitor.",
|
||||
"severity": "warning",
|
||||
"category": "enrollment",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"on_event:device_blocked"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 90,
|
||||
"confidenceThreshold": 50,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "device_blocked",
|
||||
"source": "event_type",
|
||||
"eventType": "device_blocked",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "event_count",
|
||||
"source": "event_data",
|
||||
"eventType": "device_blocked",
|
||||
"dataField": "eventCount",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "event_limit",
|
||||
"source": "event_data",
|
||||
"eventType": "device_blocked",
|
||||
"dataField": "threshold",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "blocked_until",
|
||||
"source": "event_data",
|
||||
"eventType": "device_blocked",
|
||||
"dataField": "blockedUntil",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"explanation": "Autopilot Monitor stopped accepting telemetry from this session. A protective limit keeps a single device that sends far more events than an enrollment normally does from distorting your fleet data or overloading the platform. When a session crosses it, the device's uploads are paused for a limited time; in severe cases the agent ends monitoring and removes itself. The platform operator can apply the same pause by hand.\n\nA pause does not affect the enrollment itself: the agent keeps running and only holds back its uploads, so Windows Autopilot and Intune carry on as usual. While the pause lasts, no events from this session reach Autopilot Monitor, so the timeline and the final status reflect the last state the agent reported before it. A paused session does not affect the device's next enrollment, which starts unblocked. When the agent ended monitoring, the device stays unmonitored until the end time shown on the session page, new enrollments included.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Find what made the session so busy",
|
||||
"steps": [
|
||||
"Open the event timeline and look at which event type repeats most often before the pause — for example a network connection that drops and reconnects every few minutes, or a download that reports progress continuously",
|
||||
"Fix the underlying condition (for example a loose dock, cable or Wi-Fi), then re-enroll the device if you need a complete record of the enrollment",
|
||||
"If no event type stands out, report the session from its page so it can be looked at"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "What happens next",
|
||||
"steps": [
|
||||
"A pause ends on its own at the time shown on the session page; an agent that is still running then resumes uploading",
|
||||
"After a pause, a new enrollment on this device starts unblocked",
|
||||
"After the agent ended monitoring, wait for the end time shown on the session page before re-enrolling if you want the new enrollment monitored"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Autopilot Monitor: Protective Limits",
|
||||
"url": "https://docs.autopilotmonitor.com/concepts/protective-limits"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "outcome",
|
||||
"summary": "Monitoring was paused by a protective limit",
|
||||
"facts": [
|
||||
{
|
||||
"label": "Paused at",
|
||||
"value": "{{device_blocked.timestamp}}",
|
||||
"format": "timestamp"
|
||||
},
|
||||
{
|
||||
"label": "Events in this session",
|
||||
"value": "{{event_count.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Event limit",
|
||||
"value": "{{event_limit.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Paused until",
|
||||
"value": "{{blocked_until.value}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["enrollment", "protective-limit", "blocked"]
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the DeviceSetup phase of the ESP took longer than 30 minutes, indicating a stuck or very slow blocking app. Confidence increases when the phase exceeds 60 minutes.",
|
||||
"severity": "high",
|
||||
"category": "esp",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -47,6 +47,27 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "Device setup took {{esp_stalled.durationFormatted}}",
|
||||
"subject": {
|
||||
"kind": "esp",
|
||||
"key": "{{esp_stalled.phase}}",
|
||||
"name": "Device setup"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Duration",
|
||||
"value": "{{esp_stalled.durationSeconds}}",
|
||||
"format": "duration"
|
||||
},
|
||||
{
|
||||
"label": "Started",
|
||||
"value": "{{esp_stalled.phaseStartTimestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"esp",
|
||||
"blocking",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the Certificates subcategory of the Enrollment Status Page reported a failure, even if the enrollment later appears to have succeeded. Users can click 'Continue anyway' on an ESP error, which masks the underlying certificate provisioning failure — this rule surfaces the transition so it stays visible.",
|
||||
"severity": "high",
|
||||
"category": "esp",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -58,6 +58,22 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "The ESP certificates step failed",
|
||||
"subject": {
|
||||
"kind": "esp",
|
||||
"key": "Certificates",
|
||||
"name": "Certificates"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Failed at",
|
||||
"value": "{{certificates_subcategory_failed.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"esp",
|
||||
"certificates",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects an ESP terminal failure where the ESP gave up while a blocking app was still installing (the agent's own esp_apps_timeout verdict — no per-app HRESULT was reported) on a profile that allows 'Continue anyway'. The enrollment is reported as failed, but the user most likely dismissed the error screen and reached the desktop — a soft failure rather than an unusable device. An ESP failure that carries a specific error code (a failed installer, a detection mismatch) is a different case and stays with the rule that explains that code.",
|
||||
"severity": "warning",
|
||||
"category": "esp",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
@@ -81,5 +81,30 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "The ESP gave up while {{appName.value}} was still installing ('Continue anyway' allowed)",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{appName.appId}}",
|
||||
"name": "{{appName.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "ESP error",
|
||||
"value": "{{enrollment_failed.errorCode}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "ESP time limit (min)",
|
||||
"value": "{{espSyncFailureTimeoutMinutes.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "ESP step",
|
||||
"value": "{{failedSubcategory.value}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["esp", "timeout", "continue-anyway", "soft-failure", "enrollment"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Attributes an observed enrollment reboot to device-assigned MDM policies that Windows flagged as reboot-required. Fires ONLY when both facts are present in the session: at least one actually-observed reboot (system_reboot_detected) AND reboot-required policy flags (mdm_policy_reboot_required). Policy flags alone — e.g. from a user-policy sync during AccountSetup — force no reboot and stay advisory-silent.",
|
||||
"severity": "warning",
|
||||
"category": "esp",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -94,6 +94,31 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "A device policy forced a restart during setup: {{reboot_forcing_policy_uri.value}}",
|
||||
"subject": {
|
||||
"kind": "policy",
|
||||
"key": "{{reboot_forcing_policy_uri.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "First policy",
|
||||
"value": "{{reboot_forcing_policy_uri.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Policies",
|
||||
"value": "{{multiple_reboot_uris.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Restarts",
|
||||
"value": "{{reboot_observed.count}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"esp",
|
||||
"reboot",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects the recovery story: the ESP reported a terminal failure (typically DeviceSetup/Apps), the user most likely pressed 'Try again' on the failure page, the failed step re-ran and completed successfully. The earlier failure did not stick — the enrollment continued past it.",
|
||||
"severity": "info",
|
||||
"category": "esp",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -98,6 +98,34 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "The ESP failed in the {{category.value}} phase, then recovered",
|
||||
"subject": {
|
||||
"kind": "esp",
|
||||
"key": "{{failedSubcategory.value}}",
|
||||
"name": "{{failedSubcategory.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "ESP phase",
|
||||
"value": "{{category.value}}"
|
||||
},
|
||||
{
|
||||
"label": "ESP step",
|
||||
"value": "{{failedSubcategory.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Likely apps",
|
||||
"value": "{{likelyCulpritApps.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Recovered after (min)",
|
||||
"value": "{{minutesSinceFailure.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"esp",
|
||||
"recovery",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -55,6 +55,15 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "The expected machine certificate was not found",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "machine-certificate",
|
||||
"name": "Machine certificate"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"identity",
|
||||
"certificates",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -41,6 +41,30 @@
|
||||
"operator": "equals",
|
||||
"value": "error",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "unexpected_accounts",
|
||||
"source": "event_data",
|
||||
"eventType": "local_admin_analysis",
|
||||
"dataField": "checks.unexpected_accounts",
|
||||
"operator": "not_equals",
|
||||
"value": "",
|
||||
"filterField": "finding",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "unexpected_admin_members",
|
||||
"source": "event_data",
|
||||
"eventType": "local_admin_analysis",
|
||||
"dataField": "checks.unexpected_admin_members",
|
||||
"operator": "not_equals",
|
||||
"value": "",
|
||||
"filterField": "finding",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
@@ -85,6 +109,26 @@
|
||||
}
|
||||
],
|
||||
"relatedDocs": [],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "Unexpected local accounts: {{unexpected_accounts.value}}",
|
||||
"subject": {
|
||||
"kind": "account",
|
||||
"key": "{{unexpected_accounts.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Unexpected accounts",
|
||||
"value": "{{unexpected_accounts.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "In Administrators",
|
||||
"value": "{{unexpected_admin_members.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"identity",
|
||||
"security",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Fires when the vulnerability correlation finds critical-severity CVEs or CISA KEV entries in installed software. This indicates the device has software with known, actively exploited vulnerabilities.",
|
||||
"severity": "critical",
|
||||
"category": "security",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -72,6 +72,27 @@
|
||||
"url": "https://nvd.nist.gov/"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "Installed software has {{critical_vulnerabilities.value}} critical vulnerabilities",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "vulnerabilities",
|
||||
"name": "Installed software"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Critical CVEs",
|
||||
"value": "{{critical_vulnerabilities.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Known exploited",
|
||||
"value": "{{kev_matches.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"vulnerability",
|
||||
"cve",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "On a hybrid-joined device the real user reached the desktop, but the Intune Management Extension never acquired a Microsoft Entra user token for that user within the wait window — repeatedly across reboots when the warning recurs. Without a user token the signed-in user has no Entra user affinity: user-targeted apps and policies cannot apply, Windows Hello for Business cannot provision, and with a user ESP the Account Setup page re-runs on every logon.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
@@ -44,6 +44,24 @@
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "token_failure_count",
|
||||
"source": "event_data",
|
||||
"eventType": "entra_user_affinity_pending",
|
||||
"dataField": "tokenFailureCount",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "token_failure_codes",
|
||||
"source": "event_data",
|
||||
"eventType": "entra_user_affinity_pending",
|
||||
"dataField": "tokenFailureCodes",
|
||||
"operator": "not_equals",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
@@ -96,5 +114,36 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "The user reached the desktop, but Entra user affinity never formed",
|
||||
"subject": {
|
||||
"kind": "account",
|
||||
"key": "entra-user-affinity",
|
||||
"name": "Entra user affinity"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Token failures",
|
||||
"value": "{{token_failure_count.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Token error codes",
|
||||
"value": "{{token_failure_codes.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Reported in agent runs",
|
||||
"value": "{{user_affinity_pending.count}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Desktop reached",
|
||||
"value": "{{user_desktop_reached.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["identity", "hybrid-join", "entra-id", "user-affinity", "esp"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects diagnostics package uploads that were answered by a security appliance's HTML block page instead of the Autopilot Monitor backend. A forward proxy / secure web gateway (SWG) with SSL inspection is intercepting the upload endpoint, so exactly the sessions where diagnostics matter most (OnFailure uploads) are the ones losing them.",
|
||||
"severity": "warning",
|
||||
"category": "network",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"baseConfidence": 75,
|
||||
@@ -71,6 +71,15 @@
|
||||
"url": "https://learn.microsoft.com/en-us/autopilot/networking-requirements"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "A web security gateway blocked the diagnostics upload",
|
||||
"subject": {
|
||||
"kind": "network",
|
||||
"key": "security-gateway",
|
||||
"name": "Web security gateway"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"network",
|
||||
"proxy",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the Microsoft 365 Apps (Office Click-to-Run) install failed. The V2 OfficeInstallDetector watches the C2R client directly (registry, Delivery Optimization, process) and emits office_install_failed when the C2R INSTALL scenario records an error code. Completion is proven by the core Office binaries on disk; an install window that closes without that proof ends silently, never as a failure. This catches Office failures the deployment status hides: the Intune built-in app type is marked done once the configuration is delivered, and a Win32 or RealmJoin package once its installer exits, while C2R keeps installing in the background.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -52,6 +52,22 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "Microsoft 365 Apps failed to install",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "office-c2r",
|
||||
"name": "Microsoft 365 Apps"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{error_code_present.value}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"office",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects devices where the Windows UEFI CA 2023 certificate has not been deployed to the Secure Boot DB. The 2011 certificates expired in June 2026 — affected devices no longer receive Secure Boot security updates until the 2023 certificate is deployed. Since v3 the agent reads the firmware db/KEK variables directly: when the firmware itself confirms the 2023 certificate, the rule is suppressed even if the Windows servicing status has not caught up yet.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "3.1.0",
|
||||
"version": "3.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -132,6 +132,35 @@
|
||||
"url": "https://support.microsoft.com/en-us/topic/kb5036210"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "The Windows UEFI CA 2023 certificate is not deployed",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "secure-boot",
|
||||
"name": "Secure Boot"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "CA 2023 status",
|
||||
"value": "{{ca2023_not_updated.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{ca2023_error.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "CA 2023 in firmware DB",
|
||||
"value": "{{db_ca2023_firmware_missing.value}}"
|
||||
},
|
||||
{
|
||||
"label": "KEK 2023 in firmware",
|
||||
"value": "{{kek_2023_missing.value}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"secureboot",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Fires when the AutoLogon analyzer reports a DefaultPassword value present in Winlogon. A plaintext AutoLogon password stored in the registry is readable by any local administrator and is a credential exposure — more severe than AutoLogon alone (the Sysinternals Autologon tool deliberately avoids this by storing the secret in the LSA instead).",
|
||||
"severity": "high",
|
||||
"category": "security",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -49,6 +49,15 @@
|
||||
"url": "https://learn.microsoft.com/en-us/sysinternals/downloads/autologon"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "A plaintext AutoLogon password is stored in the registry",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "autologon",
|
||||
"name": "AutoLogon"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"autologon",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Optional kiosk allow-list for AutoLogon. Fires when AutoLogon is enabled for a default user that is NOT on your configured list of approved kiosk / shared-device accounts. Disabled until configured because a normal Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (AutoLogon enabled, a default user, no plaintext password) — so AutoLogon-enabled alone is not graded by default; only a plaintext DefaultPassword on disk (ANALYZE-SEC-003) is. Enable this rule with your expected kiosk account name(s) if you want to surface AutoLogon configured for any other user.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": false,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -59,6 +59,21 @@
|
||||
"url": "https://learn.microsoft.com/en-us/troubleshoot/windows-server/user-profiles-and-logon/turn-on-automatic-logon"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "AutoLogon is enabled for an unexpected user: {{unexpected_autologon_user.value}}",
|
||||
"subject": {
|
||||
"kind": "account",
|
||||
"key": "{{unexpected_autologon_user.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "AutoLogon user",
|
||||
"value": "{{unexpected_autologon_user.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"autologon",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects provisioning packages (PPKG) that are not on the built-in allow-list. The agent emits one or more provisioning_package_scan events whose artifacts[] arrays list every PPKG (registry package, .ppkg file, or Recovery\\Customizations residue; a large set is split across several events). This rule iterates that array across all of them and fires for any artifact whose identity does not match the allow-list regex. The default allow-list covers the Windows OS-inbox packages (Cosa, Power.EnergyEstimationEngine, Power.Settings, SecureStart) plus common OEM factory-preload families (Intel PPM power profiles, AMD Power profiles, Lenovo ThinkPad power/battery packages, Microsoft Surface defaults and carrier packages) so clean factory devices stay quiet. OEM imaging leftovers (e.g. USMT.PPKG, InstallOrder.ini) and generically named packages deliberately still alert — on a pure-Autopilot device they are worth a look. To allow your own vendor/recovery packages, enable the template rule ANALYZE-SEC-006, extend its allow-list, then disable this rule.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "2.2.0",
|
||||
"version": "2.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -38,6 +38,27 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "Provisioning package not on the allow list: {{ppkg_not_allowlisted.value}}",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "provisioning-packages",
|
||||
"name": "Provisioning packages"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Package",
|
||||
"value": "{{ppkg_not_allowlisted.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Unexpected packages",
|
||||
"value": "{{ppkg_not_allowlisted.matchCount}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"provisioning-package",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Template version of ANALYZE-SEC-005 with a customizable allow-list. Iterates the provisioning_package_scan artifacts[] array and fires for any PPKG whose identity is not on YOUR allow-list regex. The placeholder ships with the same OS-inbox defaults as ANALYZE-SEC-005 — extend it with your own vendor/recovery packages. This rule is OFF by default: enable it, set your allow-list, then disable ANALYZE-SEC-005 so only packages outside your list alert.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "2.2.0",
|
||||
"version": "2.3.0",
|
||||
"enabled": false,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -45,6 +45,27 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "Provisioning package not on the allow list: {{ppkg_not_allowlisted.value}}",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "provisioning-packages",
|
||||
"name": "Provisioning packages"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Package",
|
||||
"value": "{{ppkg_not_allowlisted.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Unexpected packages",
|
||||
"value": "{{ppkg_not_allowlisted.matchCount}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"provisioning-package",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Fires when the provisioning-package scan hit a per-source collection cap (too many .ppkg files, registry packages, or Recovery\\Customizations residue files to capture them all). When that happens the allow-list rules (ANALYZE-SEC-005/006) could not evaluate every package, so a non-allow-listed package may have gone unseen. A device with that many provisioning packages is itself unusual and worth a manual look.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -36,6 +36,15 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "The provisioning package scan hit its capture limit",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "provisioning-packages",
|
||||
"name": "Provisioning packages"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"provisioning-package",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects devices enrolling with Secure Boot turned off. Secure Boot is a firmware-level protection against boot-time malware and is a prerequisite for several Windows security features (VBS/Credential Guard, device health attestation) and common Intune compliance policies. Devices that support Secure Boot but have it disabled should be flagged during enrollment. Legacy BIOS devices (no Secure Boot capability) are excluded — the agent marks those with a note field when the SecureBoot registry state key is absent.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -82,6 +82,15 @@
|
||||
"url": "https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/disabling-secure-boot"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "Secure Boot is disabled",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "secure-boot",
|
||||
"name": "Secure Boot"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"secureboot",
|
||||
|
||||
+1271
-63
File diff suppressed because it is too large
Load Diff
@@ -133,6 +133,7 @@
|
||||
"items": { "$ref": "#/$defs/relatedDoc" },
|
||||
"default": []
|
||||
},
|
||||
"presentation": { "$ref": "#/$defs/rulePresentation" },
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"description": "Tags for filtering and categorization.",
|
||||
@@ -352,6 +353,83 @@
|
||||
"required": ["title", "url"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"rulePresentation": {
|
||||
"type": "object",
|
||||
"description": "How a finding of this rule is shown: its role, a one-line summary, what it is about and its key facts. Every string is a {{token}} template resolved from the finding's evidence when it is rendered; a fact whose value was not recorded is left out.",
|
||||
"properties": {
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": ["outcome", "cause", "context", "advisory"],
|
||||
"description": "outcome: restates the session outcome (shown in the verdict, not as a finding row). cause: can explain why a session failed or is stuck. context: a circumstance such as standby, a restart or battery power. advisory: hygiene or security without bearing on the outcome."
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160,
|
||||
"description": "One line for the finding row, for example \"{{install_retry_loop.appName}} was started {{install_retry_loop.count}} times\". When a token was not recorded, the first sentence of the explanation is shown instead."
|
||||
},
|
||||
"subject": { "$ref": "#/$defs/ruleSubject" },
|
||||
"facts": {
|
||||
"type": "array",
|
||||
"description": "Key facts shown as label/value pairs, in this order.",
|
||||
"maxItems": 6,
|
||||
"items": { "$ref": "#/$defs/ruleFact" }
|
||||
}
|
||||
},
|
||||
"required": ["role"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ruleSubject": {
|
||||
"type": "object",
|
||||
"description": "What the finding is about. Findings with the same kind and resolved key are grouped, for example every finding about one app. Use qualified tokens of one signal for key and name, such as {{failed_app.appId}} and {{failed_app.appName}}, so both come from the same event.",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["app", "policy", "account", "script", "device", "network", "esp", "session"],
|
||||
"description": "The kind of thing the key identifies."
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200,
|
||||
"description": "Template of the grouping key."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200,
|
||||
"description": "Template of the display name; the key is shown when it is missing."
|
||||
}
|
||||
},
|
||||
"required": ["kind", "key"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ruleFact": {
|
||||
"type": "object",
|
||||
"description": "One key fact of a finding.",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 40,
|
||||
"description": "Short label, for example \"Install starts\"."
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200,
|
||||
"description": "Template of the value, for example {{install_retry_loop.count}}."
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["text", "code", "errorCode", "duration", "number", "timestamp"],
|
||||
"description": "How the value is shown. errorCode adds the error-code catalog meaning, duration reads seconds, timestamp an ISO 8601 time.",
|
||||
"default": "text"
|
||||
}
|
||||
},
|
||||
"required": ["label", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"templateVariable": {
|
||||
"type": "object",
|
||||
"description": "A variable in a rule condition that must be customized per-tenant before the rule can be used.",
|
||||
|
||||
@@ -30,6 +30,17 @@ BeforeAll {
|
||||
return $dir
|
||||
}
|
||||
|
||||
# What Test-FileCatalog -Detailed returns, reduced to the fields the check reads.
|
||||
function New-FakeCatalogResult {
|
||||
param(
|
||||
[string]$Status = 'Valid',
|
||||
[object]$Signature,
|
||||
[hashtable]$CatalogItems = @{ 'AutopilotMonitor.Agent.exe' = 'AA' },
|
||||
[hashtable]$PathItems = @{ 'AutopilotMonitor.Agent.exe' = 'AA' }
|
||||
)
|
||||
return [pscustomobject]@{ Status = $Status; Signature = $Signature; CatalogItems = $CatalogItems; PathItems = $PathItems }
|
||||
}
|
||||
|
||||
function New-FakeProfile {
|
||||
param([string]$Name, [double]$AgeMinutes)
|
||||
$p = Join-Path $TestDrive $Name
|
||||
@@ -322,86 +333,163 @@ Describe 'logging purity' {
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-UnverifiedAgentBinaries' {
|
||||
# The publisher gate on the extracted payload. What matters is the DECISION: which
|
||||
# payload is accepted, which is refused, and which files are looked at at all.
|
||||
Describe 'Get-AgentPayloadRejections' {
|
||||
# The payload catalog check, shared verbatim with the OOBE go-script. What matters is the
|
||||
# DECISION: which payload is accepted and which is refused. Test-FileCatalog is mocked for
|
||||
# the branches; one test runs the real cmdlet over a real (unsigned) catalog.
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
$script:ours = 'CN=glueckkanja AG, O=glueckkanja AG, C=DE'
|
||||
$script:dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.cat')
|
||||
}
|
||||
|
||||
It 'accepts a payload where every binary of ours is validly signed by us' {
|
||||
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Shared.dll')
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject $script:ours }
|
||||
Get-UnverifiedAgentBinaries -Path $dir | Should -BeNullOrEmpty
|
||||
It 'accepts a payload whose catalog is signed by us and matches every file' {
|
||||
Mock Test-FileCatalog { New-FakeCatalogResult -Signature (New-FakeSignature -Status 'Valid' -Subject $script:ours) }
|
||||
Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'checks the binaries we author and leaves third-party assemblies alone' {
|
||||
$dir = New-FakePayload -Files @(
|
||||
'AutopilotMonitor.Agent.exe',
|
||||
'AutopilotMonitor.Agent.exe.config',
|
||||
'AutopilotMonitor.Shared.dll',
|
||||
'Newtonsoft.Json.dll',
|
||||
'System.Management.Automation.dll'
|
||||
)
|
||||
$script:checked = @()
|
||||
Mock Get-AuthenticodeSignature {
|
||||
$script:checked += (Split-Path $FilePath -Leaf)
|
||||
New-FakeSignature -Status 'Valid' -Subject $script:ours
|
||||
It 'asks for the detailed result and skips the catalog itself' {
|
||||
Mock Test-FileCatalog {
|
||||
$script:call = @{ Skip = $FilesToSkip; Detailed = [bool]$Detailed; Catalog = $CatalogFilePath; Path = $Path }
|
||||
New-FakeCatalogResult -Signature (New-FakeSignature -Status 'Valid' -Subject $script:ours)
|
||||
}
|
||||
|
||||
Get-UnverifiedAgentBinaries -Path $dir | Should -BeNullOrEmpty
|
||||
$script:checked | Should -Be @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Shared.dll')
|
||||
Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher | Out-Null
|
||||
$script:call.Skip | Should -Be @('AutopilotMonitor.Agent.cat')
|
||||
$script:call.Detailed | Should -BeTrue
|
||||
$script:call.Catalog | Should -Be (Join-Path $script:dir 'AutopilotMonitor.Agent.cat')
|
||||
$script:call.Path | Should -Be $script:dir
|
||||
}
|
||||
|
||||
It 'refuses an unsigned agent executable' {
|
||||
It 'refuses a payload without catalog and does not run the check at all' {
|
||||
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'NotSigned' -Subject $null }
|
||||
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
|
||||
Mock Test-FileCatalog { throw 'must not run' }
|
||||
$r = Get-AgentPayloadRejections -Path $dir -ExpectedPublisher $ExpectedPublisher
|
||||
$r.Count | Should -Be 1
|
||||
$r[0] | Should -BeLike '*AutopilotMonitor.Agent.cat missing*'
|
||||
Should -Invoke Test-FileCatalog -Times 0
|
||||
}
|
||||
|
||||
It 'refuses a tampered binary (hash mismatch)' {
|
||||
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'HashMismatch' -Subject $script:ours }
|
||||
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
|
||||
It 'refuses matching files under an unsigned catalog (Status alone is not the verdict)' {
|
||||
Mock Test-FileCatalog { New-FakeCatalogResult -Status 'Valid' -Signature (New-FakeSignature -Status 'NotSigned' -Subject $null) }
|
||||
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
|
||||
$r.Count | Should -Be 1
|
||||
$r[0] | Should -BeLike '*signature status NotSigned*'
|
||||
}
|
||||
|
||||
It 'refuses a signature whose chain does not validate' {
|
||||
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'UnknownError' -Subject $script:ours }
|
||||
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
|
||||
It 'refuses a catalog whose signature does not validate' {
|
||||
Mock Test-FileCatalog { New-FakeCatalogResult -Signature (New-FakeSignature -Status 'HashMismatch' -Subject $script:ours) }
|
||||
(Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher).Count | Should -Be 1
|
||||
}
|
||||
|
||||
It 'refuses a validly signed binary from a different publisher' {
|
||||
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }
|
||||
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
|
||||
It 'refuses a validly signed catalog from another publisher' {
|
||||
Mock Test-FileCatalog { New-FakeCatalogResult -Signature (New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US') }
|
||||
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
|
||||
$r.Count | Should -Be 1
|
||||
$r[0] | Should -BeLike '*does not match*'
|
||||
}
|
||||
|
||||
It 'refuses the whole payload when a single DLL of ours is swapped' {
|
||||
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Shared.dll')
|
||||
Mock Get-AuthenticodeSignature {
|
||||
if ((Split-Path $FilePath -Leaf) -eq 'AutopilotMonitor.Shared.dll') {
|
||||
New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US'
|
||||
} else {
|
||||
New-FakeSignature -Status 'Valid' -Subject $script:ours
|
||||
}
|
||||
It 'names the missing, extra and changed files of a payload that differs' {
|
||||
Mock Test-FileCatalog {
|
||||
New-FakeCatalogResult -Status 'ValidationFailed' -Signature (New-FakeSignature -Status 'Valid' -Subject $script:ours) `
|
||||
-CatalogItems @{ 'a.dll' = 'AA'; 'b.dll' = 'BB'; 'c.config' = 'CC' } `
|
||||
-PathItems @{ 'a.dll' = 'AA'; 'b.dll' = 'XX'; 'evil.dll' = 'EE' }
|
||||
}
|
||||
$rejected = Get-UnverifiedAgentBinaries -Path $dir
|
||||
$rejected.Count | Should -Be 1
|
||||
$rejected[0] | Should -BeLike 'AutopilotMonitor.Shared.dll*'
|
||||
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
|
||||
$r.Count | Should -Be 1
|
||||
$r[0] | Should -BeLike '*missing: c.config;*'
|
||||
$r[0] | Should -BeLike '*extra: evil.dll;*'
|
||||
$r[0] | Should -BeLike '*changed: b.dll)*'
|
||||
}
|
||||
|
||||
It 'refuses when the signature check itself throws' {
|
||||
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
|
||||
Mock Get-AuthenticodeSignature { throw 'access denied' }
|
||||
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
|
||||
It 'reports a bad signature and a differing payload together' {
|
||||
Mock Test-FileCatalog {
|
||||
New-FakeCatalogResult -Status 'ValidationFailed' -Signature (New-FakeSignature -Status 'NotSigned' -Subject $null) `
|
||||
-PathItems @{ 'AutopilotMonitor.Agent.exe' = 'AA'; 'extra.dll' = 'EE' }
|
||||
}
|
||||
(Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher).Count | Should -Be 2
|
||||
}
|
||||
|
||||
It 'refuses an empty payload instead of passing it' {
|
||||
$dir = New-FakePayload -Files @('Newtonsoft.Json.dll')
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject $script:ours }
|
||||
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
|
||||
It 'refuses when the catalog check itself throws' {
|
||||
Mock Test-FileCatalog { throw 'access denied' }
|
||||
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
|
||||
$r.Count | Should -Be 1
|
||||
$r[0] | Should -BeLike '*failed to run*access denied*'
|
||||
}
|
||||
|
||||
It 'refuses when the catalog check returns nothing' {
|
||||
Mock Test-FileCatalog { }
|
||||
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
|
||||
$r[0] | Should -BeLike '*returned nothing*'
|
||||
}
|
||||
|
||||
It 'returns a single rejection as an array' {
|
||||
Mock Test-FileCatalog { throw 'boom' }
|
||||
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
|
||||
($r -is [array]) | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'catches an extra file with the real cmdlet over a real catalog' {
|
||||
# Real New-FileCatalog/Test-FileCatalog over text files: no PE, no process, no mock.
|
||||
$dir = New-FakePayload -Files @('a.txt', 'b.txt')
|
||||
$catalog = Join-Path $TestDrive "real-$([guid]::NewGuid()).cat"
|
||||
New-FileCatalog -Path $dir -CatalogFilePath $catalog -CatalogVersion 2.0 | Out-Null
|
||||
Copy-Item $catalog (Join-Path $dir 'AutopilotMonitor.Agent.cat')
|
||||
Set-Content -Path (Join-Path $dir 'extra.txt') -Value 'planted'
|
||||
|
||||
$r = Get-AgentPayloadRejections -Path $dir -ExpectedPublisher $ExpectedPublisher
|
||||
|
||||
($r -join ' | ') | Should -BeLike '*extra: extra.txt;*'
|
||||
($r -join ' | ') | Should -BeLike '*signature status NotSigned*'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Install-VerifiedAgentPayload' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
$src = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.cat', 'Newtonsoft.Json.dll')
|
||||
$script:zip = Join-Path $TestDrive "payload-$([guid]::NewGuid()).zip"
|
||||
Compress-Archive -Path (Join-Path $src '*') -DestinationPath $script:zip
|
||||
$script:staging = Join-Path $TestDrive "staging-$([guid]::NewGuid())"
|
||||
$script:target = Join-Path $TestDrive "agent-$([guid]::NewGuid())"
|
||||
}
|
||||
|
||||
It 'moves an accepted payload into the agent folder and removes the staging folder' {
|
||||
Mock Get-AgentPayloadRejections { , @() }
|
||||
Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher
|
||||
@(Get-ChildItem $script:target | ForEach-Object Name | Sort-Object) |
|
||||
Should -Be @('AutopilotMonitor.Agent.cat', 'AutopilotMonitor.Agent.exe', 'Newtonsoft.Json.dll')
|
||||
Test-Path $script:staging | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'verifies the staging folder, never the agent folder' {
|
||||
Mock Get-AgentPayloadRejections { $script:checked = $Path; , @() }
|
||||
Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher
|
||||
$script:checked | Should -Be $script:staging
|
||||
}
|
||||
|
||||
It 'leaves the agent folder untouched when the payload is refused, and cleans up' {
|
||||
Mock Get-AgentPayloadRejections { , @('payload differs from catalog (missing: none; extra: evil.dll; changed: none)') }
|
||||
{ Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher } |
|
||||
Should -Throw '*Payload verification FAILED*evil.dll*'
|
||||
Test-Path $script:target | Should -BeFalse
|
||||
Test-Path $script:staging | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'removes leftovers from the agent folder before the payload moves in' {
|
||||
New-Item -ItemType Directory -Path $script:target | Out-Null
|
||||
Set-Content -Path (Join-Path $script:target 'stale.dll') -Value 'x'
|
||||
Mock Get-AgentPayloadRejections { , @() }
|
||||
Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher
|
||||
Test-Path (Join-Path $script:target 'stale.dll') | Should -BeFalse
|
||||
Test-Path (Join-Path $script:target 'AutopilotMonitor.Agent.exe') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'starts from an empty staging folder even when a previous run left one behind' {
|
||||
New-Item -ItemType Directory -Path $script:staging | Out-Null
|
||||
Set-Content -Path (Join-Path $script:staging 'leftover.dll') -Value 'x'
|
||||
Mock Get-AgentPayloadRejections { $script:seen = @(Get-ChildItem $Path | ForEach-Object Name); , @() }
|
||||
Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher
|
||||
$script:seen | Should -Not -Contain 'leftover.dll'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,4 +498,8 @@ Describe 'publisher pin' {
|
||||
$ExpectedPublisher | Should -Be '*O=glueckkanja AG*'
|
||||
$ExpectedPublisher | Should -Not -Match '^[0-9A-Fa-f]{40}$'
|
||||
}
|
||||
|
||||
It 'names the payload catalog the release pipeline signs' {
|
||||
$AgentCatalogName | Should -Be 'AutopilotMonitor.Agent.cat'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@
|
||||
freshly provisioned Windows 365 Cloud PC.
|
||||
2. Downloads the monitoring agent ZIP from Azure Blob Storage
|
||||
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
|
||||
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
|
||||
5. Verifies the Authenticode signature and publisher of every binary we author
|
||||
6. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
|
||||
7. Verifies the runtime process actually launched
|
||||
4. Extracts the agent into a fresh staging folder
|
||||
5. Verifies the payload against its signed catalog (AutopilotMonitor.Agent.cat):
|
||||
signed by the expected publisher, every file present and unchanged, nothing extra
|
||||
6. Moves the verified payload into %ProgramData%\AutopilotMonitor\Agent
|
||||
7. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
|
||||
8. Verifies the runtime process actually launched
|
||||
Agent self-destructs when enrollment completes.
|
||||
|
||||
Relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
|
||||
@@ -77,6 +79,14 @@
|
||||
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
|
||||
|
||||
.CHANGELOG
|
||||
2026-10-13 v2.6 Payload catalog: the agent ZIP carries AutopilotMonitor.Agent.cat,
|
||||
a catalog of every payload file signed by the expected publisher.
|
||||
The ZIP is extracted into a staging folder and moved into the
|
||||
agent folder only when Test-FileCatalog finds the catalog signed
|
||||
by the expected publisher and every file present, unchanged and
|
||||
nothing extra. A rejected payload is deleted, so the next run
|
||||
starts clean. Replaces the v2.5 gate, which covered only the
|
||||
AutopilotMonitor binaries.
|
||||
2026-09-08 v2.5 Publisher gate: every AutopilotMonitor binary in the extracted
|
||||
payload must carry a valid Authenticode signature from the
|
||||
expected publisher before --install runs. The SHA-256 manifest
|
||||
@@ -134,16 +144,20 @@ param(
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.5"
|
||||
$ScriptVersion = "2.6"
|
||||
|
||||
# The publisher every binary we author must carry. A -like pattern on the subject, NOT a
|
||||
# The publisher the payload catalog must carry. A -like pattern on the subject, NOT a
|
||||
# thumbprint: certificates are renewed, deployed copies of this script are not. Same value
|
||||
# the loader pins for this script.
|
||||
$ExpectedPublisher = "*O=glueckkanja AG*"
|
||||
|
||||
# Signed catalog at the root of every agent ZIP: the SHA-256 of every other payload file.
|
||||
$AgentCatalogName = "AutopilotMonitor.Agent.cat"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentStagingPath = "$AgentBasePath\Agent-Install"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\bootstrap-script.log"
|
||||
|
||||
@@ -340,56 +354,107 @@ function Get-BootstrapDecision {
|
||||
return [pscustomobject]@{ Install = $true; ReasonCode = $null; RelaxActive = $relax.Active }
|
||||
}
|
||||
|
||||
# Returns the list of reasons why the extracted payload is not ours; an empty result means
|
||||
# every binary we author carries a valid Authenticode signature from the expected publisher.
|
||||
# Third-party assemblies keep their own publisher's signature and are not checked here.
|
||||
# Any error is a rejection: an unreadable signature is an unverified binary.
|
||||
function Get-UnverifiedAgentBinaries {
|
||||
param([string]$Path)
|
||||
|
||||
$rejected = @()
|
||||
$ourBinaries = @(
|
||||
Get-ChildItem -Path $Path -Filter 'AutopilotMonitor.*' -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Extension -in @('.exe', '.dll') }
|
||||
### BEGIN shared payload verification -- embedded verbatim into the OOBE go-script by
|
||||
### OobeBootstrapScriptGenerator (D-354). Keep it self-contained: parameters only, no
|
||||
### script-scope variables, no Write-Log.
|
||||
# Returns the reasons why the extracted payload in $Path must not run; an empty result means
|
||||
# it matches its signed catalog exactly. Test-FileCatalog's Status covers the hashes only (an
|
||||
# unsigned catalog over matching files reports Valid), so the signature and its publisher are
|
||||
# judged on their own. Any error is a rejection: a payload that cannot be verified is an
|
||||
# unverified payload. The comma keeps a single rejection an ARRAY: PowerShell unrolls a
|
||||
# one-element result to a bare string, and the caller reads .Count.
|
||||
function Get-AgentPayloadRejections {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$ExpectedPublisher,
|
||||
[string]$CatalogName = 'AutopilotMonitor.Agent.cat'
|
||||
)
|
||||
|
||||
# An empty set is a rejection, not a pass: it means the extraction produced nothing we
|
||||
# recognise, and the alternative would be a gate that silently approves an empty folder.
|
||||
# The comma keeps a single rejection an ARRAY: PowerShell unrolls a one-element result to
|
||||
# a bare string, and the caller reads .Count and indexes into it.
|
||||
if ($ourBinaries.Count -eq 0) {
|
||||
return , @("no AutopilotMonitor binaries found in $Path")
|
||||
$catalog = Join-Path $Path $CatalogName
|
||||
if (-not (Test-Path -LiteralPath $catalog -PathType Leaf)) {
|
||||
return , @("catalog $CatalogName missing")
|
||||
}
|
||||
|
||||
foreach ($binary in $ourBinaries) {
|
||||
try {
|
||||
$sig = Get-AuthenticodeSignature -FilePath $binary.FullName -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
$rejected += "$($binary.Name): signature check failed to run ($($_.Exception.Message))"
|
||||
continue
|
||||
}
|
||||
try {
|
||||
$result = Test-FileCatalog -Path $Path -CatalogFilePath $catalog -FilesToSkip $CatalogName -Detailed -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
return , @("catalog check failed to run ($($_.Exception.Message))")
|
||||
}
|
||||
if ($null -eq $result) {
|
||||
return , @('catalog check returned nothing')
|
||||
}
|
||||
|
||||
if ($null -eq $sig) {
|
||||
$rejected += "$($binary.Name): signature check returned nothing"
|
||||
continue
|
||||
}
|
||||
$rejected = @()
|
||||
$signature = $result.Signature
|
||||
if ($null -eq $signature -or "$($signature.Status)" -ne 'Valid') {
|
||||
$status = if ($null -eq $signature) { 'missing' } else { "$($signature.Status)" }
|
||||
$rejected += "catalog signature status $status"
|
||||
}
|
||||
elseif ($null -eq $signature.SignerCertificate -or $signature.SignerCertificate.Subject -notlike $ExpectedPublisher) {
|
||||
$subject = if ($null -eq $signature.SignerCertificate) { '' } else { $signature.SignerCertificate.Subject }
|
||||
$rejected += "catalog publisher '$subject' does not match $ExpectedPublisher"
|
||||
}
|
||||
|
||||
$subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '<unsigned>' }
|
||||
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
$rejected += "$($binary.Name): signature status $($sig.Status)"
|
||||
continue
|
||||
}
|
||||
if ($subject -notlike $ExpectedPublisher) {
|
||||
$rejected += "$($binary.Name): unexpected publisher $subject"
|
||||
continue
|
||||
if ("$($result.Status)" -ne 'Valid') {
|
||||
$expected = if ($null -eq $result.CatalogItems) { @{} } else { $result.CatalogItems }
|
||||
$actual = if ($null -eq $result.PathItems) { @{} } else { $result.PathItems }
|
||||
$names = {
|
||||
param($list)
|
||||
if ($list.Count -eq 0) { return 'none' }
|
||||
$shown = ($list | Select-Object -First 5) -join ', '
|
||||
if ($list.Count -gt 5) { $shown += " (+$($list.Count - 5) more)" }
|
||||
return $shown
|
||||
}
|
||||
$missing = @($expected.Keys | Where-Object { -not $actual.ContainsKey($_) } | Sort-Object)
|
||||
$extra = @($actual.Keys | Where-Object { -not $expected.ContainsKey($_) } | Sort-Object)
|
||||
$changed = @($expected.Keys | Where-Object { $actual.ContainsKey($_) -and $actual[$_] -ne $expected[$_] } | Sort-Object)
|
||||
$rejected += "payload differs from catalog (missing: $(& $names $missing); extra: $(& $names $extra); changed: $(& $names $changed))"
|
||||
}
|
||||
|
||||
return , $rejected
|
||||
}
|
||||
|
||||
# Extracts the agent ZIP into a fresh staging folder, verifies it there and moves it into the
|
||||
# agent folder only when it matches its signed catalog. A rejected payload is deleted with the
|
||||
# staging folder: nothing unverified lands where SYSTEM runs the agent, and the next run starts
|
||||
# clean instead of finding a half-installed agent. The caller has made sure no agent is
|
||||
# installed, so anything already in the agent folder is a leftover and goes first.
|
||||
function Install-VerifiedAgentPayload {
|
||||
param(
|
||||
[string]$ZipPath,
|
||||
[string]$StagingPath,
|
||||
[string]$TargetPath,
|
||||
[string]$ExpectedPublisher,
|
||||
[string]$CatalogName = 'AutopilotMonitor.Agent.cat'
|
||||
)
|
||||
|
||||
if (Test-Path -LiteralPath $StagingPath) {
|
||||
Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
New-Item -Path $StagingPath -ItemType Directory -Force | Out-Null
|
||||
try {
|
||||
Expand-Archive -Path $ZipPath -DestinationPath $StagingPath -Force -ErrorAction Stop
|
||||
|
||||
$rejections = Get-AgentPayloadRejections -Path $StagingPath -ExpectedPublisher $ExpectedPublisher -CatalogName $CatalogName
|
||||
if ($rejections.Count -gt 0) {
|
||||
throw "Payload verification FAILED ($($rejections -join '; ')). Refusing to install an agent that is not the published one."
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $TargetPath) {
|
||||
Get-ChildItem -LiteralPath $TargetPath -Force | Remove-Item -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
else {
|
||||
New-Item -Path $TargetPath -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
Get-ChildItem -LiteralPath $StagingPath -Force | Move-Item -Destination $TargetPath -Force -ErrorAction Stop
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
### END shared payload verification
|
||||
|
||||
# Main bootstrap flow. Only invoked when the script is run directly (see entry guard
|
||||
# at the bottom); never on dot-source.
|
||||
function Invoke-Bootstrap {
|
||||
@@ -408,117 +473,106 @@ function Invoke-Bootstrap {
|
||||
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (relaxActive=$($decision.RelaxActive))"
|
||||
|
||||
# Download and extract agent binaries
|
||||
# Download, verify and install the agent binaries. Guard 5 has already returned when an
|
||||
# agent exe exists, so this always starts without an installed agent.
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
|
||||
if (Test-Path $agentExePath) {
|
||||
Write-Log "Agent already installed at $agentExePath"
|
||||
}
|
||||
else {
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
try {
|
||||
# Derive manifest URL from the agent download URL (same blob container)
|
||||
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
|
||||
$expectedSha256 = $null
|
||||
$manifestAttempts = 3
|
||||
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
|
||||
|
||||
try {
|
||||
# Derive manifest URL from the agent download URL (same blob container)
|
||||
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
|
||||
$expectedSha256 = $null
|
||||
$manifestAttempts = 3
|
||||
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
|
||||
|
||||
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
|
||||
try {
|
||||
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
|
||||
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||
if ($versionJsonResponse.sha256) {
|
||||
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
|
||||
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
|
||||
} else {
|
||||
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
|
||||
}
|
||||
break # manifest reached (with or without hash); retrying would not help
|
||||
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
|
||||
try {
|
||||
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
|
||||
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||
if ($versionJsonResponse.sha256) {
|
||||
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
|
||||
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
|
||||
} else {
|
||||
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
|
||||
}
|
||||
catch {
|
||||
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
|
||||
if ($attempt -lt $manifestAttempts) {
|
||||
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
|
||||
}
|
||||
break # manifest reached (with or without hash); retrying would not help
|
||||
}
|
||||
catch {
|
||||
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
|
||||
if ($attempt -lt $manifestAttempts) {
|
||||
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
|
||||
}
|
||||
}
|
||||
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
|
||||
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
Invoke-WebRequest `
|
||||
-Uri $AgentDownloadUrl `
|
||||
-OutFile $zipPath `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 30 `
|
||||
-ErrorAction Stop
|
||||
$downloadStopwatch.Stop()
|
||||
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
|
||||
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) {
|
||||
throw
|
||||
}
|
||||
|
||||
$retryDelaysInSeconds = @(2, 4, 8)
|
||||
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
|
||||
Start-Sleep -Seconds $retryDelaySeconds
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
# SHA-256 from the version manifest is the only accepted integrity proof.
|
||||
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
|
||||
if (-not $expectedSha256) {
|
||||
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
|
||||
}
|
||||
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Log "Validating SHA-256 hash against manifest"
|
||||
if ($actualSha256 -ne $expectedSha256) {
|
||||
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
|
||||
}
|
||||
Write-Log "SHA-256 integrity check passed"
|
||||
|
||||
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
|
||||
Write-Log "Extracted agent to $AgentBinPath"
|
||||
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "Cleaned up temporary files"
|
||||
|
||||
if (-not (Test-Path $agentExePath)) {
|
||||
throw "Agent executable not found after extraction at $agentExePath"
|
||||
}
|
||||
|
||||
Write-Log "Agent installation completed successfully"
|
||||
}
|
||||
catch {
|
||||
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
# Publisher gate. The SHA-256 above proves the ZIP is the one the manifest names, but
|
||||
# that manifest lives in the same container as the ZIP and cannot outlive a compromise
|
||||
# of the download host. The expected publisher travels inside THIS script, which the
|
||||
# loader verified before running it, so the chain from the file an administrator
|
||||
# assigns to the process that starts here stays publisher-anchored end to end.
|
||||
# Fail-closed, exactly like the hash check: running unverified code as SYSTEM is the
|
||||
# risk being removed.
|
||||
Write-Log "Verifying Authenticode signatures of the agent binaries..."
|
||||
$unverified = Get-UnverifiedAgentBinaries -Path $AgentBinPath
|
||||
if ($unverified.Count -gt 0) {
|
||||
throw "Authenticode verification FAILED ($($unverified -join '; ')). Refusing to run an agent that is not signed by the expected publisher."
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
|
||||
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
Invoke-WebRequest `
|
||||
-Uri $AgentDownloadUrl `
|
||||
-OutFile $zipPath `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 30 `
|
||||
-ErrorAction Stop
|
||||
$downloadStopwatch.Stop()
|
||||
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
|
||||
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) {
|
||||
throw
|
||||
}
|
||||
|
||||
$retryDelaysInSeconds = @(2, 4, 8)
|
||||
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
|
||||
Start-Sleep -Seconds $retryDelaySeconds
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
# SHA-256 from the version manifest is the only accepted integrity proof.
|
||||
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
|
||||
if (-not $expectedSha256) {
|
||||
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
|
||||
}
|
||||
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Log "Validating SHA-256 hash against manifest"
|
||||
if ($actualSha256 -ne $expectedSha256) {
|
||||
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
|
||||
}
|
||||
Write-Log "SHA-256 integrity check passed"
|
||||
|
||||
# Payload catalog. The SHA-256 above proves the ZIP is the one the manifest names, but
|
||||
# that manifest lives in the same container as the ZIP and cannot outlive a compromise
|
||||
# of the download host. The catalog is signed by the expected publisher, whose pin
|
||||
# travels inside THIS script -- verified by the loader before it ran -- so the chain
|
||||
# from the file an administrator assigns to every file the agent runs from stays
|
||||
# publisher-anchored end to end. Fail-closed like the hash check: running unverified
|
||||
# code as SYSTEM is the risk being removed.
|
||||
Write-Log "Verifying the payload against its signed catalog ($AgentCatalogName, publisher pin: $ExpectedPublisher)..."
|
||||
Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $AgentStagingPath -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName
|
||||
Write-Log "Payload verified against its signed catalog and installed to $AgentBinPath"
|
||||
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "Cleaned up temporary files"
|
||||
|
||||
if (-not (Test-Path $agentExePath)) {
|
||||
throw "Agent executable not found after extraction at $agentExePath"
|
||||
}
|
||||
|
||||
Write-Log "Agent installation completed successfully"
|
||||
}
|
||||
catch {
|
||||
Write-Log "ERROR downloading/verifying agent: $($_.Exception.Message)"
|
||||
throw
|
||||
}
|
||||
Write-Log "Authenticode verification passed (publisher pin: $ExpectedPublisher)"
|
||||
|
||||
Write-Log "Calling agent install mode (--install)..."
|
||||
& $agentExePath --install
|
||||
|
||||
@@ -17,6 +17,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
public DistressErrorType ErrorType { get; set; }
|
||||
public string Message { get; set; } = default!;
|
||||
public int? HttpStatusCode { get; set; }
|
||||
public string? TpmVersion { get; set; }
|
||||
}
|
||||
|
||||
public List<Call> Calls { get; } = new List<Call>();
|
||||
@@ -32,9 +33,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
{
|
||||
}
|
||||
|
||||
public override Task TrySendAsync(DistressErrorType errorType, string message, int? httpStatusCode = null)
|
||||
public override Task TrySendAsync(DistressErrorType errorType, string message, int? httpStatusCode = null, string? tpmVersion = null)
|
||||
{
|
||||
Calls.Add(new Call { ErrorType = errorType, Message = message, HttpStatusCode = httpStatusCode });
|
||||
Calls.Add(new Call { ErrorType = errorType, Message = message, HttpStatusCode = httpStatusCode, TpmVersion = tpmVersion });
|
||||
return Send?.Invoke() ?? Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// 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), the MSI's downloader before
|
||||
/// the loader <c>[ts] MSI bootstrap runner started (user=..., pid=...).</c>.
|
||||
/// </summary>
|
||||
public sealed class BootstrapProvenanceReaderTests : IDisposable
|
||||
{
|
||||
private readonly string _dir = Path.Combine(Path.GetTempPath(), "apm-provenance-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public BootstrapProvenanceReaderTests() => Directory.CreateDirectory(_dir);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
private void Write(string name, params string[] lines) =>
|
||||
File.WriteAllText(Path.Combine(_dir, name), string.Join("\r\n", lines) + "\r\n", Encoding.ASCII);
|
||||
|
||||
private static string InstallerRun(string version) => $"[2026-10-13 09:00:00] Bootstrap script version: v{version}";
|
||||
|
||||
private static string LoaderRun(string version) =>
|
||||
$"[2026-10-13 08:59:58] === Autopilot Monitor bootstrap loader v{version} started (user=SECRETUSER, pid=4242) ===";
|
||||
|
||||
[Fact]
|
||||
public void Loader_path_reports_installer_and_loader_versions()
|
||||
{
|
||||
Write(BootstrapProvenanceReader.LoaderLogName, LoaderRun("1.2"), "[2026-10-13 08:59:59] Installer signature verified");
|
||||
Write(BootstrapProvenanceReader.InstallerLogName, "[2026-10-13 09:00:00] ===== Autopilot Monitor Bootstrap Started =====", InstallerRun("2.5"));
|
||||
|
||||
var p = BootstrapProvenanceReader.Read(_dir);
|
||||
|
||||
Assert.True(p.ScriptLogPresent);
|
||||
Assert.Equal("2.5", p.ScriptVersion);
|
||||
Assert.Equal("loader", p.LoaderLog);
|
||||
Assert.Equal("1.2", p.LoaderVersion);
|
||||
Assert.False(p.OobeLogPresent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_last_run_wins_when_the_log_grew_over_several_runs()
|
||||
{
|
||||
Write(BootstrapProvenanceReader.InstallerLogName, InstallerRun("2.3"), "x", InstallerRun("2.4"), "y", InstallerRun("2.5"), "z");
|
||||
|
||||
Assert.Equal("2.5", BootstrapProvenanceReader.Read(_dir).ScriptVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_directly_assigned_installer_has_no_loader_log()
|
||||
{
|
||||
Write(BootstrapProvenanceReader.InstallerLogName, InstallerRun("2.4"));
|
||||
|
||||
var p = BootstrapProvenanceReader.Read(_dir);
|
||||
|
||||
Assert.Equal("2.4", p.ScriptVersion);
|
||||
Assert.Null(p.LoaderLog);
|
||||
Assert.Null(p.LoaderVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_installer_from_before_the_version_line_is_present_but_without_version()
|
||||
{
|
||||
Write(BootstrapProvenanceReader.InstallerLogName, "[2026-03-01 09:00:00] ===== Autopilot Monitor Bootstrap Started =====");
|
||||
|
||||
var p = BootstrapProvenanceReader.Read(_dir);
|
||||
|
||||
Assert.True(p.ScriptLogPresent);
|
||||
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()
|
||||
{
|
||||
Write(BootstrapProvenanceReader.MsiLoaderLogName, LoaderRun("1.1"));
|
||||
|
||||
var p = BootstrapProvenanceReader.Read(_dir);
|
||||
|
||||
Assert.Equal("msi", p.LoaderLog);
|
||||
Assert.Equal("1.1", p.LoaderVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_both_loader_logs_the_most_recently_written_one_counts()
|
||||
{
|
||||
Write(BootstrapProvenanceReader.LoaderLogName, LoaderRun("1.0"));
|
||||
Write(BootstrapProvenanceReader.MsiLoaderLogName, LoaderRun("1.2"));
|
||||
File.SetLastWriteTimeUtc(Path.Combine(_dir, BootstrapProvenanceReader.LoaderLogName), DateTime.UtcNow.AddDays(-3));
|
||||
File.SetLastWriteTimeUtc(Path.Combine(_dir, BootstrapProvenanceReader.MsiLoaderLogName), DateTime.UtcNow.AddMinutes(-1));
|
||||
|
||||
var p = BootstrapProvenanceReader.Read(_dir);
|
||||
|
||||
Assert.Equal("msi", p.LoaderLog);
|
||||
Assert.Equal("1.2", p.LoaderVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_oobe_go_script_is_recognised_by_its_log()
|
||||
{
|
||||
Write(BootstrapProvenanceReader.OobeLogName, "[2026-10-13 09:00:00] ===== Autopilot Monitor OOBE Bootstrap =====");
|
||||
|
||||
Assert.True(BootstrapProvenanceReader.Read(_dir).OobeLogPresent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_the_tail_of_a_large_log_is_read()
|
||||
{
|
||||
var filler = Enumerable.Repeat(new string('.', 200), 600).ToArray(); // ~120 KB
|
||||
var lines = new List<string> { InstallerRun("2.1") };
|
||||
lines.AddRange(filler);
|
||||
lines.Add(InstallerRun("2.5"));
|
||||
Write(BootstrapProvenanceReader.InstallerLogName, lines.ToArray());
|
||||
|
||||
Assert.Equal("2.5", BootstrapProvenanceReader.Read(_dir).ScriptVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_missing_directory_reads_as_unknown_and_never_throws()
|
||||
{
|
||||
var p = BootstrapProvenanceReader.Read(Path.Combine(_dir, "does-not-exist"));
|
||||
|
||||
Assert.False(p.ScriptLogPresent);
|
||||
Assert.Null(p.ScriptVersion);
|
||||
Assert.Null(p.LoaderLog);
|
||||
Assert.False(p.OobeLogPresent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_event_keys_carry_versions_only_never_the_account_name()
|
||||
{
|
||||
Write(BootstrapProvenanceReader.LoaderLogName, LoaderRun("1.2"));
|
||||
Write(BootstrapProvenanceReader.InstallerLogName, InstallerRun("2.5"));
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
BootstrapProvenanceReader.Read(_dir).AddTo(data);
|
||||
|
||||
Assert.Equal(true, data["bootstrapScriptLogPresent"]);
|
||||
Assert.Equal("2.5", data["bootstrapScriptVersion"]);
|
||||
Assert.Equal("loader", data["bootstrapLoaderLog"]);
|
||||
Assert.Equal("1.2", data["bootstrapLoaderVersion"]);
|
||||
Assert.Equal(false, data["oobeBootstrapLogPresent"]);
|
||||
Assert.DoesNotContain(data.Values, v => v is string s && s.Contains("SECRETUSER"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keys_that_were_not_found_are_left_out()
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
BootstrapProvenanceReader.Read(_dir).AddTo(data);
|
||||
|
||||
Assert.Equal(new[] { "bootstrapScriptLogPresent", "oobeBootstrapLogPresent" }, data.Keys.OrderBy(k => k).ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Step 5b of the self-update: the staged payload must match its signed catalog before a
|
||||
/// single file is swapped in. The verifier is replaced through the static hook; each test
|
||||
/// restores it, as in <see cref="SelfUpdaterGracefulShutdownTests"/>.
|
||||
/// </summary>
|
||||
public sealed class SelfUpdaterPayloadCatalogTests : IDisposable
|
||||
{
|
||||
private const string StagingDir = @"C:\ProgramData\AutopilotMonitor\Agent-Update";
|
||||
|
||||
private readonly Func<string, PayloadCatalogVerdict> _savedVerifier;
|
||||
private readonly List<string> _log = new List<string>();
|
||||
|
||||
public SelfUpdaterPayloadCatalogTests()
|
||||
{
|
||||
_savedVerifier = SelfUpdater.VerifyPayloadCatalog;
|
||||
}
|
||||
|
||||
public void Dispose() => SelfUpdater.VerifyPayloadCatalog = _savedVerifier;
|
||||
|
||||
[Fact]
|
||||
public void Production_default_is_the_real_verifier()
|
||||
{
|
||||
Assert.Equal(typeof(PayloadCatalogVerifier), _savedVerifier.Method.DeclaringType);
|
||||
Assert.Equal(nameof(PayloadCatalogVerifier.Verify), _savedVerifier.Method.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accepted_payload_lets_the_update_continue()
|
||||
{
|
||||
string? checkedDir = null;
|
||||
SelfUpdater.VerifyPayloadCatalog = dir => { checkedDir = dir; return PayloadCatalogVerdict.Accept(); };
|
||||
|
||||
var ok = SelfUpdater.VerifyStagedPayload(StagingDir, _log.Add, out var reason);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Null(reason);
|
||||
Assert.Equal(StagingDir, checkedDir);
|
||||
Assert.Contains(_log, line => line.Contains("payload catalog verified"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejected_payload_stops_the_update_and_hands_over_the_reason()
|
||||
{
|
||||
SelfUpdater.VerifyPayloadCatalog = _ =>
|
||||
PayloadCatalogVerdict.Reject("payload differs from catalog (missing: none; extra: evil.dll; changed: none)");
|
||||
|
||||
var ok = SelfUpdater.VerifyStagedPayload(StagingDir, _log.Add, out var reason);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Contains("extra: evil.dll", reason);
|
||||
Assert.Contains(_log, line => line.Contains("REJECTED"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_throwing_verifier_is_a_rejection_not_a_pass()
|
||||
{
|
||||
SelfUpdater.VerifyPayloadCatalog = _ => throw new InvalidOperationException("runspace broke");
|
||||
|
||||
var ok = SelfUpdater.VerifyStagedPayload(StagingDir, _log.Add, out var reason);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Contains("runspace broke", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_catalog_check_runs_before_any_file_is_swapped()
|
||||
{
|
||||
// Source-level pin: CheckAndApplyUpdateAsync has no seam for the download, so the
|
||||
// ordering that makes the check meaningful is pinned on the source itself.
|
||||
var source = File.ReadAllText(Path.Combine(FindRepoRoot(),
|
||||
"src", "Agent", "AutopilotMonitor.Agent.V2.Core", "Monitoring", "Runtime", "SelfUpdater.cs"));
|
||||
|
||||
var check = source.IndexOf("if (!VerifyStagedPayload(stagingDir, log, out var catalogRejection))", StringComparison.Ordinal);
|
||||
var swap = source.IndexOf("var swapOk = SwapFiles(agentDir, stagingDir, log);", StringComparison.Ordinal);
|
||||
|
||||
Assert.True(check > 0, "step 5b not found in SelfUpdater.cs");
|
||||
Assert.True(swap > check, "SwapFiles must come after the catalog check");
|
||||
Assert.Contains("WriteSkipMarker(\"catalog_rejected\"", source);
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(dir);
|
||||
return dir.FullName;
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -44,6 +44,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Assert.Equal("Succeeded", data["remoteConfigOutcome"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmitAgentStarted_CarriesTheBootstrapProvenance()
|
||||
{
|
||||
// Values depend on the machine's %ProgramData%\AutopilotMonitor\Logs (see
|
||||
// BootstrapProvenanceReaderTests); the two presence flags are always on the wire.
|
||||
using var rig = new EmitterRig();
|
||||
rig.RemoteConfig.SetFakeOutcome(RemoteConfigFetchOutcome.Succeeded, configVersion: 28);
|
||||
|
||||
LifecycleEmitters.EmitAgentStarted(
|
||||
rig.Post, rig.AgentConfig, rig.PreviousExit, "2.0.806", rig.RemoteConfig, rig.Logger);
|
||||
|
||||
var data = AssertSingleEmit(rig.Sink, expectedEventType: "agent_started");
|
||||
Assert.IsType<bool>(data["bootstrapScriptLogPresent"]);
|
||||
Assert.IsType<bool>(data["oobeBootstrapLogPresent"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmitAgentStarted_OnFallbackToDefaults_FlagsRemoteConfigFalse()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Runtime;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
{
|
||||
/// <summary>
|
||||
/// The TPM-PSS report after a terminal SecureChannelFailure: it leaves before the run exits,
|
||||
/// carries the handshake code, the Schannel events and the TPM firmware, and a slow or failing
|
||||
/// context read never holds it back.
|
||||
/// </summary>
|
||||
public sealed class TpmPssReportTests
|
||||
{
|
||||
private static AgentLogger NewLogger(TempDirectory tmp)
|
||||
=> new AgentLogger(Path.Combine(tmp.Path, "logs"), AgentLogLevel.Debug);
|
||||
|
||||
private static TpmPssProbeResult Probe(bool pssWorks = false, string provider = "Microsoft Platform Crypto Provider") =>
|
||||
new TpmPssProbeResult(
|
||||
pkcs1Sha256Works: true, pssSha256Works: pssWorks, pssSha384Works: pssWorks,
|
||||
providerName: provider, keySizeBits: 2048,
|
||||
pssSha256Error: pssWorks ? null : "salt", pssSha384Error: pssWorks ? null : "not supported",
|
||||
pssSha256ErrorCode: pssWorks ? (int?)null : unchecked((int)0x80290423),
|
||||
pssSha384ErrorCode: pssWorks ? (int?)null : unchecked((int)0x80090029));
|
||||
|
||||
private static Exception TlsFailure() =>
|
||||
new WebException("Could not create SSL/TLS secure channel.",
|
||||
new Win32Exception(unchecked((int)0x80090326)), WebExceptionStatus.SecureChannelFailure, null);
|
||||
|
||||
private static Func<Task<(string TpmVersion, string SchannelEvents)>> Context(string tpm, string schannel) =>
|
||||
() => Task.FromResult((tpm, schannel));
|
||||
|
||||
[Fact]
|
||||
public async Task Broken_tpm_report_completes_only_after_the_send()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var sendGate = new TaskCompletionSource<bool>();
|
||||
var reporter = new FakeDistressReporter { Send = () => sendGate.Task };
|
||||
|
||||
var report = BackendSessionRegistration.ReportProbeResultAsync(
|
||||
Probe(), TlsFailure(), reporter, NewLogger(tmp), Context("IFX 7.63.3353.0", "36888:40/1205"));
|
||||
|
||||
var call = Assert.Single(reporter.Calls);
|
||||
Assert.False(report.IsCompleted);
|
||||
Assert.Equal(DistressErrorType.TpmPssUnsupported, call.ErrorType);
|
||||
Assert.Equal("IFX 7.63.3353.0", call.TpmVersion);
|
||||
Assert.Equal(
|
||||
"pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029) tls=0x80090326 sch=36888:40/1205 prov=PCP key=2048",
|
||||
call.Message);
|
||||
|
||||
sendGate.SetResult(true);
|
||||
await report;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failing_context_read_still_sends()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var reporter = new FakeDistressReporter();
|
||||
|
||||
await BackendSessionRegistration.ReportProbeResultAsync(
|
||||
Probe(), TlsFailure(), reporter, NewLogger(tmp),
|
||||
() => throw new InvalidOperationException("WMI gone"));
|
||||
|
||||
var call = Assert.Single(reporter.Calls);
|
||||
Assert.Null(call.TpmVersion);
|
||||
Assert.DoesNotContain("sch=", call.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Working_pss_sends_nothing()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var reporter = new FakeDistressReporter();
|
||||
|
||||
await BackendSessionRegistration.ReportProbeResultAsync(
|
||||
Probe(pssWorks: true), TlsFailure(), reporter, NewLogger(tmp), Context("IFX 7.85", null!));
|
||||
|
||||
Assert.Empty(reporter.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Software_key_with_broken_pss_sends_nothing()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var reporter = new FakeDistressReporter();
|
||||
|
||||
await BackendSessionRegistration.ReportProbeResultAsync(
|
||||
Probe(provider: "Microsoft Software Key Storage Provider"), TlsFailure(), reporter, NewLogger(tmp), Context(null!, null!));
|
||||
|
||||
Assert.Empty(reporter.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Context_reads_return_what_finished_within_the_bound()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var slowRelease = new ManualResetEventSlim(false);
|
||||
using var fastReturned = new ManualResetEventSlim(false);
|
||||
try
|
||||
{
|
||||
// The bound ends only after the fast read returned (plus a grace for its task to
|
||||
// complete) — independent of how busy the thread pool is.
|
||||
var bound = Task.Run(async () =>
|
||||
{
|
||||
fastReturned.Wait(TimeSpan.FromSeconds(30));
|
||||
await Task.Delay(500);
|
||||
});
|
||||
|
||||
var (tpm, schannel) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
|
||||
() => { fastReturned.Set(); return "IFX 7.63.3353.0"; },
|
||||
() => { slowRelease.Wait(TimeSpan.FromSeconds(30)); return "late"; },
|
||||
bound,
|
||||
NewLogger(tmp));
|
||||
|
||||
Assert.Equal("IFX 7.63.3353.0", tpm);
|
||||
Assert.Null(schannel);
|
||||
}
|
||||
finally
|
||||
{
|
||||
slowRelease.Set();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Context_read_that_throws_counts_as_missing()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
|
||||
var (tpm, schannel) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
|
||||
() => throw new InvalidOperationException("no WMI"),
|
||||
() => "36887:116",
|
||||
Task.Delay(TimeSpan.FromSeconds(30)),
|
||||
NewLogger(tmp));
|
||||
|
||||
Assert.Null(tpm);
|
||||
Assert.Equal("36887:116", schannel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// The agent data directory keeps standard users from adding files where the SYSTEM agent
|
||||
/// starts from. Works on a temp folder the test owns; as owner it can always hand itself the
|
||||
/// rights back to clean up.
|
||||
/// </summary>
|
||||
public sealed class AgentDirectoryAclTests : IDisposable
|
||||
{
|
||||
private const FileSystemRights WriteRights =
|
||||
FileSystemRights.WriteData | FileSystemRights.AppendData | FileSystemRights.WriteAttributes
|
||||
| FileSystemRights.WriteExtendedAttributes | FileSystemRights.Delete | FileSystemRights.DeleteSubdirectoriesAndFiles
|
||||
| FileSystemRights.ChangePermissions | FileSystemRights.TakeOwnership;
|
||||
|
||||
private static readonly SecurityIdentifier Users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null);
|
||||
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "apm-acl-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public AgentDirectoryAclTests() => Directory.CreateDirectory(_root);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
var me = WindowsIdentity.GetCurrent().User!;
|
||||
var directory = new DirectoryInfo(_root);
|
||||
var security = directory.GetAccessControl();
|
||||
security.AddAccessRule(new FileSystemAccessRule(me, FileSystemRights.FullControl,
|
||||
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow));
|
||||
directory.SetAccessControl(security);
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best effort: a leftover temp folder must not fail the run
|
||||
}
|
||||
}
|
||||
|
||||
private static FileSystemRights UsersRights(string path) =>
|
||||
new DirectoryInfo(path).GetAccessControl()
|
||||
.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier))
|
||||
.Cast<FileSystemAccessRule>()
|
||||
.Where(r => r.AccessControlType == AccessControlType.Allow && Users.Equals(r.IdentityReference))
|
||||
.Aggregate((FileSystemRights)0, (all, r) => all | r.FileSystemRights);
|
||||
|
||||
[Fact]
|
||||
public void The_protected_security_inherits_nothing_and_gives_users_read_only()
|
||||
{
|
||||
var security = AgentDirectoryAcl.BuildProtectedSecurity();
|
||||
|
||||
Assert.True(security.AreAccessRulesProtected);
|
||||
var users = security.GetAccessRules(true, true, typeof(SecurityIdentifier)).Cast<FileSystemAccessRule>()
|
||||
.Single(r => Users.Equals(r.IdentityReference));
|
||||
// An allow rule always carries Synchronize; .NET adds it on construction.
|
||||
Assert.Equal(FileSystemRights.ReadAndExecute, users.FileSystemRights & ~FileSystemRights.Synchronize);
|
||||
Assert.Equal((FileSystemRights)0, users.FileSystemRights & WriteRights);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_folder_is_protected_once_and_then_left_alone()
|
||||
{
|
||||
var first = AgentDirectoryAcl.EnsureProtected(_root);
|
||||
var second = AgentDirectoryAcl.EnsureProtected(_root);
|
||||
|
||||
Assert.Equal(AgentDirectoryAclOutcome.Applied, first.Outcome);
|
||||
Assert.Equal(AgentDirectoryAclOutcome.AlreadyProtected, second.Outcome);
|
||||
Assert.True(AgentDirectoryAcl.IsProtectedAsExpected(new DirectoryInfo(_root).GetAccessControl(AccessControlSections.Access)));
|
||||
Assert.Equal((FileSystemRights)0, UsersRights(_root) & WriteRights);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Existing_subfolders_take_over_the_protection()
|
||||
{
|
||||
// Install mode protects the base after Agent, Spool and Logs already exist.
|
||||
var agent = Directory.CreateDirectory(Path.Combine(_root, "Agent")).FullName;
|
||||
|
||||
AgentDirectoryAcl.EnsureProtected(_root);
|
||||
|
||||
Assert.Equal((FileSystemRights)0, UsersRights(agent) & WriteRights);
|
||||
Assert.NotEqual((FileSystemRights)0, UsersRights(agent) & FileSystemRights.ReadAndExecute);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unprotected_or_widened_dacl_is_not_taken_as_protected()
|
||||
{
|
||||
var unprotected = AgentDirectoryAcl.BuildProtectedSecurity();
|
||||
unprotected.SetAccessRuleProtection(isProtected: false, preserveInheritance: true);
|
||||
Assert.False(AgentDirectoryAcl.IsProtectedAsExpected(unprotected));
|
||||
|
||||
var widened = AgentDirectoryAcl.BuildProtectedSecurity();
|
||||
widened.AddAccessRule(new FileSystemAccessRule(Users, FileSystemRights.Modify,
|
||||
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow));
|
||||
Assert.False(AgentDirectoryAcl.IsProtectedAsExpected(widened));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_missing_folder_is_reported_not_created()
|
||||
{
|
||||
var missing = Path.Combine(_root, "does-not-exist");
|
||||
|
||||
Assert.Equal(AgentDirectoryAclOutcome.Missing, AgentDirectoryAcl.EnsureProtected(missing).Outcome);
|
||||
Assert.False(Directory.Exists(missing));
|
||||
}
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// The decision over what <c>Test-FileCatalog -Detailed</c> reports. The runspace itself is
|
||||
/// exercised on a lab device (plan agent-payload-catalog, V1/V3), never in the test host.
|
||||
/// </summary>
|
||||
public sealed class PayloadCatalogVerifierTests
|
||||
{
|
||||
private const string OurSubject = "CN=glueckkanja AG, O=glueckkanja AG, L=Offenbach, S=Hessen, C=DE";
|
||||
|
||||
private static Dictionary<string, string> Files(params string[] namesAndHashes)
|
||||
{
|
||||
var map = new Dictionary<string, string>();
|
||||
for (var i = 0; i < namesAndHashes.Length; i += 2) map[namesAndHashes[i]] = namesAndHashes[i + 1];
|
||||
return map;
|
||||
}
|
||||
|
||||
private static PayloadCatalogFacts Facts(
|
||||
string status = "Valid", string? signatureStatus = "Valid", string? subject = OurSubject,
|
||||
Dictionary<string, string>? catalog = null, Dictionary<string, string>? path = null)
|
||||
=> new PayloadCatalogFacts(status, signatureStatus, subject,
|
||||
catalog ?? Files("a.dll", "AA"), path ?? Files("a.dll", "AA"));
|
||||
|
||||
private static PayloadCatalogVerdict Evaluate(PayloadCatalogFacts facts)
|
||||
=> PayloadCatalogVerifier.Evaluate(facts, Constants.CodeSigningPublisherPattern);
|
||||
|
||||
[Fact]
|
||||
public void Signed_by_us_and_matching_is_accepted()
|
||||
{
|
||||
var verdict = Evaluate(Facts());
|
||||
|
||||
Assert.True(verdict.Accepted);
|
||||
Assert.Null(verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matching_hashes_under_an_unsigned_catalog_are_rejected()
|
||||
{
|
||||
// Status covers the hashes only: an unsigned catalog over matching files reports Valid.
|
||||
var verdict = Evaluate(Facts(signatureStatus: "NotSigned", subject: null));
|
||||
|
||||
Assert.False(verdict.Accepted);
|
||||
Assert.Contains("catalog signature status NotSigned", verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_validly_signed_catalog_from_another_publisher_is_rejected()
|
||||
{
|
||||
var verdict = Evaluate(Facts(subject: "CN=Contoso Ltd, O=Contoso Ltd, C=US"));
|
||||
|
||||
Assert.False(verdict.Accepted);
|
||||
Assert.Contains("does not match", verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_signature_details_are_a_rejection_not_a_pass()
|
||||
{
|
||||
Assert.False(Evaluate(Facts(signatureStatus: null, subject: null)).Accepted);
|
||||
Assert.False(Evaluate(Facts(subject: "")).Accepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_differing_payload_names_missing_extra_and_changed_files()
|
||||
{
|
||||
var verdict = Evaluate(Facts(
|
||||
status: "ValidationFailed",
|
||||
catalog: Files("a.dll", "AA", "b.dll", "BB", "c.config", "CC"),
|
||||
path: Files("a.dll", "AA", "b.dll", "XX", "evil.dll", "EE")));
|
||||
|
||||
Assert.False(verdict.Accepted);
|
||||
Assert.Contains("missing: c.config", verdict.Reason);
|
||||
Assert.Contains("extra: evil.dll", verdict.Reason);
|
||||
Assert.Contains("changed: b.dll", verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Long_difference_lists_are_capped()
|
||||
{
|
||||
var path = Files("a.dll", "AA");
|
||||
for (var i = 0; i < 12; i++) path[$"extra{i:00}.txt"] = "00";
|
||||
|
||||
var verdict = Evaluate(Facts(status: "ValidationFailed", path: path));
|
||||
|
||||
Assert.Contains("(+7 more)", verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Signature_and_payload_problems_are_both_reported()
|
||||
{
|
||||
var verdict = Evaluate(Facts(status: "ValidationFailed", signatureStatus: "HashMismatch", subject: null,
|
||||
path: Files("a.dll", "AA", "x.dll", "11")));
|
||||
|
||||
Assert.Contains("catalog signature status HashMismatch", verdict.Reason);
|
||||
Assert.Contains("extra: x.dll", verdict.Reason);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(OurSubject, true)]
|
||||
[InlineData("cn=glueckkanja ag, o=glueckkanja ag, c=de", true)] // -like is case-insensitive
|
||||
[InlineData("CN=x, O=glueckkanja AG Holding, C=DE", true)] // the pattern is open-ended by design
|
||||
[InlineData("CN=glueckkanja AG, O=Contoso, C=DE", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void Publisher_pattern_behaves_like_powershell_like(string? subject, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, PayloadCatalogVerifier.MatchesPublisherPattern(subject, Constants.CodeSigningPublisherPattern));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Regex_metacharacters_in_the_pattern_are_literal()
|
||||
{
|
||||
Assert.True(PayloadCatalogVerifier.MatchesPublisherPattern("O=Json.NET (.NET Foundation)", "*O=Json.NET (.NET Foundation)*"));
|
||||
Assert.False(PayloadCatalogVerifier.MatchesPublisherPattern("O=JsonXNET (.NET Foundation)", "*O=Json.NET (.NET Foundation)*"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_payload_without_catalog_is_rejected_before_any_runspace_starts()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "apm-catalog-test-" + System.Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "readme.txt"), "x");
|
||||
|
||||
var verdict = PayloadCatalogVerifier.Verify(dir);
|
||||
|
||||
Assert.False(verdict.Accepted);
|
||||
Assert.Contains("missing", verdict.Reason);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
public sealed class SchannelEventReaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Token_is_event_id_with_numeric_properties()
|
||||
{
|
||||
Assert.Equal("36888:40/1205", SchannelEventReader.FormatToken(36888, new[] { "40", "1205" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_drops_text_properties()
|
||||
{
|
||||
// Newer builds add target name and certificate subjects; they stay on the device.
|
||||
Assert.Equal("36887:116", SchannelEventReader.FormatToken(
|
||||
36887, new[] { "116", "autopilotmonitor-api.example.test", "CN=device", "" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_without_numbers_is_the_event_id()
|
||||
{
|
||||
Assert.Equal("36871", SchannelEventReader.FormatToken(36871, new[] { "TLS 1.2" }));
|
||||
Assert.Equal("36871", SchannelEventReader.FormatToken(36871, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_keeps_at_most_three_numbers()
|
||||
{
|
||||
Assert.Equal("36888:1/2/3", SchannelEventReader.FormatToken(36888, new[] { "1", "2", "3", "4" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_rejects_non_ascii_digits()
|
||||
{
|
||||
Assert.Equal("36888", SchannelEventReader.FormatToken(36888, new[] { "٤٠" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_is_accepted_by_the_event_log_service()
|
||||
{
|
||||
// A malformed XPath would only surface as "no events" through ReadSince (it never
|
||||
// throws), so the query itself is run here.
|
||||
using (var reader = new EventLogReader(new EventLogQuery(
|
||||
"System", PathType.LogName, SchannelEventReader.BuildXPath(DateTime.UtcNow.AddDays(-1))) { ReverseDirection = true }))
|
||||
{
|
||||
using (reader.ReadEvent()) { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_events_after_now()
|
||||
{
|
||||
Assert.Null(SchannelEventReader.ReadSince(DateTime.UtcNow.AddHours(1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -332,6 +332,74 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
Assert.Contains("transient attempt 6", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Terminal_callback_gets_the_last_attempts_exception()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
Exception? seen = null;
|
||||
|
||||
var apiClient = new FakeApiClient(attempt =>
|
||||
throw new InvalidOperationException($"transient attempt {attempt}"));
|
||||
|
||||
await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
backoffDelay: _ => Task.CompletedTask,
|
||||
onTerminalTransportFailure: ex => { seen = ex; return Task.CompletedTask; });
|
||||
|
||||
Assert.Equal("transient attempt 6", seen?.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Terminal_callback_skipped_when_the_last_attempt_answered()
|
||||
{
|
||||
// Five transport failures, then the backend answers without success: the run did not
|
||||
// end in a transport failure, so nothing may classify the old exception.
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
var callbackRan = false;
|
||||
|
||||
var apiClient = new FakeApiClient(attempt =>
|
||||
{
|
||||
if (attempt < 6) throw new InvalidOperationException($"transient attempt {attempt}");
|
||||
return new RegisterSessionResponse { Success = false, Message = "backend 500" };
|
||||
});
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
backoffDelay: _ => Task.CompletedTask,
|
||||
onTerminalTransportFailure: _ => { callbackRan = true; return Task.CompletedTask; });
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.Failed, result.Outcome);
|
||||
Assert.Equal("backend 500", result.ErrorMessage);
|
||||
Assert.False(callbackRan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failure_message_carries_the_native_code_of_the_tls_failure()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
|
||||
var apiClient = new FakeApiClient(_ =>
|
||||
throw new System.Net.Http.HttpRequestException(
|
||||
"An error occurred while sending the request.",
|
||||
new System.Net.WebException(
|
||||
"Could not create SSL/TLS secure channel.",
|
||||
new System.ComponentModel.Win32Exception(unchecked((int)0x80090326)),
|
||||
System.Net.WebExceptionStatus.SecureChannelFailure,
|
||||
null)));
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
backoffDelay: _ => Task.CompletedTask);
|
||||
|
||||
Assert.Equal("An error occurred while sending the request. (win32 0x80090326)", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Waits_for_network_link_before_first_attempt()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// The TPM-PSS distress message: codes instead of localized error texts, so reports from
|
||||
/// German, Czech and English devices read the same and the 256-char budget holds the
|
||||
/// handshake code and the Schannel events.
|
||||
/// </summary>
|
||||
public sealed class TpmPssProbeResultTests
|
||||
{
|
||||
private const int SaltSizeMismatch = unchecked((int)0x80290423);
|
||||
private const int NotSupported = unchecked((int)0x80090029);
|
||||
|
||||
private static TpmPssProbeResult BrokenPcp(string provider = "Microsoft Platform Crypto Provider") =>
|
||||
new TpmPssProbeResult(
|
||||
pkcs1Sha256Works: true,
|
||||
pssSha256Works: false,
|
||||
pssSha384Works: false,
|
||||
providerName: provider,
|
||||
keySizeBits: 2048,
|
||||
pssSha256Error: "Die angeforderte Salt-Größe für die Signierung mit RSAPSS entspricht nicht dem, was TPM verwendet.",
|
||||
pssSha384Error: "Der angeforderte Vorgang wird nicht unterstützt.",
|
||||
pssSha256ErrorCode: SaltSizeMismatch,
|
||||
pssSha384ErrorCode: NotSupported);
|
||||
|
||||
[Fact]
|
||||
public void Message_carries_codes_handshake_and_schannel_events()
|
||||
{
|
||||
var message = BrokenPcp().ToDistressMessage("0x80090326", "36888:40/1205,36887:116");
|
||||
|
||||
Assert.Equal(
|
||||
"pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029) tls=0x80090326 sch=36888:40/1205,36887:116 prov=PCP key=2048",
|
||||
message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Message_never_carries_the_localized_error_text()
|
||||
{
|
||||
var message = BrokenPcp().ToDistressMessage();
|
||||
|
||||
Assert.DoesNotContain("Salt", message);
|
||||
Assert.Equal("pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029) prov=PCP key=2048", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Working_signatures_and_failures_without_a_code()
|
||||
{
|
||||
var result = new TpmPssProbeResult(
|
||||
pkcs1Sha256Works: true, pssSha256Works: true, pssSha384Works: false,
|
||||
providerName: "Microsoft Software Key Storage Provider", keySizeBits: 0,
|
||||
pssSha256Error: null, pssSha384Error: "empty signature");
|
||||
|
||||
Assert.Equal("pkcs1=ok pss256=ok pss384=fail prov=KSP", result.ToDistressMessage());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Message_stays_within_the_distress_cap()
|
||||
{
|
||||
var longEvents = string.Join(",", System.Linq.Enumerable.Repeat("36888:4294967295/4294967295/4294967295", 10));
|
||||
|
||||
var message = BrokenPcp(new string('P', 200)).ToDistressMessage("0x80090326", longEvents);
|
||||
|
||||
Assert.Equal(TpmPssProbeResult.MaxDistressMessageLength, message.Length);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Microsoft Platform Crypto Provider", "PCP")]
|
||||
[InlineData("microsoft platform crypto provider", "PCP")]
|
||||
[InlineData("Microsoft Software Key Storage Provider", "KSP")]
|
||||
[InlineData("Contoso Smart Card Provider", "Contoso Smart Card Provider")]
|
||||
[InlineData("", "(unknown)")]
|
||||
public void Provider_names_are_shortened(string provider, string expected)
|
||||
{
|
||||
Assert.Equal(expected, TpmPssProbeResult.ShortProviderName(provider));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Long_provider_name_is_cut_to_32_chars()
|
||||
{
|
||||
Assert.Equal(32, TpmPssProbeResult.ShortProviderName(new string('x', 40)).Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tpm_backed_broken_key_is_the_reportable_footprint()
|
||||
{
|
||||
var result = BrokenPcp();
|
||||
|
||||
Assert.True(result.IsTpmPssBroken);
|
||||
Assert.True(result.IsTpmBacked);
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.DeviceInfo
|
||||
{
|
||||
public sealed class TpmInfoLabelTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("IFX", "7.63.3353.0", "IFX 7.63.3353.0")]
|
||||
[InlineData("STM ", "73.4.17568.4452", "STM 73.4.17568.4452")] // ManufacturerIdTxt is space-padded
|
||||
[InlineData("INTC", null, "INTC")]
|
||||
[InlineData(null, "403.1.0.0", "403.1.0.0")]
|
||||
[InlineData(" ", " ", null)]
|
||||
[InlineData(null, null, null)]
|
||||
public void Label_is_trimmed_manufacturer_and_firmware(string? name, string? version, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, TpmInfo.FormatLabel(name!, version!));
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// What a distress report carries and that it fits the backend's body cap: the endpoint drops
|
||||
/// a larger body with 200, so an oversized report would be lost without a trace.
|
||||
/// </summary>
|
||||
public sealed class DistressReporterPayloadTests
|
||||
{
|
||||
private static DistressReporter NewReporter(string field = "x", string agentVersion = "2.0.1500")
|
||||
=> new DistressReporter(
|
||||
baseUrl: "https://example.test",
|
||||
tenantId: "11111111-2222-3333-4444-555555555555",
|
||||
manufacturer: field,
|
||||
model: field,
|
||||
serialNumber: field,
|
||||
agentVersion: agentVersion,
|
||||
logger: null!);
|
||||
|
||||
[Fact]
|
||||
public void Report_carries_os_build_and_setup_state_at_send_time()
|
||||
{
|
||||
using (var reporter = NewReporter())
|
||||
{
|
||||
var report = reporter.BuildReport(DistressErrorType.AuthCertificateRejected, "m", 401, tpmVersion: null);
|
||||
|
||||
var osBuild = DeviceInfoProvider.GetOsBuild();
|
||||
Assert.Equal(string.IsNullOrEmpty(osBuild) ? null : osBuild, report.OsBuild);
|
||||
Assert.Equal(OobeStateReader.Read(), report.OobeState);
|
||||
Assert.Null(report.TpmVersion);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tpm_version_passes_through_capped_at_48()
|
||||
{
|
||||
using (var reporter = NewReporter())
|
||||
{
|
||||
Assert.Equal("IFX 7.63.3353.0",
|
||||
reporter.BuildReport(DistressErrorType.TpmPssUnsupported, "m", null, "IFX 7.63.3353.0").TpmVersion);
|
||||
Assert.Equal(48,
|
||||
reporter.BuildReport(DistressErrorType.TpmPssUnsupported, "m", null, new string('v', 60)).TpmVersion!.Length);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Largest_ascii_report_fits_the_cap_untouched()
|
||||
{
|
||||
// Every field at the agent's own caps; the agent version as built (not cut to 32).
|
||||
using (var reporter = NewReporter(new string('A', 64), "2.0.1486+d95640c597cb90849803dd65afda19e2d7a39444"))
|
||||
{
|
||||
var report = reporter.BuildReport(DistressErrorType.ClientCertificateNotReceived, new string('m', 256), 403, new string('t', 48));
|
||||
report.CertThumbprint = new string('F', 40);
|
||||
report.CertSubject = new string('S', 96);
|
||||
report.CertIssuer = new string('I', 96);
|
||||
report.CertNotBefore = DateTime.UtcNow;
|
||||
report.CertNotAfter = DateTime.UtcNow;
|
||||
report.OsBuild = "26300.97111";
|
||||
report.OobeState = "unknown_2147483647".Substring(0, 16);
|
||||
|
||||
var json = DistressReporter.SerializeWithinCap(report);
|
||||
|
||||
Assert.Equal(256, report.Message!.Length);
|
||||
Assert.True(Encoding.UTF8.GetByteCount(json) <= Constants.DistressReportMaxBytes,
|
||||
$"{Encoding.UTF8.GetByteCount(json)} bytes");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Oversized_report_gives_up_message_characters_to_fit()
|
||||
{
|
||||
// Three-byte characters everywhere — far past the cap before trimming.
|
||||
using (var reporter = NewReporter(new string('€', 64)))
|
||||
{
|
||||
var report = reporter.BuildReport(DistressErrorType.ClientCertificateNotReceived, new string('€', 256), 403, null);
|
||||
report.CertSubject = new string('€', 96);
|
||||
report.CertIssuer = new string('€', 96);
|
||||
Assert.True(Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(report)) > Constants.DistressReportMaxBytes);
|
||||
|
||||
var json = DistressReporter.SerializeWithinCap(report);
|
||||
|
||||
Assert.True(Encoding.UTF8.GetByteCount(json) <= Constants.DistressReportMaxBytes,
|
||||
$"{Encoding.UTF8.GetByteCount(json)} bytes");
|
||||
Assert.True(report.Message!.Length < 256);
|
||||
Assert.Equal(report.Message, JsonConvert.DeserializeObject<DistressReport>(json)!.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Security.Authentication;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
public sealed class TransportErrorCodesTests
|
||||
{
|
||||
private const int IllegalMessage = unchecked((int)0x80090326);
|
||||
|
||||
// The .NET Framework shape of a failed TLS handshake through HttpClient.
|
||||
private static Exception TlsFailureChain(params Exception[] innermost) =>
|
||||
new HttpRequestException(
|
||||
"An error occurred while sending the request.",
|
||||
new WebException(
|
||||
"The request was aborted: Could not create SSL/TLS secure channel.",
|
||||
new AuthenticationException("A call to SSPI failed, see inner exception.", innermost.Length > 0 ? innermost[0] : null),
|
||||
WebExceptionStatus.SecureChannelFailure,
|
||||
null));
|
||||
|
||||
[Fact]
|
||||
public void Hex_is_eight_digits_with_prefix()
|
||||
{
|
||||
Assert.Equal("0x80090326", TransportErrorCodes.Hex(IllegalMessage));
|
||||
Assert.Equal("0x00002746", TransportErrorCodes.Hex(10054));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Innermost_win32_code_of_a_tls_failure()
|
||||
{
|
||||
var chain = TlsFailureChain(new Win32Exception(IllegalMessage));
|
||||
|
||||
Assert.Equal("0x80090326", TransportErrorCodes.InnermostNativeCode(chain));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Innermost_wins_over_an_outer_win32_exception()
|
||||
{
|
||||
// The outer code is whatever the last Win32 error was; only the innermost counts.
|
||||
var chain = new Win32Exception("outer", new Win32Exception(IllegalMessage));
|
||||
|
||||
Assert.Equal("0x80090326", TransportErrorCodes.InnermostNativeCode(chain));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_win32_exception_no_code()
|
||||
{
|
||||
Assert.Null(TransportErrorCodes.InnermostNativeCode(TlsFailureChain()));
|
||||
Assert.Null(TransportErrorCodes.InnermostNativeCode(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Message_gets_the_code_appended()
|
||||
{
|
||||
var chain = TlsFailureChain(new Win32Exception(IllegalMessage));
|
||||
|
||||
Assert.Equal(
|
||||
"An error occurred while sending the request. (win32 0x80090326)",
|
||||
TransportErrorCodes.MessageWithNativeCode(chain));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Message_without_a_code_stays_as_is()
|
||||
{
|
||||
Assert.Equal("boom", TransportErrorCodes.MessageWithNativeCode(new InvalidOperationException("boom")));
|
||||
Assert.Null(TransportErrorCodes.MessageWithNativeCode(null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,8 +62,15 @@
|
||||
<ItemGroup>
|
||||
<Reference Include="System.Net.Http" />
|
||||
<Reference Include="System.Management" />
|
||||
<!--
|
||||
Compile against the GAC copy, never ship it (Private=false). The runtime binds this
|
||||
strong-named assembly from the device's GAC before it probes the app folder, and every
|
||||
Windows 10/11 has it (Windows PowerShell 5.1 runs on it), so a shipped copy was never
|
||||
loaded. It was the build machine's file: unsigned on any other machine, and half the ZIP.
|
||||
-->
|
||||
<Reference Include="System.Management.Automation">
|
||||
<HintPath>$(windir)\Microsoft.NET\assembly\GAC_MSIL\System.Management.Automation\v4.0_3.0.0.0__31bf3856ad364e35\System.Management.Automation.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="System.ServiceProcess" />
|
||||
<Reference Include="System.IO.Compression" />
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Which bootstrap installed this agent, read from the bootstrap scripts' own logs next to the
|
||||
/// agent log. The scripts cannot report it themselves (they run before any agent exists), and
|
||||
/// 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_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>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
|
||||
/// never read out. Observational only, and it never throws: an unreadable log reads as absent.
|
||||
/// </summary>
|
||||
internal static class BootstrapProvenanceReader
|
||||
{
|
||||
internal const string InstallerLogName = "bootstrap-script.log";
|
||||
internal const string LoaderLogName = "bootstrap-loader.log";
|
||||
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;
|
||||
|
||||
private static readonly Regex InstallerVersionLine = new Regex(
|
||||
@"Bootstrap script version: v(?<v>\d+(?:\.\d+){0,3})\b", RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex LoaderStartLine = new Regex(
|
||||
@"bootstrap loader v(?<v>\d+(?:\.\d+){0,3}) started\b", RegexOptions.CultureInvariant);
|
||||
|
||||
public static BootstrapProvenance Read(string logDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
var installerLog = ReadTail(MostRecentlyWritten(logDirectory, InstallerLogName, LegacyInstallerLogName));
|
||||
|
||||
// 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: loaderChannel,
|
||||
loaderVersion: LastVersion(ReadTail(loaderPath), LoaderStartLine),
|
||||
oobeLogPresent: File.Exists(Path.Combine(logDirectory, OobeLogName)));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return BootstrapProvenance.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
string version = null;
|
||||
foreach (Match match in line.Matches(text))
|
||||
version = match.Groups["v"].Value;
|
||||
return version;
|
||||
}
|
||||
|
||||
/// <summary>The last <see cref="TailBytes"/> of the file, or null when it is missing or unreadable.</summary>
|
||||
private static string ReadTail(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path)) return null;
|
||||
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
|
||||
{
|
||||
var start = Math.Max(0, stream.Length - TailBytes);
|
||||
stream.Seek(start, SeekOrigin.Begin);
|
||||
var buffer = new byte[stream.Length - start];
|
||||
var read = 0;
|
||||
while (read < buffer.Length)
|
||||
{
|
||||
var n = stream.Read(buffer, read, buffer.Length - read);
|
||||
if (n == 0) break;
|
||||
read += n;
|
||||
}
|
||||
// The scripts write ASCII (their own pure-ASCII rule); UTF-8 decodes it either way.
|
||||
return Encoding.UTF8.GetString(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What <see cref="BootstrapProvenanceReader"/> found; null fields were not found.</summary>
|
||||
internal sealed class BootstrapProvenance
|
||||
{
|
||||
public static readonly BootstrapProvenance Unknown = new BootstrapProvenance(false, null, null, null, false);
|
||||
|
||||
public BootstrapProvenance(bool scriptLogPresent, string scriptVersion, string loaderLog, string loaderVersion, bool oobeLogPresent)
|
||||
{
|
||||
ScriptLogPresent = scriptLogPresent;
|
||||
ScriptVersion = scriptVersion;
|
||||
LoaderLog = loaderLog;
|
||||
LoaderVersion = loaderVersion;
|
||||
OobeLogPresent = oobeLogPresent;
|
||||
}
|
||||
|
||||
public bool ScriptLogPresent { get; }
|
||||
public string ScriptVersion { get; }
|
||||
public string LoaderLog { get; }
|
||||
public string LoaderVersion { get; }
|
||||
public bool OobeLogPresent { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Adds the <c>agent_started</c> keys. Version and loader keys only when found, so a
|
||||
/// missing key always means "not found" and never an empty string.
|
||||
/// </summary>
|
||||
public void AddTo(IDictionary<string, object> data)
|
||||
{
|
||||
data["bootstrapScriptLogPresent"] = ScriptLogPresent;
|
||||
if (ScriptVersion != null) data["bootstrapScriptVersion"] = ScriptVersion;
|
||||
if (LoaderLog != null) data["bootstrapLoaderLog"] = LoaderLog;
|
||||
if (LoaderVersion != null) data["bootstrapLoaderVersion"] = LoaderVersion;
|
||||
data["oobeBootstrapLogPresent"] = OobeLogPresent;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// </summary>
|
||||
internal const int GracefulExitFallbackMs = 15000;
|
||||
|
||||
/// <summary>
|
||||
/// The payload catalog check of step 5b. Production keeps the default,
|
||||
/// <see cref="PayloadCatalogVerifier.Verify"/>; tests swap it so the update path runs
|
||||
/// without a PowerShell runspace. Same static-hook idiom as <see cref="BackendExpectedSha256"/>.
|
||||
/// </summary>
|
||||
internal static Func<string, PayloadCatalogVerdict> VerifyPayloadCatalog { get; set; } = PayloadCatalogVerifier.Verify;
|
||||
|
||||
/// <summary>
|
||||
/// Checks for a newer agent version and applies the update if available.
|
||||
/// On success, restarts the process and never returns.
|
||||
@@ -381,6 +388,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 5b: The payload must match its signed catalog -- the check the installer and
|
||||
// the OOBE script run before --install. Covers what the ZIP hash cannot: a hash
|
||||
// from the download host vouches for nothing if that host is compromised, and
|
||||
// without any hash there was no check at all.
|
||||
if (!VerifyStagedPayload(stagingDir, log, out var catalogRejection))
|
||||
{
|
||||
CleanupStaging(stagingDir, zipPath);
|
||||
if (writeMarkers)
|
||||
WriteSkipMarker("catalog_rejected", currentVersion, latestVersion, catalogRejection, log);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 6: Swap files (rename locked → .old, copy new)
|
||||
var swapTimerSw = Stopwatch.StartNew();
|
||||
var swapOk = SwapFiles(agentDir, stagingDir, log);
|
||||
@@ -597,6 +616,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Step 5b. Fail-closed: a rejected payload is never swapped in, and the running agent --
|
||||
/// verified when it was installed -- stays. A verifier that throws counts as a rejection.
|
||||
/// </summary>
|
||||
internal static bool VerifyStagedPayload(string stagingDir, Action<string> log, out string rejectionReason)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
PayloadCatalogVerdict verdict;
|
||||
try
|
||||
{
|
||||
verdict = VerifyPayloadCatalog(stagingDir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
verdict = PayloadCatalogVerdict.Reject($"catalog check failed to run ({ex.Message})");
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
if (verdict.Accepted)
|
||||
{
|
||||
log($"Self-update: payload catalog verified ({sw.ElapsedMilliseconds}ms)");
|
||||
rejectionReason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
log($"Self-update: payload catalog REJECTED ({sw.ElapsedMilliseconds}ms) — {verdict.Reason}. Aborting update.");
|
||||
rejectionReason = verdict.Reason;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the ZIP to the staging directory. Returns false on failure.
|
||||
///
|
||||
|
||||
+9
-19
@@ -633,26 +633,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
new ManagementScope(@"\\.\root\CIMV2\Security\MicrosoftTpm"),
|
||||
new ObjectQuery("SELECT IsActivated_InitialValue, IsEnabled_InitialValue, IsOwned_InitialValue, ManufacturerIdTxt, ManufacturerVersion, SpecVersion FROM Win32_Tpm")))
|
||||
var tpm = TpmInfoReader.Read();
|
||||
if (tpm != null)
|
||||
{
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
data["isActivated"] = obj["IsActivated_InitialValue"]?.ToString() == "True";
|
||||
data["isEnabled"] = obj["IsEnabled_InitialValue"]?.ToString() == "True";
|
||||
data["isOwned"] = obj["IsOwned_InitialValue"]?.ToString() == "True";
|
||||
data["manufacturerName"] = obj["ManufacturerIdTxt"]?.ToString();
|
||||
data["manufacturerVersion"] = obj["ManufacturerVersion"]?.ToString();
|
||||
|
||||
// SpecVersion is comma-separated: "2.0, 0, 1.59" → first element is the spec version
|
||||
var specVersion = obj["SpecVersion"]?.ToString();
|
||||
if (!string.IsNullOrEmpty(specVersion))
|
||||
{
|
||||
var parts = specVersion.Split(',');
|
||||
data["specVersion"] = parts[0].Trim();
|
||||
}
|
||||
}
|
||||
data["isActivated"] = tpm.IsActivated;
|
||||
data["isEnabled"] = tpm.IsEnabled;
|
||||
data["isOwned"] = tpm.IsOwned;
|
||||
data["manufacturerName"] = tpm.ManufacturerName;
|
||||
data["manufacturerVersion"] = tpm.ManufacturerVersion;
|
||||
if (tpm.SpecVersion != null)
|
||||
data["specVersion"] = tpm.SpecVersion;
|
||||
}
|
||||
|
||||
if (data.Count > 0)
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
using System.Management;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
{
|
||||
/// <summary>The TPM as <c>Win32_Tpm</c> reports it.</summary>
|
||||
internal sealed class TpmInfo
|
||||
{
|
||||
public bool IsActivated { get; set; }
|
||||
public bool IsEnabled { get; set; }
|
||||
public bool IsOwned { get; set; }
|
||||
|
||||
/// <summary><c>ManufacturerIdTxt</c> as reported — may be space-padded (<c>"STM "</c>).</summary>
|
||||
public string ManufacturerName { get; set; }
|
||||
|
||||
/// <summary><c>ManufacturerVersion</c>: the firmware version.</summary>
|
||||
public string ManufacturerVersion { get; set; }
|
||||
|
||||
/// <summary>First element of <c>SpecVersion</c> (<c>"2.0, 0, 1.59"</c> → <c>"2.0"</c>); null when absent.</summary>
|
||||
public string SpecVersion { get; set; }
|
||||
|
||||
/// <summary>Manufacturer id and firmware version, trimmed: <c>IFX 7.63.3353.0</c>; null when both are empty.</summary>
|
||||
public string Label => FormatLabel(ManufacturerName, ManufacturerVersion);
|
||||
|
||||
internal static string FormatLabel(string manufacturerName, string manufacturerVersion)
|
||||
{
|
||||
var label = $"{manufacturerName?.Trim()} {manufacturerVersion?.Trim()}".Trim();
|
||||
return label.Length == 0 ? null : label;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads <c>Win32_Tpm</c> from <c>root\CIMV2\Security\MicrosoftTpm</c>: one WMI connect plus one
|
||||
/// query. Shared by the <c>tpm_status</c> device-info event and the TPM-PSS distress report.
|
||||
/// </summary>
|
||||
internal static class TpmInfoReader
|
||||
{
|
||||
/// <summary>The last listed TPM instance (there is one in practice); null when WMI lists none. Throws on WMI failure.</summary>
|
||||
public static TpmInfo Read()
|
||||
{
|
||||
TpmInfo info = null;
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
new ManagementScope(@"\\.\root\CIMV2\Security\MicrosoftTpm"),
|
||||
new ObjectQuery("SELECT IsActivated_InitialValue, IsEnabled_InitialValue, IsOwned_InitialValue, ManufacturerIdTxt, ManufacturerVersion, SpecVersion FROM Win32_Tpm")))
|
||||
{
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
var specVersion = obj["SpecVersion"]?.ToString();
|
||||
info = new TpmInfo
|
||||
{
|
||||
IsActivated = obj["IsActivated_InitialValue"]?.ToString() == "True",
|
||||
IsEnabled = obj["IsEnabled_InitialValue"]?.ToString() == "True",
|
||||
IsOwned = obj["IsOwned_InitialValue"]?.ToString() == "True",
|
||||
ManufacturerName = obj["ManufacturerIdTxt"]?.ToString(),
|
||||
ManufacturerVersion = obj["ManufacturerVersion"]?.ToString(),
|
||||
SpecVersion = string.IsNullOrEmpty(specVersion) ? null : specVersion.Split(',')[0].Trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return info;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,6 +291,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
sb.AppendLine($" WebException.Status = {we.Status}");
|
||||
}
|
||||
if (current is System.ComponentModel.Win32Exception win32)
|
||||
{
|
||||
sb.AppendLine($" NativeErrorCode = {TransportErrorCodes.Hex(win32.NativeErrorCode)}");
|
||||
}
|
||||
current = current.InnerException;
|
||||
depth++;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
@@ -120,16 +122,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to send a distress report. Returns immediately without awaiting
|
||||
/// (intended to be called as fire-and-forget via <c>_ = reporter.TrySendAsync(...)</c>).
|
||||
/// Attempts to send a distress report. Callers usually fire and forget
|
||||
/// (<c>_ = reporter.TrySendAsync(...)</c>); one that exits right after awaits it — the send
|
||||
/// is bounded by the 5-second HTTP timeout.
|
||||
///
|
||||
/// All anti-flood checks are applied synchronously before the async HTTP call,
|
||||
/// so repeated calls are cheap when suppressed.
|
||||
/// so repeated calls are cheap when suppressed. Every report carries the OS build and the
|
||||
/// Windows setup state at send time; <paramref name="tpmVersion"/> only the TPM-PSS one.
|
||||
/// </summary>
|
||||
public virtual async Task TrySendAsync(
|
||||
DistressErrorType errorType,
|
||||
string message,
|
||||
int? httpStatusCode = null)
|
||||
int? httpStatusCode = null,
|
||||
string tpmVersion = null)
|
||||
{
|
||||
var key = $"{errorType}:{httpStatusCode}";
|
||||
int currentReportCount;
|
||||
@@ -164,29 +169,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
var statusText = httpStatusCode.HasValue ? $" HTTP {httpStatusCode}" : string.Empty;
|
||||
_logger?.Warning($"[DistressChannel] Sending report {currentReportCount}/{MaxReportsPerSession}: {errorType}{statusText}");
|
||||
|
||||
var report = new DistressReport
|
||||
{
|
||||
TenantId = _tenantId,
|
||||
ErrorType = errorType,
|
||||
Manufacturer = _manufacturer,
|
||||
Model = _model,
|
||||
SerialNumber = _serialNumber,
|
||||
AgentVersion = _agentVersion,
|
||||
HttpStatusCode = httpStatusCode,
|
||||
Message = Truncate(message, 256),
|
||||
Timestamp = DateTime.UtcNow,
|
||||
IsCloudPc = _isCloudPc,
|
||||
CertSourceState = _capturedCertState,
|
||||
CertThumbprint = _capturedCertThumbprint,
|
||||
CertSubject = Truncate(_capturedCertSubject, 96),
|
||||
CertIssuer = Truncate(_capturedCertIssuer, 96),
|
||||
CertNotBefore = _capturedCertNotBefore,
|
||||
CertNotAfter = _capturedCertNotAfter,
|
||||
};
|
||||
var report = BuildReport(errorType, message, httpStatusCode, tpmVersion);
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(report);
|
||||
var json = SerializeWithinCap(report);
|
||||
|
||||
using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, _distressUrl)
|
||||
{
|
||||
@@ -216,6 +203,51 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
return value.Length <= maxLength ? value : value.Substring(0, maxLength);
|
||||
}
|
||||
|
||||
/// <summary>The report as sent: construction-time device and cert context plus the send-time OS build and setup state.</summary>
|
||||
internal DistressReport BuildReport(DistressErrorType errorType, string message, int? httpStatusCode, string tpmVersion)
|
||||
{
|
||||
return new DistressReport
|
||||
{
|
||||
TenantId = _tenantId,
|
||||
ErrorType = errorType,
|
||||
Manufacturer = _manufacturer,
|
||||
Model = _model,
|
||||
SerialNumber = _serialNumber,
|
||||
AgentVersion = _agentVersion,
|
||||
HttpStatusCode = httpStatusCode,
|
||||
Message = Truncate(message, 256),
|
||||
Timestamp = DateTime.UtcNow,
|
||||
IsCloudPc = _isCloudPc,
|
||||
CertSourceState = _capturedCertState,
|
||||
CertThumbprint = _capturedCertThumbprint,
|
||||
CertSubject = Truncate(_capturedCertSubject, 96),
|
||||
CertIssuer = Truncate(_capturedCertIssuer, 96),
|
||||
CertNotBefore = _capturedCertNotBefore,
|
||||
CertNotAfter = _capturedCertNotAfter,
|
||||
OsBuild = NullIfEmpty(DeviceInfoProvider.GetOsBuild()),
|
||||
OobeState = OobeStateReader.Read(),
|
||||
TpmVersion = Truncate(tpmVersion, 48),
|
||||
};
|
||||
}
|
||||
|
||||
private static string NullIfEmpty(string value) => string.IsNullOrEmpty(value) ? null : value;
|
||||
|
||||
/// <summary>
|
||||
/// The report as JSON within <see cref="Constants.DistressReportMaxBytes"/>: the backend drops
|
||||
/// a larger body without a word, so an oversized report gives up message characters
|
||||
/// (each removed character removes at least one byte).
|
||||
/// </summary>
|
||||
internal static string SerializeWithinCap(DistressReport report)
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(report);
|
||||
var overflow = Encoding.UTF8.GetByteCount(json) - Constants.DistressReportMaxBytes;
|
||||
if (overflow <= 0 || string.IsNullOrEmpty(report.Message))
|
||||
return json;
|
||||
|
||||
report.Message = report.Message.Substring(0, Math.Max(0, report.Message.Length - overflow));
|
||||
return JsonConvert.SerializeObject(report);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient?.Dispose();
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// The native status behind a transport failure. Schannel and WinHTTP report it only on the
|
||||
/// innermost <see cref="Win32Exception"/> of the chain (e.g. <c>0x80090326</c>); the outer
|
||||
/// <c>HttpRequestException</c> and <c>WebException</c> texts are generic.
|
||||
/// </summary>
|
||||
internal static class TransportErrorCodes
|
||||
{
|
||||
/// <summary>The innermost <see cref="Win32Exception.NativeErrorCode"/> as <c>0x80090326</c>; null when the chain has none.</summary>
|
||||
public static string InnermostNativeCode(Exception ex)
|
||||
{
|
||||
string code = null;
|
||||
for (var current = ex; current != null; current = current.InnerException)
|
||||
{
|
||||
if (current is Win32Exception win32)
|
||||
code = Hex(win32.NativeErrorCode);
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
/// <summary><paramref name="ex"/>'s message with the innermost native code appended as <c>(win32 0x80090326)</c>.</summary>
|
||||
public static string MessageWithNativeCode(Exception ex)
|
||||
{
|
||||
if (ex == null) return null;
|
||||
var code = InnermostNativeCode(ex);
|
||||
return code == null ? ex.Message : $"{ex.Message} (win32 {code})";
|
||||
}
|
||||
|
||||
/// <summary>Eight-digit hex with <c>0x</c>: <c>-2146893018</c> → <c>0x80090326</c>.</summary>
|
||||
public static string Hex(int value) => "0x" + value.ToString("X8", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Keeps <c>%ProgramData%\AutopilotMonitor</c> out of reach of standard users. Left alone the
|
||||
/// folder inherits ProgramData's "Users may create files and folders" entry, and the SYSTEM
|
||||
/// agent and its boot task start from the Agent subfolder: a user who can add a file there
|
||||
/// can plant code the next SYSTEM start loads. During the device phase of OOBE only SYSTEM is
|
||||
/// around, but the agent keeps running through the user phase and on the desktop until the
|
||||
/// enrollment ends, restarted by the boot task after every reboot; that is the window this
|
||||
/// closes. The payload catalog does not cover it: it is checked at install time, a file
|
||||
/// added later is never looked at.
|
||||
/// <para>
|
||||
/// The DACL is protected (nothing inherited from ProgramData): SYSTEM and Administrators full
|
||||
/// control, Users read and execute, all inherited by the subfolders. Explicit entries on a
|
||||
/// child stay. The summary dialog's temp folder lives outside this tree anyway.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class AgentDirectoryAcl
|
||||
{
|
||||
private const InheritanceFlags Inherit = InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit;
|
||||
|
||||
private static readonly SecurityIdentifier LocalSystem = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
|
||||
private static readonly SecurityIdentifier Administrators = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
|
||||
private static readonly SecurityIdentifier Users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null);
|
||||
|
||||
/// <summary>
|
||||
/// Applies the protected DACL to <paramref name="path"/> unless it is already in place.
|
||||
/// Never throws: a folder the agent cannot protect must not stop the agent.
|
||||
/// </summary>
|
||||
public static AgentDirectoryAclResult EnsureProtected(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var directory = new DirectoryInfo(path);
|
||||
if (!directory.Exists)
|
||||
return new AgentDirectoryAclResult(AgentDirectoryAclOutcome.Missing, null);
|
||||
|
||||
if (IsProtectedAsExpected(directory.GetAccessControl(AccessControlSections.Access)))
|
||||
return new AgentDirectoryAclResult(AgentDirectoryAclOutcome.AlreadyProtected, null);
|
||||
|
||||
directory.SetAccessControl(BuildProtectedSecurity());
|
||||
return new AgentDirectoryAclResult(AgentDirectoryAclOutcome.Applied, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AgentDirectoryAclResult(AgentDirectoryAclOutcome.Failed, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
internal static DirectorySecurity BuildProtectedSecurity()
|
||||
{
|
||||
var security = new DirectorySecurity();
|
||||
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
||||
security.AddAccessRule(new FileSystemAccessRule(LocalSystem, FileSystemRights.FullControl, Inherit, PropagationFlags.None, AccessControlType.Allow));
|
||||
security.AddAccessRule(new FileSystemAccessRule(Administrators, FileSystemRights.FullControl, Inherit, PropagationFlags.None, AccessControlType.Allow));
|
||||
security.AddAccessRule(new FileSystemAccessRule(Users, FileSystemRights.ReadAndExecute, Inherit, PropagationFlags.None, AccessControlType.Allow));
|
||||
return security;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the DACL is protected and holds exactly the three entries above. Windows adds
|
||||
/// <see cref="FileSystemRights.Synchronize"/> when it stores an entry, so that bit is ignored.
|
||||
/// </summary>
|
||||
internal static bool IsProtectedAsExpected(DirectorySecurity security)
|
||||
{
|
||||
if (!security.AreAccessRulesProtected)
|
||||
return false;
|
||||
|
||||
var rules = security.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier))
|
||||
.Cast<FileSystemAccessRule>()
|
||||
.ToList();
|
||||
|
||||
return rules.Count == 3
|
||||
&& Has(rules, LocalSystem, FileSystemRights.FullControl)
|
||||
&& Has(rules, Administrators, FileSystemRights.FullControl)
|
||||
&& Has(rules, Users, FileSystemRights.ReadAndExecute);
|
||||
}
|
||||
|
||||
private static bool Has(IEnumerable<FileSystemAccessRule> rules, SecurityIdentifier sid, FileSystemRights rights) =>
|
||||
rules.Any(r => r.AccessControlType == AccessControlType.Allow
|
||||
&& !r.IsInherited
|
||||
&& sid.Equals(r.IdentityReference)
|
||||
&& (r.FileSystemRights & ~FileSystemRights.Synchronize) == (rights & ~FileSystemRights.Synchronize)
|
||||
&& r.InheritanceFlags == Inherit
|
||||
&& r.PropagationFlags == PropagationFlags.None);
|
||||
}
|
||||
|
||||
public enum AgentDirectoryAclOutcome
|
||||
{
|
||||
Applied,
|
||||
AlreadyProtected,
|
||||
Missing,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// <summary>Outcome of <see cref="AgentDirectoryAcl.EnsureProtected"/>; <see cref="Error"/> only on failure.</summary>
|
||||
public sealed class AgentDirectoryAclResult
|
||||
{
|
||||
public AgentDirectoryAclResult(AgentDirectoryAclOutcome outcome, string error)
|
||||
{
|
||||
Outcome = outcome;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public AgentDirectoryAclOutcome Outcome { get; }
|
||||
public string Error { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Management.Automation;
|
||||
using System.Management.Automation.Runspaces;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies an extracted agent payload against its signed catalog
|
||||
/// (<see cref="Constants.AgentPayloadCatalogFileName"/>) with Windows PowerShell's own
|
||||
/// <c>Test-FileCatalog</c>, the cmdlet the bootstrap installer and the OOBE script run as well,
|
||||
/// so all three verifiers share one semantics: the catalog must carry a valid signature from
|
||||
/// <see cref="Constants.CodeSigningPublisherPattern"/>, and every file of the payload must match
|
||||
/// it, with nothing missing, changed or extra.
|
||||
/// <para>
|
||||
/// A reimplementation of the catalog format (WinVerifyTrust plus the CryptCAT APIs) was the
|
||||
/// alternative and was rejected: a second parser for the same contract would drift from the
|
||||
/// cmdlet the other two verifiers use. The runspace costs about a second, paid only on the
|
||||
/// rare update path.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class PayloadCatalogVerifier
|
||||
{
|
||||
private const int InvokeTimeoutMs = 60000;
|
||||
private const int MaxNamesPerList = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Checks <paramref name="payloadDir"/>. Never throws: any failure to run the check is a
|
||||
/// rejection, because an unverifiable payload must not be installed.
|
||||
/// </summary>
|
||||
public static PayloadCatalogVerdict Verify(string payloadDir)
|
||||
{
|
||||
var catalogPath = Path.Combine(payloadDir, Constants.AgentPayloadCatalogFileName);
|
||||
if (!File.Exists(catalogPath))
|
||||
return PayloadCatalogVerdict.Reject($"catalog {Constants.AgentPayloadCatalogFileName} missing");
|
||||
|
||||
return RunCatalogCheck(payloadDir, catalogPath);
|
||||
}
|
||||
|
||||
// Kept out of Verify (and never inlined) so the PowerShell engine is loaded only when a
|
||||
// catalog is actually checked, not when the method that guards it is compiled.
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static PayloadCatalogVerdict RunCatalogCheck(string payloadDir, string catalogPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var runspace = RunspaceFactory.CreateRunspace())
|
||||
{
|
||||
runspace.Open();
|
||||
using (var ps = PowerShell.Create())
|
||||
{
|
||||
ps.Runspace = runspace;
|
||||
ps.AddCommand(@"Microsoft.PowerShell.Security\Test-FileCatalog")
|
||||
.AddParameter("Path", payloadDir)
|
||||
.AddParameter("CatalogFilePath", catalogPath)
|
||||
.AddParameter("FilesToSkip", new[] { Constants.AgentPayloadCatalogFileName })
|
||||
.AddParameter("Detailed");
|
||||
|
||||
var asyncResult = ps.BeginInvoke();
|
||||
if (!asyncResult.AsyncWaitHandle.WaitOne(InvokeTimeoutMs))
|
||||
{
|
||||
ps.Stop();
|
||||
return PayloadCatalogVerdict.Reject($"catalog check timed out after {InvokeTimeoutMs / 1000}s");
|
||||
}
|
||||
|
||||
var results = ps.EndInvoke(asyncResult);
|
||||
if (ps.HadErrors && ps.Streams.Error.Count > 0)
|
||||
return PayloadCatalogVerdict.Reject($"catalog check failed to run ({ps.Streams.Error[0]})");
|
||||
|
||||
var info = results.FirstOrDefault();
|
||||
if (info == null)
|
||||
return PayloadCatalogVerdict.Reject("catalog check returned nothing");
|
||||
|
||||
var signature = info.Properties["Signature"]?.Value as Signature;
|
||||
return Evaluate(new PayloadCatalogFacts(
|
||||
status: info.Properties["Status"]?.Value?.ToString(),
|
||||
signatureStatus: signature?.Status.ToString(),
|
||||
signerSubject: signature?.SignerCertificate?.Subject,
|
||||
catalogItems: ToStringMap(info.Properties["CatalogItems"]?.Value),
|
||||
pathItems: ToStringMap(info.Properties["PathItems"]?.Value)),
|
||||
Constants.CodeSigningPublisherPattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return PayloadCatalogVerdict.Reject($"catalog check failed to run ({ex.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The decision over the facts <c>Test-FileCatalog -Detailed</c> reports. Separate from the
|
||||
/// runspace so it is testable without PowerShell. <c>Status</c> covers the hashes only: an
|
||||
/// unsigned catalog over matching files reports <c>Valid</c>, which is why the signature is
|
||||
/// judged on its own.
|
||||
/// </summary>
|
||||
internal static PayloadCatalogVerdict Evaluate(PayloadCatalogFacts facts, string publisherPattern)
|
||||
{
|
||||
var reasons = new List<string>();
|
||||
|
||||
if (!string.Equals(facts.SignatureStatus, "Valid", StringComparison.Ordinal))
|
||||
{
|
||||
reasons.Add($"catalog signature status {facts.SignatureStatus ?? "unknown"}");
|
||||
}
|
||||
else if (!MatchesPublisherPattern(facts.SignerSubject, publisherPattern))
|
||||
{
|
||||
reasons.Add($"catalog publisher '{facts.SignerSubject}' does not match {publisherPattern}");
|
||||
}
|
||||
|
||||
if (!string.Equals(facts.Status, "Valid", StringComparison.Ordinal))
|
||||
reasons.Add(DescribeDifference(facts.CatalogItems, facts.PathItems));
|
||||
|
||||
return reasons.Count == 0
|
||||
? PayloadCatalogVerdict.Accept()
|
||||
: PayloadCatalogVerdict.Reject(string.Join("; ", reasons));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PowerShell's case-insensitive <c>-like</c> for the two wildcards the pin uses (<c>*</c>,
|
||||
/// <c>?</c>), without loading the PowerShell engine. Bracket classes are not supported;
|
||||
/// <c>CodeSigningParityTests</c> pins the pattern's shape.
|
||||
/// </summary>
|
||||
internal static bool MatchesPublisherPattern(string subject, string pattern)
|
||||
{
|
||||
if (string.IsNullOrEmpty(subject) || string.IsNullOrEmpty(pattern)) return false;
|
||||
var regex = "^" + Regex.Escape(pattern).Replace(@"\*", ".*").Replace(@"\?", ".") + "$";
|
||||
return Regex.IsMatch(subject, regex, RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.CultureInvariant);
|
||||
}
|
||||
|
||||
private static string DescribeDifference(
|
||||
IReadOnlyDictionary<string, string> expected, IReadOnlyDictionary<string, string> actual)
|
||||
{
|
||||
var missing = expected.Keys.Where(k => !actual.ContainsKey(k));
|
||||
var extra = actual.Keys.Where(k => !expected.ContainsKey(k));
|
||||
var changed = expected.Keys.Where(k => actual.TryGetValue(k, out var hash)
|
||||
&& !string.Equals(hash, expected[k], StringComparison.OrdinalIgnoreCase));
|
||||
return $"payload differs from catalog (missing: {Cap(missing)}; extra: {Cap(extra)}; changed: {Cap(changed)})";
|
||||
}
|
||||
|
||||
private static string Cap(IEnumerable<string> names)
|
||||
{
|
||||
var list = names.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
if (list.Count == 0) return "none";
|
||||
var shown = string.Join(", ", list.Take(MaxNamesPerList));
|
||||
return list.Count > MaxNamesPerList ? $"{shown} (+{list.Count - MaxNamesPerList} more)" : shown;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> ToStringMap(object value)
|
||||
{
|
||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (value is IDictionary dictionary)
|
||||
{
|
||||
foreach (DictionaryEntry entry in dictionary)
|
||||
map[Convert.ToString(entry.Key)] = Convert.ToString(entry.Value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What <c>Test-FileCatalog -Detailed</c> reported, reduced to the fields the decision needs.</summary>
|
||||
internal sealed class PayloadCatalogFacts
|
||||
{
|
||||
public PayloadCatalogFacts(
|
||||
string status, string signatureStatus, string signerSubject,
|
||||
IReadOnlyDictionary<string, string> catalogItems, IReadOnlyDictionary<string, string> pathItems)
|
||||
{
|
||||
Status = status;
|
||||
SignatureStatus = signatureStatus;
|
||||
SignerSubject = signerSubject;
|
||||
CatalogItems = catalogItems ?? new Dictionary<string, string>();
|
||||
PathItems = pathItems ?? new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
public string Status { get; }
|
||||
public string SignatureStatus { get; }
|
||||
public string SignerSubject { get; }
|
||||
public IReadOnlyDictionary<string, string> CatalogItems { get; }
|
||||
public IReadOnlyDictionary<string, string> PathItems { get; }
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a payload catalog check; <see cref="Reason"/> is null when accepted.</summary>
|
||||
public sealed class PayloadCatalogVerdict
|
||||
{
|
||||
private PayloadCatalogVerdict(bool accepted, string reason)
|
||||
{
|
||||
Accepted = accepted;
|
||||
Reason = reason;
|
||||
}
|
||||
|
||||
public bool Accepted { get; }
|
||||
public string Reason { get; }
|
||||
|
||||
public static PayloadCatalogVerdict Accept() => new PayloadCatalogVerdict(true, null);
|
||||
|
||||
public static PayloadCatalogVerdict Reject(string reason) => new PayloadCatalogVerdict(false, reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// One-shot read of the Schannel errors the System log recorded since a point in time:
|
||||
/// 36871 (creating the client credential failed), 36887 (fatal TLS alert received) and
|
||||
/// 36888 (fatal TLS alert sent). Only the terminal registration-failure path calls it.
|
||||
/// Another process's TLS failure in the same window shows up too, so the result is a hint.
|
||||
/// </summary>
|
||||
internal static class SchannelEventReader
|
||||
{
|
||||
internal const int MaxEvents = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Newest first, at most <see cref="MaxEvents"/> events as <see cref="FormatToken"/> tokens
|
||||
/// joined by commas (<c>36888:40/1205,36887:116</c>); null when there is none or the log
|
||||
/// cannot be read. Never throws.
|
||||
/// </summary>
|
||||
public static string ReadSince(DateTime sinceUtc, AgentLogger logger = null)
|
||||
{
|
||||
var tokens = new List<string>();
|
||||
try
|
||||
{
|
||||
var query = new EventLogQuery("System", PathType.LogName, BuildXPath(sinceUtc)) { ReverseDirection = true };
|
||||
using (var reader = new EventLogReader(query))
|
||||
{
|
||||
EventRecord record;
|
||||
while (tokens.Count < MaxEvents && (record = reader.ReadEvent()) != null)
|
||||
{
|
||||
using (record)
|
||||
{
|
||||
tokens.Add(FormatToken(record.Id, ReadProperties(record)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Debug($"SchannelEventReader: System log not readable ({ex.Message}).");
|
||||
return null;
|
||||
}
|
||||
return tokens.Count == 0 ? null : string.Join(",", tokens);
|
||||
}
|
||||
|
||||
internal static string BuildXPath(DateTime sinceUtc)
|
||||
{
|
||||
var since = sinceUtc.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
|
||||
return "*[System[Provider[@Name='Schannel'] and (EventID=36871 or EventID=36887 or EventID=36888)" +
|
||||
$" and TimeCreated[@SystemTime >= '{since}']]]";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>36888:40/1205</c>: the event id and its numeric properties (TLS alert code, Schannel
|
||||
/// error state). Text properties (target name, certificate subjects) are left out.
|
||||
/// </summary>
|
||||
internal static string FormatToken(int eventId, IEnumerable<string> properties)
|
||||
{
|
||||
var numbers = (properties ?? Enumerable.Empty<string>())
|
||||
.Where(p => !string.IsNullOrEmpty(p) && p.All(c => c >= '0' && c <= '9'))
|
||||
.Take(3)
|
||||
.ToList();
|
||||
return numbers.Count == 0
|
||||
? eventId.ToString(CultureInfo.InvariantCulture)
|
||||
: $"{eventId.ToString(CultureInfo.InvariantCulture)}:{string.Join("/", numbers)}";
|
||||
}
|
||||
|
||||
private static List<string> ReadProperties(EventRecord record)
|
||||
{
|
||||
var values = new List<string>();
|
||||
try
|
||||
{
|
||||
foreach (var property in record.Properties)
|
||||
values.Add(Convert.ToString(property?.Value, CultureInfo.InvariantCulture));
|
||||
}
|
||||
catch { /* best-effort — keep what was read */ }
|
||||
return values;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
}
|
||||
|
||||
lastError = response?.Message ?? "(null response)";
|
||||
// The newest attempt answered: an earlier attempt's exception no longer
|
||||
// describes how this run ended (onTerminalTransportFailure).
|
||||
lastException = null;
|
||||
logger.Warning($"Session registration failed: {lastError}");
|
||||
}
|
||||
catch (Exception ex) when (
|
||||
@@ -170,7 +173,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex.Message;
|
||||
// The native code (e.g. the Schannel status) is the only specific part of a
|
||||
// transport failure; it travels on with the run record.
|
||||
lastError = TransportErrorCodes.MessageWithNativeCode(ex);
|
||||
lastException = ex;
|
||||
logger.Error($"Failed to register session (attempt {attempt}/{MaxAttempts}): {ex.Message}", ex);
|
||||
|
||||
@@ -181,7 +186,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
if (emergencyReporter != null)
|
||||
_ = emergencyReporter.TrySendAsync(
|
||||
AgentErrorType.RegisterSessionFailed,
|
||||
ex.Message);
|
||||
lastError);
|
||||
}
|
||||
catch { /* emergency channel is best-effort */ }
|
||||
}
|
||||
|
||||
@@ -3,24 +3,24 @@ using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Diagnostic probe that asks the local key-storage provider to produce three RSA signatures
|
||||
/// over a fixed payload — PKCS#1 v1.5 SHA-256 (legacy, what every TPM 2.0 supports),
|
||||
/// PSS SHA-256, and PSS SHA-384 (what modern Schannel prefers for TLS 1.2/1.3 client-auth
|
||||
/// on Windows 11 25H2+).
|
||||
/// PSS SHA-256, and PSS SHA-384 (what Schannel prefers for TLS client authentication, and the
|
||||
/// only RSA signature TLS 1.3 allows).
|
||||
/// <para>
|
||||
/// The probe exists for one reason: to distinguish a generic <c>SecureChannelFailure</c>
|
||||
/// from the specific case where a TPM-backed cert is silently filtered out of TLS client-auth
|
||||
/// because its firmware can't do PSS. On 2015-era Infineon TPM 2.0 chips (Surface Book 1
|
||||
/// SLB 9665 is the canonical example) PKCS#1 succeeds but PSS reports
|
||||
/// <c>"The requested salt size for signing with RSAPSS does not match what the TPM uses"</c>
|
||||
/// or <c>"The requested operation is not supported"</c>. Schannel observes this during cert
|
||||
/// pre-filtering, drops the cert, and the handshake then completes with an empty client
|
||||
/// Certificate message — which the backend rejects, surfacing in .NET as the generic
|
||||
/// <c>WebException.Status = SecureChannelFailure</c>.
|
||||
/// from the case where a TPM-backed cert can be filtered out of TLS client-auth because its
|
||||
/// firmware can't do PSS. On older TPM 2.0 firmware (Surface Book 1's Infineon SLB 9665,
|
||||
/// Infineon 7.63 and STMicro 73.4 in 2017/18 business devices) PKCS#1 succeeds but PSS
|
||||
/// reports <c>"The requested salt size for signing with RSAPSS does not match what the TPM
|
||||
/// uses"</c> or <c>"The requested operation is not supported"</c>. Such a device can still
|
||||
/// connect at another time (failed during setup, connected after it), so the result
|
||||
/// classifies the failed start, not the device.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Hot-path discipline:</b> this probe is intentionally NOT called at agent startup.
|
||||
@@ -71,20 +71,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
providerName: providerName,
|
||||
keySizeBits: keySizeBits,
|
||||
pssSha256Error: pssSha256.ErrorMessage,
|
||||
pssSha384Error: pssSha384.ErrorMessage);
|
||||
pssSha384Error: pssSha384.ErrorMessage,
|
||||
pssSha256ErrorCode: pssSha256.ErrorCode,
|
||||
pssSha384ErrorCode: pssSha384.ErrorCode);
|
||||
}
|
||||
|
||||
private static (bool Success, string ErrorMessage) TrySignData(
|
||||
private static (bool Success, string ErrorMessage, int? ErrorCode) TrySignData(
|
||||
RSA rsa, byte[] data, HashAlgorithmName hash, RSASignaturePadding padding)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sig = rsa.SignData(data, hash, padding);
|
||||
return (sig != null && sig.Length > 0, null);
|
||||
return (sig != null && sig.Length > 0, null, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
// CryptographicException carries the NCrypt status as HResult
|
||||
// (0x80290423 = the TPM's PSS salt size does not match, 0x80090029 = not supported).
|
||||
return (false, ex.Message, ex.HResult);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +126,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// </summary>
|
||||
public sealed class TpmPssProbeResult
|
||||
{
|
||||
/// <summary>Longest <see cref="ToDistressMessage"/> — the distress message cap.</summary>
|
||||
internal const int MaxDistressMessageLength = 256;
|
||||
|
||||
public bool Pkcs1Sha256Works { get; }
|
||||
public bool PssSha256Works { get; }
|
||||
public bool PssSha384Works { get; }
|
||||
@@ -129,12 +136,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
public int KeySizeBits { get; }
|
||||
public string PssSha256Error { get; }
|
||||
public string PssSha384Error { get; }
|
||||
public int? PssSha256ErrorCode { get; }
|
||||
public int? PssSha384ErrorCode { get; }
|
||||
public bool IsProbable { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True iff PKCS#1 succeeded but PSS did not — the canonical "TPM firmware is too old
|
||||
/// for modern Schannel" footprint. Distinguishes the TPM-PSS regression from generic
|
||||
/// crypto failures (which would also fail PKCS#1).
|
||||
/// for PSS" footprint. Distinguishes the TPM-PSS case from generic crypto failures
|
||||
/// (which would also fail PKCS#1).
|
||||
/// </summary>
|
||||
public bool IsTpmPssBroken => IsProbable && Pkcs1Sha256Works && !PssSha256Works;
|
||||
|
||||
@@ -154,7 +163,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
string providerName,
|
||||
int keySizeBits,
|
||||
string pssSha256Error,
|
||||
string pssSha384Error)
|
||||
string pssSha384Error,
|
||||
int? pssSha256ErrorCode = null,
|
||||
int? pssSha384ErrorCode = null)
|
||||
{
|
||||
Pkcs1Sha256Works = pkcs1Sha256Works;
|
||||
PssSha256Works = pssSha256Works;
|
||||
@@ -163,6 +174,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
KeySizeBits = keySizeBits;
|
||||
PssSha256Error = pssSha256Error;
|
||||
PssSha384Error = pssSha384Error;
|
||||
PssSha256ErrorCode = pssSha256ErrorCode;
|
||||
PssSha384ErrorCode = pssSha384ErrorCode;
|
||||
IsProbable = true;
|
||||
}
|
||||
|
||||
@@ -176,23 +189,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
public static TpmPssProbeResult Unprobable(string reason) => new TpmPssProbeResult(reason);
|
||||
|
||||
/// <summary>
|
||||
/// Compact, distress-message-friendly summary (under 256 chars). Caller passes this to
|
||||
/// <c>DistressReporter.TrySendAsync</c>.
|
||||
/// Language-neutral summary for the distress message, codes instead of the localized
|
||||
/// error texts: <c>pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029)
|
||||
/// tls=0x80090326 sch=36888:40/1205 prov=PCP key=2048</c>. <paramref name="tlsCode"/>
|
||||
/// is the native code of the failed handshake (<see cref="TransportErrorCodes"/>),
|
||||
/// <paramref name="schannelEvents"/> the System-log tokens (<see cref="SchannelEventReader"/>);
|
||||
/// both are left out when null. At most <see cref="MaxDistressMessageLength"/> chars.
|
||||
/// </summary>
|
||||
public string ToDistressMessage()
|
||||
public string ToDistressMessage(string tlsCode = null, string schannelEvents = null)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("provider=").Append(ProviderName);
|
||||
if (KeySizeBits > 0) sb.Append(" keySize=").Append(KeySizeBits);
|
||||
sb.Append(" pkcs1=").Append(Pkcs1Sha256Works ? "ok" : "fail");
|
||||
sb.Append(" pssSha256=").Append(PssSha256Works ? "ok" : "fail");
|
||||
sb.Append(" pssSha384=").Append(PssSha384Works ? "ok" : "fail");
|
||||
if (!PssSha256Works && !string.IsNullOrEmpty(PssSha256Error))
|
||||
{
|
||||
sb.Append(" err256=").Append(PssSha256Error);
|
||||
}
|
||||
const int maxLen = 240;
|
||||
return sb.Length <= maxLen ? sb.ToString() : sb.ToString(0, maxLen);
|
||||
sb.Append("pkcs1=").Append(Pkcs1Sha256Works ? "ok" : "fail");
|
||||
AppendSignOutcome(sb, " pss256=", PssSha256Works, PssSha256ErrorCode);
|
||||
AppendSignOutcome(sb, " pss384=", PssSha384Works, PssSha384ErrorCode);
|
||||
if (!string.IsNullOrEmpty(tlsCode)) sb.Append(" tls=").Append(tlsCode);
|
||||
if (!string.IsNullOrEmpty(schannelEvents)) sb.Append(" sch=").Append(schannelEvents);
|
||||
sb.Append(" prov=").Append(ShortProviderName(ProviderName));
|
||||
if (KeySizeBits > 0) sb.Append(" key=").Append(KeySizeBits);
|
||||
return sb.Length <= MaxDistressMessageLength ? sb.ToString() : sb.ToString(0, MaxDistressMessageLength);
|
||||
}
|
||||
|
||||
private static void AppendSignOutcome(StringBuilder sb, string key, bool works, int? errorCode)
|
||||
{
|
||||
sb.Append(key);
|
||||
if (works) sb.Append("ok");
|
||||
else if (errorCode.HasValue) sb.Append("fail(").Append(TransportErrorCodes.Hex(errorCode.Value)).Append(')');
|
||||
else sb.Append("fail");
|
||||
}
|
||||
|
||||
internal static string ShortProviderName(string providerName)
|
||||
{
|
||||
if (string.Equals(providerName, "Microsoft Platform Crypto Provider", StringComparison.OrdinalIgnoreCase)) return "PCP";
|
||||
if (string.Equals(providerName, "Microsoft Software Key Storage Provider", StringComparison.OrdinalIgnoreCase)) return "KSP";
|
||||
if (string.IsNullOrEmpty(providerName)) return "(unknown)";
|
||||
return providerName.Length <= 32 ? providerName : providerName.Substring(0, 32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,13 @@
|
||||
<InformationalVersion>$(Version)</InformationalVersion>
|
||||
|
||||
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
||||
|
||||
<!--
|
||||
Of the files that travel next to referenced assemblies, only PDBs ship: they put file and
|
||||
line into the stack traces the agent log records. XML doc files (AutopilotMonitor.Shared.xml)
|
||||
are IntelliSense data no device reads. This output folder is the release payload.
|
||||
-->
|
||||
<AllowedReferenceRelatedFileExtensions>.pdb</AllowedReferenceRelatedFileExtensions>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="PersistBuildCounter" BeforeTargets="BeforeBuild" Condition="'$(BUILD_NUMBER)' == ''">
|
||||
|
||||
@@ -57,6 +57,8 @@ namespace AutopilotMonitor.Agent.V2
|
||||
logger.Info("======================= Agent install mode (--install) =======================");
|
||||
|
||||
EnsureAgentDirectories(logger);
|
||||
LogAgentDirectoryAcl(AgentDirectoryAcl.EnsureProtected(
|
||||
Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory)), logger, alwaysLog: true);
|
||||
|
||||
var sourceExePath = Assembly.GetExecutingAssembly().Location;
|
||||
var sourceDir = Path.GetDirectoryName(sourceExePath) ?? string.Empty;
|
||||
@@ -273,6 +275,26 @@ namespace AutopilotMonitor.Agent.V2
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs the outcome of <see cref="AgentDirectoryAcl.EnsureProtected"/>. Install mode logs
|
||||
/// every outcome; the runtime start only a change or a failure, so a normal start stays quiet.
|
||||
/// </summary>
|
||||
internal static void LogAgentDirectoryAcl(AgentDirectoryAclResult result, AgentLogger logger, bool alwaysLog)
|
||||
{
|
||||
switch (result.Outcome)
|
||||
{
|
||||
case AgentDirectoryAclOutcome.Applied:
|
||||
logger.Info("Agent data directory protected: SYSTEM and Administrators full control, Users read and execute, nothing inherited from ProgramData.");
|
||||
break;
|
||||
case AgentDirectoryAclOutcome.Failed:
|
||||
logger.Warning($"Could not protect the agent data directory: {result.Error}");
|
||||
break;
|
||||
default:
|
||||
if (alwaysLog) logger.Info($"Agent data directory ACL: {result.Outcome}.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureAgentDirectories(AgentLogger logger)
|
||||
{
|
||||
var basePath = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
|
||||
|
||||
@@ -168,6 +168,11 @@ namespace AutopilotMonitor.Agent.V2
|
||||
var formattedArgs = FormatArgsForLog(args);
|
||||
logger.Info($"Command line: {(string.IsNullOrEmpty(formattedArgs) ? "(no args)" : formattedArgs)}");
|
||||
|
||||
// Same protection install mode applies, re-checked on every start: it also reaches
|
||||
// agents that arrived by self-update over an install made before the hardening.
|
||||
LogAgentDirectoryAcl(AgentDirectoryAcl.EnsureProtected(
|
||||
Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory)), logger, alwaysLog: false);
|
||||
|
||||
try
|
||||
{
|
||||
return RunAgent(args, logger, dataDirectory, logDirectory, consoleMode);
|
||||
|
||||
@@ -7,6 +7,8 @@ using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
@@ -66,7 +68,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
logger: logger,
|
||||
authFailureTracker: auth.AuthFailureTracker,
|
||||
emergencyReporter: auth.EmergencyReporter,
|
||||
onTerminalTransportFailure: ex => DiagnoseTerminalTransportFailure(ex, auth, logger),
|
||||
onTerminalTransportFailure: ex => DiagnoseTerminalTransportFailure(ex, auth, startedUtc, logger),
|
||||
// Reuse the single hardened hardware read from the auth bundle so the session row
|
||||
// matches the security headers the backend validated against (no second WMI query).
|
||||
deviceHardware: (auth.Manufacturer, auth.Model, auth.SerialNumber),
|
||||
@@ -280,6 +282,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>How long the TPM and System-log reads for the TPM-PSS report may take together.</summary>
|
||||
internal static readonly TimeSpan DiagnosticContextBound = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Off-hot-path diagnostic invoked once after all session-registration retries fail.
|
||||
/// Detects whether the terminal exception was a TLS-layer <c>SecureChannelFailure</c>
|
||||
@@ -287,7 +292,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// failure" from "TPM firmware can't do RSA-PSS so Schannel filters the cert out".
|
||||
/// On confirmation, emits a <see cref="DistressErrorType.TpmPssUnsupported"/> distress
|
||||
/// signal so tenant admins see this device's specific blocker rather than a generic
|
||||
/// network failure.
|
||||
/// network failure (<see cref="ReportProbeResultAsync"/>).
|
||||
/// <para>
|
||||
/// Never throws — diagnostic-only side effects.
|
||||
/// </para>
|
||||
@@ -295,6 +300,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
private static Task DiagnoseTerminalTransportFailure(
|
||||
Exception ex,
|
||||
BackendAuthBundle auth,
|
||||
DateTime registrationStartedUtc,
|
||||
AgentLogger logger)
|
||||
{
|
||||
try
|
||||
@@ -314,35 +320,106 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
var cert = new DefaultCertificateResolver().FindClientCertificate(logger);
|
||||
var probe = TpmPssCapabilityProbe.Probe(cert, logger);
|
||||
|
||||
logger.Info(
|
||||
$"TpmPssCapabilityProbe: provider={probe.ProviderName} keySize={probe.KeySizeBits} " +
|
||||
$"pkcs1Sha256={probe.Pkcs1Sha256Works} pssSha256={probe.PssSha256Works} pssSha384={probe.PssSha384Works}");
|
||||
|
||||
if (probe.IsTpmPssBroken && probe.IsTpmBacked)
|
||||
{
|
||||
logger.Error(
|
||||
"TpmPssCapabilityProbe: device's TPM-backed key cannot perform RSA-PSS — " +
|
||||
"modern Schannel filters the cert out of TLS client-auth, blocking mTLS to the backend. " +
|
||||
"Likely fix: update TPM firmware (common on 2015-era Infineon TPM 2.0).");
|
||||
|
||||
_ = auth.DistressReporter.TrySendAsync(
|
||||
DistressErrorType.TpmPssUnsupported,
|
||||
probe.ToDistressMessage());
|
||||
}
|
||||
else if (probe.IsTpmPssBroken)
|
||||
{
|
||||
// PSS broken but key isn't TPM-backed — different bug, don't mis-classify.
|
||||
logger.Warning(
|
||||
$"TpmPssCapabilityProbe: PSS signing failed on a non-TPM provider ({probe.ProviderName}). " +
|
||||
"Not emitting TpmPssUnsupported distress. Investigate as a separate crypto issue.");
|
||||
}
|
||||
return ReportProbeResultAsync(
|
||||
probe, ex, auth.DistressReporter, logger,
|
||||
() => ReadDiagnosticContextAsync(
|
||||
() => TpmInfoReader.Read()?.Label,
|
||||
() => SchannelEventReader.ReadSince(registrationStartedUtc, logger),
|
||||
Task.Delay(DiagnosticContextBound), logger));
|
||||
}
|
||||
catch (Exception probeEx)
|
||||
{
|
||||
logger?.Warning($"DiagnoseTerminalTransportFailure: unexpected error: {probeEx.Message}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
/// <summary>
|
||||
/// Sends <see cref="DistressErrorType.TpmPssUnsupported"/> when the probe shows a TPM-backed
|
||||
/// key that signs PKCS#1 but not PSS, with the native code of the failed handshake, the
|
||||
/// Schannel events and the TPM firmware from <paramref name="readContext"/>. The returned
|
||||
/// task completes only after the send: the run exits right after this callback, and
|
||||
/// nothing else waits for it. Never throws.
|
||||
/// </summary>
|
||||
internal static async Task ReportProbeResultAsync(
|
||||
TpmPssProbeResult probe,
|
||||
Exception transportFailure,
|
||||
DistressReporter reporter,
|
||||
AgentLogger logger,
|
||||
Func<Task<(string TpmVersion, string SchannelEvents)>> readContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tlsCode = TransportErrorCodes.InnermostNativeCode(transportFailure);
|
||||
logger?.Info(
|
||||
$"TpmPssCapabilityProbe: provider={probe.ProviderName} keySize={probe.KeySizeBits} " +
|
||||
$"pkcs1Sha256={probe.Pkcs1Sha256Works} pssSha256={probe.PssSha256Works} pssSha384={probe.PssSha384Works} " +
|
||||
$"tls={tlsCode ?? "n/a"} pssSha256Error={probe.PssSha256Error ?? "n/a"}");
|
||||
|
||||
if (probe.IsTpmPssBroken && probe.IsTpmBacked)
|
||||
{
|
||||
logger?.Error(
|
||||
"TpmPssCapabilityProbe: device's TPM-backed key cannot perform RSA-PSS — " +
|
||||
"Schannel can withhold the cert from TLS client-auth, blocking mTLS to the backend. " +
|
||||
"Likely fix: update TPM firmware (common on older TPM 2.0 firmware).");
|
||||
|
||||
string tpmVersion = null, schannelEvents = null;
|
||||
try
|
||||
{
|
||||
(tpmVersion, schannelEvents) = await readContext().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The context only enriches the report; it never holds it back.
|
||||
logger?.Debug($"TPM-PSS report: context read failed ({ex.Message}) — sending without it.");
|
||||
}
|
||||
await reporter.TrySendAsync(
|
||||
DistressErrorType.TpmPssUnsupported,
|
||||
probe.ToDistressMessage(tlsCode, schannelEvents),
|
||||
tpmVersion: tpmVersion).ConfigureAwait(false);
|
||||
}
|
||||
else if (probe.IsTpmPssBroken)
|
||||
{
|
||||
// PSS broken but key isn't TPM-backed — different bug, don't mis-classify.
|
||||
logger?.Warning(
|
||||
$"TpmPssCapabilityProbe: PSS signing failed on a non-TPM provider ({probe.ProviderName}). " +
|
||||
"Not emitting TpmPssUnsupported distress. Investigate as a separate crypto issue.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Warning($"TPM-PSS report failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs both reads in parallel until both finished or <paramref name="bound"/> completed
|
||||
/// (production: <see cref="DiagnosticContextBound"/>); a read that threw or had not
|
||||
/// finished by then counts as missing (null). Never throws.
|
||||
/// </summary>
|
||||
internal static async Task<(string TpmVersion, string SchannelEvents)> ReadDiagnosticContextAsync(
|
||||
Func<string> readTpmVersion,
|
||||
Func<string> readSchannelEvents,
|
||||
Task bound,
|
||||
AgentLogger logger)
|
||||
{
|
||||
var tpm = Task.Run(readTpmVersion);
|
||||
var schannel = Task.Run(readSchannelEvents);
|
||||
await Task.WhenAny(Task.WhenAll(tpm, schannel), bound).ConfigureAwait(false);
|
||||
return (ResultOrNull(tpm, "TPM", logger), ResultOrNull(schannel, "Schannel event", logger));
|
||||
}
|
||||
|
||||
private static string ResultOrNull(Task<string> read, string what, AgentLogger logger)
|
||||
{
|
||||
if (read.Status == TaskStatus.RanToCompletion)
|
||||
return read.Result;
|
||||
|
||||
// Observe a later fault so it never surfaces as an unobserved task exception.
|
||||
read.ContinueWith(t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted);
|
||||
logger?.Debug(read.IsFaulted
|
||||
? $"TPM-PSS report: {what} read failed ({read.Exception?.GetBaseException().Message})."
|
||||
: $"TPM-PSS report: {what} read still running — sending without it.");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -92,6 +92,13 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
if (recovered)
|
||||
data["remoteConfigRecovered"] = true;
|
||||
|
||||
// Which bootstrap installed this agent (installer and loader versions, MSI or
|
||||
// Intune loader, OOBE go-script), read from the scripts' own logs: makes the
|
||||
// fleet's share of old or directly assigned installers countable.
|
||||
BootstrapProvenanceReader
|
||||
.Read(Environment.ExpandEnvironmentVariables(SharedConstants.LogDirectory))
|
||||
.AddTo(data);
|
||||
|
||||
if (!string.IsNullOrEmpty(previousExit?.CrashExceptionType))
|
||||
data["previousCrashException"] = previousExit.CrashExceptionType;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -31,6 +32,8 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
|
||||
private readonly Mock<TenantAdminsService> _tenantAdminsMock;
|
||||
private readonly Mock<PreviewWhitelistService> _previewMock;
|
||||
private readonly Mock<McpUserService> _mcpUserMock;
|
||||
private readonly Mock<TelegramNotificationService> _telegramMock;
|
||||
private readonly Mock<OpsEventService> _opsEventsMock;
|
||||
private readonly Mock<GlobalNotificationService> _globalNotificationMock;
|
||||
@@ -39,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()
|
||||
@@ -54,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,
|
||||
@@ -71,7 +77,7 @@ public class AuthFunctionSideEffectTests
|
||||
adminRepo, cache, Mock.Of<ILogger<TenantAdminsService>>())
|
||||
{ CallBase = false };
|
||||
|
||||
var previewMock = new Mock<PreviewWhitelistService>(
|
||||
_previewMock = new Mock<PreviewWhitelistService>(
|
||||
configRepo, cache, Mock.Of<ILogger<PreviewWhitelistService>>(), _tenantConfigMock.Object)
|
||||
{ CallBase = false };
|
||||
|
||||
@@ -87,7 +93,7 @@ public class AuthFunctionSideEffectTests
|
||||
configRepo, Mock.Of<ILogger<AdminConfigurationService>>(), cache)
|
||||
{ CallBase = false };
|
||||
|
||||
var mcpUserMock = new Mock<McpUserService>(
|
||||
_mcpUserMock = new Mock<McpUserService>(
|
||||
adminRepo, new StubAdminIdentityBindingService(bound: true), cache, Mock.Of<ILogger<McpUserService>>(),
|
||||
globalAdminMock.Object, delegatedAdminMock.Object, adminConfigService.Object,
|
||||
new TenantMemberRoleResolver(_tenantAdminsMock.Object, _tenantConfigMock.Object), _tenantConfigMock.Object)
|
||||
@@ -100,17 +106,17 @@ 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,
|
||||
_tenantConfigMock.Object,
|
||||
_tenantAdminsMock.Object,
|
||||
_metricsRepoMock.Object,
|
||||
previewMock.Object,
|
||||
_previewMock.Object,
|
||||
_telegramMock.Object,
|
||||
_globalNotificationMock.Object,
|
||||
mcpUserMock.Object,
|
||||
_mcpUserMock.Object,
|
||||
_autoApproveEnqueuerMock.Object,
|
||||
new AutopilotMonitor.Functions.Security.EntraAppRegistry(
|
||||
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
|
||||
@@ -120,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.
|
||||
@@ -169,6 +177,13 @@ public class AuthFunctionSideEffectTests
|
||||
}
|
||||
|
||||
private static (HttpRequestData Req, FunctionContext Ctx) BuildAuthenticatedRequest(string callerUpn, string? tenantId = null)
|
||||
{
|
||||
var claims = new List<Claim> { new("preferred_username", callerUpn) };
|
||||
if (tenantId != null) claims.Add(new Claim("tid", tenantId));
|
||||
return BuildRequest(new ClaimsPrincipal(new ClaimsIdentity(claims, "test")));
|
||||
}
|
||||
|
||||
private static (HttpRequestData Req, FunctionContext Ctx) BuildRequest(ClaimsPrincipal principal)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
@@ -177,10 +192,6 @@ public class AuthFunctionSideEffectTests
|
||||
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var claims = new List<Claim> { new("preferred_username", callerUpn) };
|
||||
if (tenantId != null) claims.Add(new Claim("tid", tenantId));
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
||||
|
||||
var context = new Mock<FunctionContext>();
|
||||
context.SetupGet(c => c.Items).Returns(
|
||||
new Dictionary<object, object> { ["ClaimsPrincipal"] = principal });
|
||||
@@ -846,4 +857,151 @@ public class AuthFunctionSideEffectTests
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.HandlePostDecisionSideEffectsAsync(decision, TenantId, Upn, DisplayName, ObjectId));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GetCurrentUser end to end: an external identity never claims the tenant
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// 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, 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),
|
||||
new Claim(ClaimTypes.Upn, Upn),
|
||||
new Claim("http://schemas.microsoft.com/identity/claims/identityprovider", external
|
||||
? "https://sts.windows.net/bbbbbbbb-1111-2222-3333-444444444444/"
|
||||
: $"https://sts.windows.net/{TenantId}/"),
|
||||
}.Concat(appRoles.Select(role => new Claim("roles", role))), "test"));
|
||||
|
||||
private void ArrangeAuthMe(TenantConfiguration config, bool exists, bool approved)
|
||||
{
|
||||
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(TenantId)).ReturnsAsync((config, exists));
|
||||
_previewMock.Setup(x => x.IsApprovedAsync(TenantId)).ReturnsAsync(approved);
|
||||
_tenantAdminsMock.Setup(x => x.GetTenantAdminsAsync(TenantId)).ReturnsAsync(new List<TenantAdminRow>());
|
||||
_mcpUserMock
|
||||
.Setup(x => x.IsAllowedAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
|
||||
It.IsAny<IReadOnlyList<string>?>(), It.IsAny<bool>()))
|
||||
.ReturnsAsync(McpAccessCheckResult.Denied("not allowed"));
|
||||
_metricsRepoMock.Setup(x => x.GetUserWhatsNewSeenAsync(TenantId, Upn)).ReturnsAsync(new UserWhatsNewSeen());
|
||||
}
|
||||
|
||||
private static async Task<string> ReadBodyAsync(HttpResponseData response)
|
||||
{
|
||||
response.Body.Position = 0;
|
||||
return await new StreamReader(response.Body).ReadToEndAsync();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task GetCurrentUser_FirstSignInOfANewTenant_OnlyAMemberOnboardsIt(bool external)
|
||||
{
|
||||
// The pointer read stays healthy on purpose: a throwing read would skip the seed for the guest too, and
|
||||
// the test would pass for the wrong reason.
|
||||
ArrangeAuthMe(DefaultConfig(), exists: false, approved: false);
|
||||
var (req, ctx) = BuildRequest(SignedIn(external));
|
||||
|
||||
var response = await _sut.GetCurrentUser(req, ctx);
|
||||
|
||||
// Both wait at the activation gate; only the member's sign-in seeded the tenant and queued its activation.
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Contains("PendingActivation", await ReadBodyAsync(response));
|
||||
Assert.Equal(external ? 0 : 1, _store.Writes.Count);
|
||||
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
|
||||
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
|
||||
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), external ? Times.Never() : Times.Once());
|
||||
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(TenantId, Upn), external ? Times.Never() : Times.Once());
|
||||
if (!external)
|
||||
Assert.Equal(Upn, _store.Row!.OnboardedBy);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task GetCurrentUser_ActivatedTenantWithoutAdmins_OnlyAMemberBecomesItsFirstAdmin(bool external)
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.DomainName = "contoso.com";
|
||||
ArrangeAuthMe(config, exists: true, approved: true);
|
||||
_tenantAdminsMock
|
||||
.Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System"))
|
||||
.ReturnsAsync(new TenantAdminRow());
|
||||
var (req, ctx) = BuildRequest(SignedIn(external));
|
||||
|
||||
var response = await _sut.GetCurrentUser(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
_tenantAdminsMock.Verify(
|
||||
x => x.AddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()),
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,6 +414,43 @@ public class AuthFunctionTests
|
||||
Assert.Null((string?)body.role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoAdmin_ExternalIdentityFirstUser_IsNeverTheFirstAdmin()
|
||||
{
|
||||
// A guest signing in to an activated tenant that has no admins yet must not be made its Tenant Admin:
|
||||
// it is not a member of the tenant at all.
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: null, mcpCheck: McpDenied(),
|
||||
hasTenantAdmins: false,
|
||||
TenantId, Upn, DisplayName, ObjectId,
|
||||
isExternalIdentity: true);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.NeedsAutoAdmin);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.False((bool)body.isTenantAdmin);
|
||||
Assert.Null((string?)body.role);
|
||||
Assert.False((bool)body.canManageBootstrapTokens);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExternalIdentity_KeepsARoleTheTenantGrantedExplicitly()
|
||||
{
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: AdminRole(), mcpCheck: McpDenied(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId,
|
||||
isExternalIdentity: true);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.NeedsAutoAdmin);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.True((bool)body.isTenantAdmin);
|
||||
Assert.Equal(Constants.TenantRoles.Admin, (string)body.role);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Auto-Admin Logic
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
+37
-2
@@ -1,6 +1,7 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Xunit;
|
||||
@@ -34,12 +35,13 @@ public class AuthenticationMiddlewareTokenValidationTests
|
||||
string audience = ClientId,
|
||||
string tid = Tid,
|
||||
DateTime? notBefore = null,
|
||||
DateTime? expires = null)
|
||||
DateTime? expires = null,
|
||||
IEnumerable<Claim>? extraClaims = null)
|
||||
{
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: issuer ?? V2Issuer,
|
||||
audience: audience,
|
||||
claims: new[] { new Claim("tid", tid) },
|
||||
claims: new[] { new Claim("tid", tid) }.Concat(extraClaims ?? Array.Empty<Claim>()),
|
||||
notBefore: notBefore ?? DateTime.UtcNow.AddMinutes(-5),
|
||||
expires: expires ?? DateTime.UtcNow.AddMinutes(30),
|
||||
signingCredentials: creds); // null creds → alg "none", unsigned
|
||||
@@ -136,6 +138,39 @@ public class AuthenticationMiddlewareTokenValidationTests
|
||||
Assert.ThrowsAny<SecurityTokenException>(() => Validate(forged));
|
||||
}
|
||||
|
||||
// ── External identity: the guest signals survive inbound claim mapping ──
|
||||
// A validated token carries tid and idp only under their mapped names, so a classifier reading the raw
|
||||
// names would call every member with an idp external, or every guest a member.
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://sts.windows.net/bbbbbbbb-1111-2222-3333-444444444444/", true)] // a guest of another Entra tenant
|
||||
[InlineData("live.com", true)] // a personal Microsoft account
|
||||
[InlineData("https://sts.windows.net/" + Tid + "/", false)] // a member: own tenant, v1 form under the v2 issuer
|
||||
public void IdpClaim_after_validation_decides_external_identity(string idp, bool external)
|
||||
{
|
||||
var principal = Validate(Mint(RsaCreds, extraClaims: new[] { new Claim("idp", idp) }));
|
||||
|
||||
Assert.Equal(external, principal.IsExternalIdentity());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1", true)]
|
||||
[InlineData("0", false)]
|
||||
public void AcctClaim_after_validation_is_read_as_the_number_Entra_sends(string acct, bool external)
|
||||
{
|
||||
var principal = Validate(Mint(RsaCreds, extraClaims: new[] { new Claim("acct", acct, ClaimValueTypes.Integer32) }));
|
||||
|
||||
Assert.Equal(external, principal.IsExternalIdentity());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MemberToken_without_guest_signals_is_not_external()
|
||||
{
|
||||
var principal = Validate(Mint(RsaCreds, extraClaims: new[] { new Claim("upn", "alice@contoso.example") }));
|
||||
|
||||
Assert.False(principal.IsExternalIdentity());
|
||||
}
|
||||
|
||||
// ── IsValidTenantId: pre-signature tid GUID gate ─────────────────────
|
||||
|
||||
[Theory]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user