mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-22 19:55:35 +00:00
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:
+1
-1
Submodule .claude/commands updated: 207fe472b1...e593da2447
@@ -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
+1
-1
Submodule internal updated: ac48bcb940...8f1bfa7cc2
+165
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Step 5b of the self-update: the staged payload must match its signed catalog before a
|
||||
/// single file is swapped in. The verifier is replaced through the static hook; each test
|
||||
/// restores it, as in <see cref="SelfUpdaterGracefulShutdownTests"/>.
|
||||
/// </summary>
|
||||
public sealed class SelfUpdaterPayloadCatalogTests : IDisposable
|
||||
{
|
||||
private const string StagingDir = @"C:\ProgramData\AutopilotMonitor\Agent-Update";
|
||||
|
||||
private readonly Func<string, PayloadCatalogVerdict> _savedVerifier;
|
||||
private readonly List<string> _log = new List<string>();
|
||||
|
||||
public SelfUpdaterPayloadCatalogTests()
|
||||
{
|
||||
_savedVerifier = SelfUpdater.VerifyPayloadCatalog;
|
||||
}
|
||||
|
||||
public void Dispose() => SelfUpdater.VerifyPayloadCatalog = _savedVerifier;
|
||||
|
||||
[Fact]
|
||||
public void Production_default_is_the_real_verifier()
|
||||
{
|
||||
Assert.Equal(typeof(PayloadCatalogVerifier), _savedVerifier.Method.DeclaringType);
|
||||
Assert.Equal(nameof(PayloadCatalogVerifier.Verify), _savedVerifier.Method.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accepted_payload_lets_the_update_continue()
|
||||
{
|
||||
string? checkedDir = null;
|
||||
SelfUpdater.VerifyPayloadCatalog = dir => { checkedDir = dir; return PayloadCatalogVerdict.Accept(); };
|
||||
|
||||
var ok = SelfUpdater.VerifyStagedPayload(StagingDir, _log.Add, out var reason);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Null(reason);
|
||||
Assert.Equal(StagingDir, checkedDir);
|
||||
Assert.Contains(_log, line => line.Contains("payload catalog verified"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejected_payload_stops_the_update_and_hands_over_the_reason()
|
||||
{
|
||||
SelfUpdater.VerifyPayloadCatalog = _ =>
|
||||
PayloadCatalogVerdict.Reject("payload differs from catalog (missing: none; extra: evil.dll; changed: none)");
|
||||
|
||||
var ok = SelfUpdater.VerifyStagedPayload(StagingDir, _log.Add, out var reason);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Contains("extra: evil.dll", reason);
|
||||
Assert.Contains(_log, line => line.Contains("REJECTED"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_throwing_verifier_is_a_rejection_not_a_pass()
|
||||
{
|
||||
SelfUpdater.VerifyPayloadCatalog = _ => throw new InvalidOperationException("runspace broke");
|
||||
|
||||
var ok = SelfUpdater.VerifyStagedPayload(StagingDir, _log.Add, out var reason);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Contains("runspace broke", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_catalog_check_runs_before_any_file_is_swapped()
|
||||
{
|
||||
// Source-level pin: CheckAndApplyUpdateAsync has no seam for the download, so the
|
||||
// ordering that makes the check meaningful is pinned on the source itself.
|
||||
var source = File.ReadAllText(Path.Combine(FindRepoRoot(),
|
||||
"src", "Agent", "AutopilotMonitor.Agent.V2.Core", "Monitoring", "Runtime", "SelfUpdater.cs"));
|
||||
|
||||
var check = source.IndexOf("if (!VerifyStagedPayload(stagingDir, log, out var catalogRejection))", StringComparison.Ordinal);
|
||||
var swap = source.IndexOf("var swapOk = SwapFiles(agentDir, stagingDir, log);", StringComparison.Ordinal);
|
||||
|
||||
Assert.True(check > 0, "step 5b not found in SelfUpdater.cs");
|
||||
Assert.True(swap > check, "SwapFiles must come after the catalog check");
|
||||
Assert.Contains("WriteSkipMarker(\"catalog_rejected\"", source);
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(dir);
|
||||
return dir.FullName;
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -44,6 +44,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Assert.Equal("Succeeded", data["remoteConfigOutcome"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmitAgentStarted_CarriesTheBootstrapProvenance()
|
||||
{
|
||||
// Values depend on the machine's %ProgramData%\AutopilotMonitor\Logs (see
|
||||
// BootstrapProvenanceReaderTests); the two presence flags are always on the wire.
|
||||
using var rig = new EmitterRig();
|
||||
rig.RemoteConfig.SetFakeOutcome(RemoteConfigFetchOutcome.Succeeded, configVersion: 28);
|
||||
|
||||
LifecycleEmitters.EmitAgentStarted(
|
||||
rig.Post, rig.AgentConfig, rig.PreviousExit, "2.0.806", rig.RemoteConfig, rig.Logger);
|
||||
|
||||
var data = AssertSingleEmit(rig.Sink, expectedEventType: "agent_started");
|
||||
Assert.IsType<bool>(data["bootstrapScriptLogPresent"]);
|
||||
Assert.IsType<bool>(data["oobeBootstrapLogPresent"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmitAgentStarted_OnFallbackToDefaults_FlagsRemoteConfigFalse()
|
||||
{
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// The decision over what <c>Test-FileCatalog -Detailed</c> reports. The runspace itself is
|
||||
/// exercised on a lab device (plan agent-payload-catalog, V1/V3), never in the test host.
|
||||
/// </summary>
|
||||
public sealed class PayloadCatalogVerifierTests
|
||||
{
|
||||
private const string OurSubject = "CN=glueckkanja AG, O=glueckkanja AG, L=Offenbach, S=Hessen, C=DE";
|
||||
|
||||
private static Dictionary<string, string> Files(params string[] namesAndHashes)
|
||||
{
|
||||
var map = new Dictionary<string, string>();
|
||||
for (var i = 0; i < namesAndHashes.Length; i += 2) map[namesAndHashes[i]] = namesAndHashes[i + 1];
|
||||
return map;
|
||||
}
|
||||
|
||||
private static PayloadCatalogFacts Facts(
|
||||
string status = "Valid", string? signatureStatus = "Valid", string? subject = OurSubject,
|
||||
Dictionary<string, string>? catalog = null, Dictionary<string, string>? path = null)
|
||||
=> new PayloadCatalogFacts(status, signatureStatus, subject,
|
||||
catalog ?? Files("a.dll", "AA"), path ?? Files("a.dll", "AA"));
|
||||
|
||||
private static PayloadCatalogVerdict Evaluate(PayloadCatalogFacts facts)
|
||||
=> PayloadCatalogVerifier.Evaluate(facts, Constants.CodeSigningPublisherPattern);
|
||||
|
||||
[Fact]
|
||||
public void Signed_by_us_and_matching_is_accepted()
|
||||
{
|
||||
var verdict = Evaluate(Facts());
|
||||
|
||||
Assert.True(verdict.Accepted);
|
||||
Assert.Null(verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matching_hashes_under_an_unsigned_catalog_are_rejected()
|
||||
{
|
||||
// Status covers the hashes only: an unsigned catalog over matching files reports Valid.
|
||||
var verdict = Evaluate(Facts(signatureStatus: "NotSigned", subject: null));
|
||||
|
||||
Assert.False(verdict.Accepted);
|
||||
Assert.Contains("catalog signature status NotSigned", verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_validly_signed_catalog_from_another_publisher_is_rejected()
|
||||
{
|
||||
var verdict = Evaluate(Facts(subject: "CN=Contoso Ltd, O=Contoso Ltd, C=US"));
|
||||
|
||||
Assert.False(verdict.Accepted);
|
||||
Assert.Contains("does not match", verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_signature_details_are_a_rejection_not_a_pass()
|
||||
{
|
||||
Assert.False(Evaluate(Facts(signatureStatus: null, subject: null)).Accepted);
|
||||
Assert.False(Evaluate(Facts(subject: "")).Accepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_differing_payload_names_missing_extra_and_changed_files()
|
||||
{
|
||||
var verdict = Evaluate(Facts(
|
||||
status: "ValidationFailed",
|
||||
catalog: Files("a.dll", "AA", "b.dll", "BB", "c.config", "CC"),
|
||||
path: Files("a.dll", "AA", "b.dll", "XX", "evil.dll", "EE")));
|
||||
|
||||
Assert.False(verdict.Accepted);
|
||||
Assert.Contains("missing: c.config", verdict.Reason);
|
||||
Assert.Contains("extra: evil.dll", verdict.Reason);
|
||||
Assert.Contains("changed: b.dll", verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Long_difference_lists_are_capped()
|
||||
{
|
||||
var path = Files("a.dll", "AA");
|
||||
for (var i = 0; i < 12; i++) path[$"extra{i:00}.txt"] = "00";
|
||||
|
||||
var verdict = Evaluate(Facts(status: "ValidationFailed", path: path));
|
||||
|
||||
Assert.Contains("(+7 more)", verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Signature_and_payload_problems_are_both_reported()
|
||||
{
|
||||
var verdict = Evaluate(Facts(status: "ValidationFailed", signatureStatus: "HashMismatch", subject: null,
|
||||
path: Files("a.dll", "AA", "x.dll", "11")));
|
||||
|
||||
Assert.Contains("catalog signature status HashMismatch", verdict.Reason);
|
||||
Assert.Contains("extra: x.dll", verdict.Reason);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(OurSubject, true)]
|
||||
[InlineData("cn=glueckkanja ag, o=glueckkanja ag, c=de", true)] // -like is case-insensitive
|
||||
[InlineData("CN=x, O=glueckkanja AG Holding, C=DE", true)] // the pattern is open-ended by design
|
||||
[InlineData("CN=glueckkanja AG, O=Contoso, C=DE", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void Publisher_pattern_behaves_like_powershell_like(string? subject, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, PayloadCatalogVerifier.MatchesPublisherPattern(subject, Constants.CodeSigningPublisherPattern));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Regex_metacharacters_in_the_pattern_are_literal()
|
||||
{
|
||||
Assert.True(PayloadCatalogVerifier.MatchesPublisherPattern("O=Json.NET (.NET Foundation)", "*O=Json.NET (.NET Foundation)*"));
|
||||
Assert.False(PayloadCatalogVerifier.MatchesPublisherPattern("O=JsonXNET (.NET Foundation)", "*O=Json.NET (.NET Foundation)*"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_payload_without_catalog_is_rejected_before_any_runspace_starts()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "apm-catalog-test-" + System.Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "readme.txt"), "x");
|
||||
|
||||
var verdict = PayloadCatalogVerifier.Verify(dir);
|
||||
|
||||
Assert.False(verdict.Accepted);
|
||||
Assert.Contains("missing", verdict.Reason);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
+140
@@ -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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user