diff --git a/.claude/commands b/.claude/commands
index 207fe472..e593da24 160000
--- a/.claude/commands
+++ b/.claude/commands
@@ -1 +1 @@
-Subproject commit 207fe472b1c3273ac9d834cb0c55c3732bdf992e
+Subproject commit e593da24472691cbf46bfe89b8cf0aa48b9d22a3
diff --git a/.github/workflows/build-agent.yml b/.github/workflows/build-agent.yml
index 3c42a252..3ae28d3e 100644
--- a/.github/workflows/build-agent.yml
+++ b/.github/workflows/build-agent.yml
@@ -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,62 @@ 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, the edition installer and OOBE script verify with; the pin is
+ # the installer's own literal, the value devices compare against.
+ - 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)
+
+ $pinLine = Select-String -Path $env:BOOTSTRAP_SOURCE -Pattern '^\$ExpectedPublisher = "([^"]+)"' | Select-Object -First 1
+ if (-not $pinLine) { throw "Could not read the publisher pin from $env:BOOTSTRAP_SOURCE." }
+ $pin = $pinLine.Matches[0].Groups[1].Value
+ if (-not [string]::IsNullOrWhiteSpace($env:CODE_SIGN_EXPECTED_SUBJECT) -and $pin -ne $env:CODE_SIGN_EXPECTED_SUBJECT) {
+ throw "Installer pin '$pin' differs from CODE_SIGN_EXPECTED_SUBJECT '$env:CODE_SIGN_EXPECTED_SUBJECT'."
+ }
+
+ $catalog = Join-Path $payload $env:PAYLOAD_CATALOG
+ if (-not (Test-Path $catalog -PathType Leaf)) { throw "$env:PAYLOAD_CATALOG is missing from the ZIP." }
+ $result = Test-FileCatalog -Path $payload -CatalogFilePath $catalog -FilesToSkip $env:PAYLOAD_CATALOG -Detailed
+
+ $problems = @()
+ if ($result.Status -ne 'Valid') {
+ $expected = $result.CatalogItems
+ $actual = $result.PathItems
+ $missing = @($expected.Keys | Where-Object { -not $actual.ContainsKey($_) })
+ $extra = @($actual.Keys | Where-Object { -not $expected.ContainsKey($_) })
+ $changed = @($expected.Keys | Where-Object { $actual.ContainsKey($_) -and $actual[$_] -ne $expected[$_] })
+ $problems += "payload differs from the catalog (missing: $($missing -join ', '); extra: $($extra -join ', '); changed: $($changed -join ', '))"
+ }
+ $signature = $result.Signature
+ if ($signature.Status -ne 'Valid') {
+ $problems += "catalog signature status is '$($signature.Status)'"
+ } elseif ($signature.SignerCertificate.Subject -notlike $pin) {
+ $problems += "catalog signed by '$($signature.SignerCertificate.Subject)', expected '$pin'"
+ }
+ 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: $($result.CatalogItems.Count) files, signer $($signature.SignerCertificate.Subject)"
+
- name: Upload to Azure Blob Storage
if: inputs.upload_blob
shell: pwsh
@@ -531,6 +712,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 +829,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
```
diff --git a/infra b/infra
index 0af46893..0085deab 160000
--- a/infra
+++ b/infra
@@ -1 +1 @@
-Subproject commit 0af4689368740005cc40cf6b48e50fe2adddce94
+Subproject commit 0085deab45d0c208138fa3725baf0bbc2c6bde98
diff --git a/internal b/internal
index ac48bcb9..8f1bfa7c 160000
--- a/internal
+++ b/internal
@@ -1 +1 @@
-Subproject commit ac48bcb940d22e51677dd8195b17119d1984d807
+Subproject commit 8f1bfa7cc296a2000da0c636aee474118bfcae0f
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/BootstrapProvenanceReaderTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/BootstrapProvenanceReaderTests.cs
new file mode 100644
index 00000000..012b8418
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/BootstrapProvenanceReaderTests.cs
@@ -0,0 +1,165 @@
+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
+{
+ ///
+ /// The bootstrap provenance in agent_started. Fixture lines are the scripts' real
+ /// formats: installer [ts] Bootstrap script version: vX.Y
+ /// (Install-AutopilotMonitor.ps1), loader [ts] === Autopilot Monitor bootstrap loader vX.Y
+ /// started (user=..., pid=...) === (Start-AutopilotMonitor.ps1).
+ ///
+ 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 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 { 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();
+
+ 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();
+
+ BootstrapProvenanceReader.Read(_dir).AddTo(data);
+
+ Assert.Equal(new[] { "bootstrapScriptLogPresent", "oobeBootstrapLogPresent" }, data.Keys.OrderBy(k => k).ToArray());
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/SelfUpdaterPayloadCatalogTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/SelfUpdaterPayloadCatalogTests.cs
new file mode 100644
index 00000000..2684e0e9
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/SelfUpdaterPayloadCatalogTests.cs
@@ -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
+{
+ ///
+ /// 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 .
+ ///
+ public sealed class SelfUpdaterPayloadCatalogTests : IDisposable
+ {
+ private const string StagingDir = @"C:\ProgramData\AutopilotMonitor\Agent-Update";
+
+ private readonly Func _savedVerifier;
+ private readonly List _log = new List();
+
+ 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;
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/LifecycleEmittersRemoteConfigTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/LifecycleEmittersRemoteConfigTests.cs
index a27b2713..a0fd2946 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/LifecycleEmittersRemoteConfigTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/LifecycleEmittersRemoteConfigTests.cs
@@ -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(data["bootstrapScriptLogPresent"]);
+ Assert.IsType(data["oobeBootstrapLogPresent"]);
+ }
+
[Fact]
public void EmitAgentStarted_OnFallbackToDefaults_FlagsRemoteConfigFalse()
{
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/PayloadCatalogVerifierTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/PayloadCatalogVerifierTests.cs
new file mode 100644
index 00000000..778820cb
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/PayloadCatalogVerifierTests.cs
@@ -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
+{
+ ///
+ /// The decision over what Test-FileCatalog -Detailed reports. The runspace itself is
+ /// exercised on a lab device (plan agent-payload-catalog, V1/V3), never in the test host.
+ ///
+ public sealed class PayloadCatalogVerifierTests
+ {
+ private const string OurSubject = "CN=glueckkanja AG, O=glueckkanja AG, L=Offenbach, S=Hessen, C=DE";
+
+ private static Dictionary Files(params string[] namesAndHashes)
+ {
+ var map = new Dictionary();
+ 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? catalog = null, Dictionary? 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);
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/AutopilotMonitor.Agent.V2.Core.csproj b/src/Agent/AutopilotMonitor.Agent.V2.Core/AutopilotMonitor.Agent.V2.Core.csproj
index 4243dcd8..b1e7d122 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/AutopilotMonitor.Agent.V2.Core.csproj
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/AutopilotMonitor.Agent.V2.Core.csproj
@@ -62,8 +62,15 @@
+
$(windir)\Microsoft.NET\assembly\GAC_MSIL\System.Management.Automation\v4.0_3.0.0.0__31bf3856ad364e35\System.Management.Automation.dll
+ false
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/BootstrapProvenanceReader.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/BootstrapProvenanceReader.cs
new file mode 100644
index 00000000..94060c07
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/BootstrapProvenanceReader.cs
@@ -0,0 +1,140 @@
+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
+{
+ ///
+ /// 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 --install would only ever cover installers released after that
+ /// change -- the old installers still assigned in Intune are exactly the ones worth counting.
+ ///
+ /// - bootstrap-script.log: Bootstrap script version: vX.Y, written by every
+ /// installer run since 2026-04-09. A log without that line means an older installer.
+ /// - bootstrap-loader.log (Intune) / bootstrap-msi.log (MSI):
+ /// bootstrap loader vX.Y started, written by every loader since v1.0.
+ /// - oobe_bootstrap.log: the OOBE go-script (generated by the backend, no version).
+ ///
+ /// 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.
+ ///
+ 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";
+
+ // 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(?\d+(?:\.\d+){0,3})\b", RegexOptions.CultureInvariant);
+
+ private static readonly Regex LoaderStartLine = new Regex(
+ @"bootstrap loader v(?\d+(?:\.\d+){0,3}) started\b", RegexOptions.CultureInvariant);
+
+ public static BootstrapProvenance Read(string logDirectory)
+ {
+ try
+ {
+ var installerLog = ReadTail(Path.Combine(logDirectory, InstallerLogName));
+
+ // Intune loader and MSI loader write separate files; when a device has both, the
+ // more recently written one belongs to the bootstrap that ran last.
+ var loader = new[] { (Name: LoaderLogName, Channel: "loader"), (Name: MsiLoaderLogName, Channel: "msi") }
+ .Select(l => (l.Channel, Path: Path.Combine(logDirectory, l.Name)))
+ .Where(l => File.Exists(l.Path))
+ .OrderByDescending(l => File.GetLastWriteTimeUtc(l.Path))
+ .Select(l => (l.Channel, Version: LastVersion(ReadTail(l.Path), LoaderStartLine)))
+ .FirstOrDefault();
+
+ return new BootstrapProvenance(
+ scriptLogPresent: installerLog != null,
+ scriptVersion: LastVersion(installerLog, InstallerVersionLine),
+ loaderLog: loader.Channel,
+ loaderVersion: loader.Version,
+ oobeLogPresent: File.Exists(Path.Combine(logDirectory, OobeLogName)));
+ }
+ catch
+ {
+ return BootstrapProvenance.Unknown;
+ }
+ }
+
+ 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;
+ }
+
+ /// The last of the file, or null when it is missing or unreadable.
+ 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;
+ }
+ }
+ }
+
+ /// What found; null fields were not found.
+ 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; }
+
+ ///
+ /// Adds the agent_started keys. Version and loader keys only when found, so a
+ /// missing key always means "not found" and never an empty string.
+ ///
+ public void AddTo(IDictionary 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;
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SelfUpdater.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SelfUpdater.cs
index af414937..7a909865 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SelfUpdater.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SelfUpdater.cs
@@ -139,6 +139,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
///
internal const int GracefulExitFallbackMs = 15000;
+ ///
+ /// The payload catalog check of step 5b. Production keeps the default,
+ /// ; tests swap it so the update path runs
+ /// without a PowerShell runspace. Same static-hook idiom as .
+ ///
+ internal static Func VerifyPayloadCatalog { get; set; } = PayloadCatalogVerifier.Verify;
+
///
/// 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
}
}
+ ///
+ /// 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.
+ ///
+ internal static bool VerifyStagedPayload(string stagingDir, Action 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;
+ }
+
///
/// Extracts the ZIP to the staging directory. Returns false on failure.
///
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/PayloadCatalogVerifier.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/PayloadCatalogVerifier.cs
new file mode 100644
index 00000000..1ffdef72
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/PayloadCatalogVerifier.cs
@@ -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
+{
+ ///
+ /// Verifies an extracted agent payload against its signed catalog
+ /// () with Windows PowerShell's own
+ /// Test-FileCatalog, 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
+ /// , and every file of the payload must match
+ /// it, with nothing missing, changed or extra.
+ ///
+ /// 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.
+ ///
+ ///
+ public static class PayloadCatalogVerifier
+ {
+ private const int InvokeTimeoutMs = 60000;
+ private const int MaxNamesPerList = 5;
+
+ ///
+ /// Checks . Never throws: any failure to run the check is a
+ /// rejection, because an unverifiable payload must not be installed.
+ ///
+ 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})");
+ }
+ }
+
+ ///
+ /// The decision over the facts Test-FileCatalog -Detailed reports. Separate from the
+ /// runspace so it is testable without PowerShell. Status covers the hashes only: an
+ /// unsigned catalog over matching files reports Valid, which is why the signature is
+ /// judged on its own.
+ ///
+ internal static PayloadCatalogVerdict Evaluate(PayloadCatalogFacts facts, string publisherPattern)
+ {
+ var reasons = new List();
+
+ 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));
+ }
+
+ ///
+ /// PowerShell's case-insensitive -like for the two wildcards the pin uses (*,
+ /// ?), without loading the PowerShell engine. Bracket classes are not supported;
+ /// CodeSigningParityTests pins the pattern's shape.
+ ///
+ 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 expected, IReadOnlyDictionary 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 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 ToStringMap(object value)
+ {
+ var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (value is IDictionary dictionary)
+ {
+ foreach (DictionaryEntry entry in dictionary)
+ map[Convert.ToString(entry.Key)] = Convert.ToString(entry.Value);
+ }
+ return map;
+ }
+ }
+
+ /// What Test-FileCatalog -Detailed reported, reduced to the fields the decision needs.
+ internal sealed class PayloadCatalogFacts
+ {
+ public PayloadCatalogFacts(
+ string status, string signatureStatus, string signerSubject,
+ IReadOnlyDictionary catalogItems, IReadOnlyDictionary pathItems)
+ {
+ Status = status;
+ SignatureStatus = signatureStatus;
+ SignerSubject = signerSubject;
+ CatalogItems = catalogItems ?? new Dictionary();
+ PathItems = pathItems ?? new Dictionary();
+ }
+
+ public string Status { get; }
+ public string SignatureStatus { get; }
+ public string SignerSubject { get; }
+ public IReadOnlyDictionary CatalogItems { get; }
+ public IReadOnlyDictionary PathItems { get; }
+ }
+
+ /// Outcome of a payload catalog check; is null when accepted.
+ 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);
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj b/src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj
index 28fdd208..1ffe4ced 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj
+++ b/src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj
@@ -33,6 +33,13 @@
$(Version)
true
+
+
+ .pdb
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs
index d36ae1f9..978bcc62 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs
@@ -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;
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/CodeSigningParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/CodeSigningParityTests.cs
new file mode 100644
index 00000000..464fc298
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/CodeSigningParityTests.cs
@@ -0,0 +1,62 @@
+using System.Text.RegularExpressions;
+using AutopilotMonitor.Shared;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// The publisher pin and the payload catalog name live in places that cannot share code: the C#
+/// constants (OOBE bootstrap template, agent self-updater), the bootstrap installer, the frozen
+/// loader and the release workflow. A drift would make one verifier refuse what the others
+/// accept, or the pipeline publish a catalog no device looks for, so each literal is pinned
+/// against its constant here.
+///
+public class CodeSigningParityTests
+{
+ private static readonly Regex ExpectedPublisherLine = new(
+ "^\\$ExpectedPublisher = \"(?[^\"]+)\"\\r?$", RegexOptions.Multiline);
+
+ [Theory]
+ [InlineData("Install-AutopilotMonitor.ps1")]
+ [InlineData("Start-AutopilotMonitor.ps1")]
+ public void Bootstrap_script_pin_matches_the_constant(string scriptName)
+ {
+ var source = File.ReadAllText(Path.Combine(FindRepoRoot(), "scripts", "Bootstrap", scriptName));
+
+ var matches = ExpectedPublisherLine.Matches(source);
+
+ Assert.Single(matches);
+ Assert.Equal(Constants.CodeSigningPublisherPattern, matches[0].Groups["pin"].Value);
+ }
+
+ [Fact]
+ public void Pin_is_a_subject_pattern_not_a_thumbprint()
+ {
+ Assert.StartsWith("*O=", Constants.CodeSigningPublisherPattern);
+ Assert.EndsWith("*", Constants.CodeSigningPublisherPattern);
+ Assert.DoesNotMatch("^[0-9A-Fa-f]{40}$", Constants.CodeSigningPublisherPattern.Trim('*'));
+ // The agent's matcher implements -like for * and ? only (PayloadCatalogVerifier).
+ Assert.DoesNotContain("[", Constants.CodeSigningPublisherPattern);
+ }
+
+ [Fact]
+ public void Release_workflow_catalog_name_matches_the_constant()
+ {
+ var workflow = File.ReadAllText(Path.Combine(FindRepoRoot(), ".github", "workflows", "build-agent.yml"));
+
+ var match = Regex.Match(workflow, "^\\s+PAYLOAD_CATALOG: (?\\S+)\\r?$", RegexOptions.Multiline);
+
+ Assert.True(match.Success, "build-agent.yml no longer declares PAYLOAD_CATALOG");
+ Assert.Equal(Constants.AgentPayloadCatalogFileName, match.Groups["name"].Value);
+ }
+
+ 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;
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs
index 947e13e2..5365eef0 100644
--- a/src/Shared/AutopilotMonitor.Shared/Constants.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs
@@ -309,6 +309,22 @@ namespace AutopilotMonitor.Shared
///
public const string AgentDirectory = @"%ProgramData%\AutopilotMonitor\Agent";
+ ///
+ /// Publisher pin for everything the release pipeline signs: a -like pattern on the
+ /// Authenticode signer's subject, never a thumbprint (certificates are renewed, deployed
+ /// verifiers are not). The bootstrap loader and installer carry the same literal as
+ /// $ExpectedPublisher; a parity test keeps all three in step.
+ ///
+ public const string CodeSigningPublisherPattern = "*O=glueckkanja AG*";
+
+ ///
+ /// Signed catalog at the root of every agent ZIP, holding the SHA-256 of every other file
+ /// in the payload. Installer, OOBE bootstrap script and self-updater accept a payload only
+ /// when this catalog is signed by and matches the
+ /// extracted files exactly: nothing missing, changed or extra.
+ ///
+ public const string AgentPayloadCatalogFileName = "AutopilotMonitor.Agent.cat";
+
// -----------------------------------------------------------------------
// Upload / batching defaults
// -----------------------------------------------------------------------