Agent payload: signed catalog, vendor check, dev channel in CI (D-354, phase A)

- build-agent.yml: input channel (release | dev; dev writes the lab blobs only and
  refuses stable/release). Every third-party PE needs its vendor's embedded Authenticode
  signature (signtool verify /pa, read-only). AutopilotMonitor.Agent.cat, a signed
  SHA-256 catalog over every payload file, is created after Authenticode and before the
  ZIP; the device check runs under Windows PowerShell 5.1 on the final ZIP.
- Agent: SelfUpdater step 5b verifies the staged payload against the catalog before any
  file is swapped (PayloadCatalogVerifier, Test-FileCatalog in-process; a rejection means
  no update, skip reason catalog_rejected). agent_started reports which bootstrap
  installed the agent, read from the bootstrap logs (BootstrapProvenanceReader).
- Payload: System.Management.Automation.dll (never loaded, the GAC copy binds first) and
  AutopilotMonitor.Shared.xml no longer ship; PDBs stay.
- Shared: CodeSigningPublisherPattern, AgentPayloadCatalogFileName;
  CodeSigningParityTests pins them against installer, loader and workflow.
- Submodules: infra (build.ps1 local only), internal (D-354, plan, docs), commands.

Installer v2.6 and the OOBE script follow once a catalog ZIP is on stable: a merged
installer change is live on the next loader run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-13 17:48:29 +02:00
parent 47728fbcd4
commit 283c094986
16 changed files with 1115 additions and 7 deletions
+197 -4
View File
@@ -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
```
+1 -1
Submodule infra updated: 0af4689368...0085deab45
@@ -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
{
/// <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).
/// </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 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());
}
}
}
@@ -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;
}
}
}
@@ -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,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);
}
}
}
}
@@ -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" />
@@ -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
{
/// <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 script version: vX.Y</c>, written by every
/// installer run since 2026-04-09. A log without that line means an older installer.</item>
/// <item><c>bootstrap-loader.log</c> (Intune) / <c>bootstrap-msi.log</c> (MSI):
/// <c>bootstrap loader vX.Y started</c>, written by every loader since v1.0.</item>
/// <item><c>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";
// 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(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;
}
/// <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.
///
@@ -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);
}
}
@@ -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)' == ''">
@@ -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;
@@ -0,0 +1,62 @@
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// 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.
/// </summary>
public class CodeSigningParityTests
{
private static readonly Regex ExpectedPublisherLine = new(
"^\\$ExpectedPublisher = \"(?<pin>[^\"]+)\"\\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: (?<name>\\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;
}
}
@@ -309,6 +309,22 @@ namespace AutopilotMonitor.Shared
/// </summary>
public const string AgentDirectory = @"%ProgramData%\AutopilotMonitor\Agent";
/// <summary>
/// Publisher pin for everything the release pipeline signs: a <c>-like</c> 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
/// <c>$ExpectedPublisher</c>; a parity test keeps all three in step.
/// </summary>
public const string CodeSigningPublisherPattern = "*O=glueckkanja AG*";
/// <summary>
/// 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 <see cref="CodeSigningPublisherPattern"/> and matches the
/// extracted files exactly: nothing missing, changed or extra.
/// </summary>
public const string AgentPayloadCatalogFileName = "AutopilotMonitor.Agent.cat";
// -----------------------------------------------------------------------
// Upload / batching defaults
// -----------------------------------------------------------------------