diff --git a/.claude/commands b/.claude/commands index 207fe472..e593da24 160000 --- a/.claude/commands +++ b/.claude/commands @@ -1 +1 @@ -Subproject commit 207fe472b1c3273ac9d834cb0c55c3732bdf992e +Subproject commit e593da24472691cbf46bfe89b8cf0aa48b9d22a3 diff --git a/.github/workflows/build-agent.yml b/.github/workflows/build-agent.yml index 3c42a252..3ae28d3e 100644 --- a/.github/workflows/build-agent.yml +++ b/.github/workflows/build-agent.yml @@ -7,6 +7,14 @@ on: description: 'Build number override (leave empty to auto-increment from buildcounter.txt)' required: false type: string + channel: + description: 'release = versioned line (+ stable on cutover). dev = lab channel only (Agent-v2-dev.zip, version-v2-dev.json, Agent-dev.zip, version-dev.json); needs publish_as_stable=false and create_release=false' + required: true + type: choice + options: + - release + - dev + default: release upload_blob: description: 'Upload to versioned namespace (Agent-v2.zip + version-v2.json)' required: true @@ -60,6 +68,15 @@ env: VERSIONED_ZIP: AutopilotMonitor-Agent-v2.zip STABLE_ZIP: AutopilotMonitor-Agent.zip STABLE_MANIFEST: version.json + # Lab channel (channel=dev): the names the dev renders of the bootstrap scripts read. + CHANNEL: ${{ inputs.channel }} + DEV_VERSIONED_ZIP: AutopilotMonitor-Agent-v2-dev.zip + DEV_MANIFEST: version-v2-dev.json + DEV_STABLE_ZIP: AutopilotMonitor-Agent-dev.zip + DEV_STABLE_MANIFEST: version-dev.json + # Signed catalog over every other file of the payload. Must equal + # Constants.AgentPayloadCatalogFileName; CodeSigningParityTests keeps them in step. + PAYLOAD_CATALOG: AutopilotMonitor.Agent.cat # Authenticode via Azure Key Vault; identity and vault come from repository variables, the # timestamp server (CODE_SIGN_TIMESTAMP_URL) from the endpoint registry like every host here. # CODE_SIGN_EXPECTED_SUBJECT is optional and pins the publisher in the verify step. @@ -106,6 +123,22 @@ jobs: } Write-Host "Code-signing configuration present (certificate '$env:CODE_SIGN_CERTIFICATE_NAME')" + # The dev channel is a lab channel: it never touches stable, AdminConfiguration, the + # bootstrap scripts or a GitHub Release. Refused here, before a build number is burnt. + - name: Check channel inputs + shell: pwsh + env: + PUBLISH_AS_STABLE: ${{ inputs.publish_as_stable }} + CREATE_RELEASE: ${{ inputs.create_release }} + run: | + if ($env:CHANNEL -notin @('release', 'dev')) { + throw "Unknown channel '$($env:CHANNEL)'." + } + if ($env:CHANNEL -eq 'dev' -and ($env:PUBLISH_AS_STABLE -eq 'true' -or $env:CREATE_RELEASE -eq 'true')) { + throw "channel=dev publishes the lab channel only: run it with publish_as_stable=false and create_release=false." + } + Write-Host "Channel: $env:CHANNEL" + - name: Login to Azure (OIDC) uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 with: @@ -222,7 +255,8 @@ jobs: retention-days: 1 # Trust zone: only this repository's scripts, pinned actions and the pinned signing tool - # run here -- never a restore or build. The build output arrives as data. + # run here -- never a restore or build. The build output arrives as data. The runner + # image's Windows SDK signtool is used read-only, to verify third-party signatures. # # No `environment:` yet: the code-signing credential is still bound to the ref, and a job # carries exactly one OIDC subject, so an environment here would break the signing login. @@ -353,8 +387,7 @@ jobs: throw "azuresigntool failed with exit code $LASTEXITCODE -- aborting before anything is published." } - - name: Create release artifact - id: artifact + - name: Stage release payload shell: pwsh run: | $version = $env:AGENT_VERSION @@ -399,6 +432,98 @@ jobs: } Write-Host "Version check passed: EXE = manifest = reserved = $version" + # Third-party code enters the payload only with its vendor's own signature: every PE that + # is not ours needs a valid EMBEDDED Authenticode signature from an allowed vendor. Embedded + # only (signtool verify /pa, no /a): Get-AuthenticodeSignature prefers the runner's Windows + # catalogs and reports "Microsoft Windows" for files Windows happens to ship as well. An + # unsigned file or a new vendor stops the release until a person decides. This guards the + # bytes we did not write; a build that alters our own code is D-346's remaining risk. + - name: Check third-party signatures + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $allowedVendors = @('Microsoft Corporation', 'Json.NET (.NET Foundation)') + + $signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" -ErrorAction SilentlyContinue | + Where-Object { $_.Directory.Parent.Name -match '^\d+(\.\d+){1,3}$' } | + Sort-Object { [version]$_.Directory.Parent.Name } -Descending | + Select-Object -First 1 + if (-not $signtool) { throw "signtool.exe not found in the runner image's Windows SDK." } + Write-Host "Using $($signtool.FullName)" + + $failed = $false + $checked = 0 + foreach ($file in Get-ChildItem -Path 'staging/agent' -Recurse -File) { + # A PE by content (MZ header), not by extension. + $head = [byte[]]::new(2) + $stream = [System.IO.File]::OpenRead($file.FullName) + try { $read = $stream.Read($head, 0, 2) } finally { $stream.Dispose() } + if ($read -lt 2 -or $head[0] -ne 0x4D -or $head[1] -ne 0x5A) { continue } + if ($file.Name -like 'AutopilotMonitor.*') { continue } # ours: the verify step pins them + + $checked++ + $output = & $signtool.FullName verify /pa $file.FullName 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Host "::error file=$($file.Name)::No valid embedded vendor signature. $(($output | Out-String).Trim())" + $failed = $true + continue + } + $subject = [System.Security.Cryptography.X509Certificates.X509Certificate]::CreateFromSignedFile($file.FullName).Subject + $vendor = if ($subject -match '(?:^|,\s*)O=("[^"]*"|[^,]*)') { $Matches[1].Trim('"') } else { '' } + if ($vendor -notin $allowedVendors) { + Write-Host "::error file=$($file.Name)::Signed by '$subject' -- vendor '$vendor' is not on the allowlist." + $failed = $true + continue + } + Write-Host "$($file.Name): embedded signature valid, vendor $vendor" + } + + if ($failed) { throw "Third-party signature check failed. Nothing was published." } + Write-Host "All $checked third-party PE files carry a valid signature from an allowed vendor." + + # The payload contract: one catalog, signed like our binaries, holding the SHA-256 of every + # other file in the ZIP. Installer, OOBE script and self-updater accept a payload only when + # this catalog is ours and matches the extracted files exactly. Created AFTER signing and + # BEFORE the ZIP -- the same load-bearing position as the signing step above. + - name: Create and sign payload catalog + shell: pwsh + env: + DOTNET_ROLL_FORWARD: LatestMajor + run: | + $ErrorActionPreference = 'Stop' + $stagingDir = 'staging/agent' + $catalog = Join-Path $env:RUNNER_TEMP $env:PAYLOAD_CATALOG + if (Test-Path (Join-Path $stagingDir $env:PAYLOAD_CATALOG)) { + throw "$stagingDir already contains $env:PAYLOAD_CATALOG." + } + + New-FileCatalog -Path $stagingDir -CatalogFilePath $catalog -CatalogVersion 2.0 | Out-Null + + & azuresigntool.exe sign --verbose ` + --azure-key-vault-url "$env:CODE_SIGN_KEY_VAULT_URL" ` + --azure-key-vault-accesstoken "$env:CODE_SIGN_AKV_ACCESS_TOKEN" ` + --azure-key-vault-certificate "$env:CODE_SIGN_CERTIFICATE_NAME" ` + --timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" ` + --file-digest sha256 ` + --description "Autopilot Monitor Agent" ` + --description-url "$env:WWW_BASE_URL" ` + $catalog + if ($LASTEXITCODE -ne 0) { + throw "azuresigntool failed on the payload catalog with exit code $LASTEXITCODE -- aborting before anything is published." + } + + Copy-Item -Path $catalog -Destination $stagingDir + $count = (Test-FileCatalog -Path $stagingDir -CatalogFilePath $catalog -FilesToSkip $env:PAYLOAD_CATALOG -Detailed).CatalogItems.Count + Write-Host "Payload catalog signed: $count files" + + - name: Create release artifact + id: artifact + shell: pwsh + run: | + $version = $env:AGENT_VERSION + $stagingDir = "staging/agent" + $manifest = Get-Content "$env:AGENT_OUTPUT/$env:MANIFEST_NAME" -Raw | ConvertFrom-Json + $versionedZip = "AutopilotMonitor-Agent-v$version.zip" Compress-Archive -Path "$stagingDir/*" -DestinationPath $versionedZip Write-Host "Created GitHub-Release artifact: $versionedZip" @@ -488,6 +613,62 @@ jobs: } Write-Host "All $($files.Count) binaries signed with certificate $($thumbprints.Keys -join ', ')" + # The devices' check, run on the FINAL ZIP rather than the staging folder: whatever the + # archiver did (Compress-Archive skips hidden files) fails here instead of on a device. + # Windows PowerShell 5.1, the edition installer and OOBE script verify with; the pin is + # the installer's own literal, the value devices compare against. + - name: Verify payload catalog (device check) + shell: powershell + env: + RELEASE_ZIP: ${{ steps.artifact.outputs.versioned_zip_release }} + run: | + $ErrorActionPreference = 'Stop' + Add-Type -AssemblyName System.IO.Compression.FileSystem + + $payload = Join-Path $env:RUNNER_TEMP 'verify-payload' + if (Test-Path $payload) { throw "$payload already exists." } + [System.IO.Compression.ZipFile]::ExtractToDirectory((Resolve-Path $env:RELEASE_ZIP).Path, $payload) + + $pinLine = Select-String -Path $env:BOOTSTRAP_SOURCE -Pattern '^\$ExpectedPublisher = "([^"]+)"' | Select-Object -First 1 + if (-not $pinLine) { throw "Could not read the publisher pin from $env:BOOTSTRAP_SOURCE." } + $pin = $pinLine.Matches[0].Groups[1].Value + if (-not [string]::IsNullOrWhiteSpace($env:CODE_SIGN_EXPECTED_SUBJECT) -and $pin -ne $env:CODE_SIGN_EXPECTED_SUBJECT) { + throw "Installer pin '$pin' differs from CODE_SIGN_EXPECTED_SUBJECT '$env:CODE_SIGN_EXPECTED_SUBJECT'." + } + + $catalog = Join-Path $payload $env:PAYLOAD_CATALOG + if (-not (Test-Path $catalog -PathType Leaf)) { throw "$env:PAYLOAD_CATALOG is missing from the ZIP." } + $result = Test-FileCatalog -Path $payload -CatalogFilePath $catalog -FilesToSkip $env:PAYLOAD_CATALOG -Detailed + + $problems = @() + if ($result.Status -ne 'Valid') { + $expected = $result.CatalogItems + $actual = $result.PathItems + $missing = @($expected.Keys | Where-Object { -not $actual.ContainsKey($_) }) + $extra = @($actual.Keys | Where-Object { -not $expected.ContainsKey($_) }) + $changed = @($expected.Keys | Where-Object { $actual.ContainsKey($_) -and $actual[$_] -ne $expected[$_] }) + $problems += "payload differs from the catalog (missing: $($missing -join ', '); extra: $($extra -join ', '); changed: $($changed -join ', '))" + } + $signature = $result.Signature + if ($signature.Status -ne 'Valid') { + $problems += "catalog signature status is '$($signature.Status)'" + } elseif ($signature.SignerCertificate.Subject -notlike $pin) { + $problems += "catalog signed by '$($signature.SignerCertificate.Subject)', expected '$pin'" + } + if (-not $signature.TimeStamperCertificate) { + $problems += 'catalog signature carries no RFC-3161 timestamp -- it would expire with the certificate' + } + $exeSigner = (Get-AuthenticodeSignature (Join-Path $payload 'AutopilotMonitor.Agent.exe')).SignerCertificate + if ($signature.SignerCertificate -and $exeSigner -and $signature.SignerCertificate.Thumbprint -ne $exeSigner.Thumbprint) { + $problems += 'catalog and agent exe carry different signing certificates' + } + + if ($problems.Count -gt 0) { + $problems | ForEach-Object { Write-Host "::error::$_" } + throw "Payload catalog verification failed. Nothing was published." + } + Write-Host "Payload catalog verified on the final ZIP: $($result.CatalogItems.Count) files, signer $($signature.SignerCertificate.Subject)" + - name: Upload to Azure Blob Storage if: inputs.upload_blob shell: pwsh @@ -531,6 +712,18 @@ jobs: } } + # Dev channel: lab blobs only, under the names the dev renders of the bootstrap scripts + # read. Nothing else is touched -- no versioned or stable line, no AdminConfiguration, no + # scripts; the channel check in `reserve` already refused publish_as_stable/create_release. + if ($env:CHANNEL -eq 'dev') { + Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:DEV_VERSIONED_ZIP -ContentType "application/zip" + Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:DEV_MANIFEST -ContentType "application/json" + Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:DEV_STABLE_ZIP -ContentType "application/zip" + Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:DEV_STABLE_MANIFEST -ContentType "application/json" + Write-Host "DEV CHANNEL: lab blobs written, nothing else touched." + exit 0 + } + # Versioned namespace (always) Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:VERSIONED_ZIP -ContentType "application/zip" Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:MANIFEST_NAME -ContentType "application/json" @@ -636,7 +829,7 @@ jobs: - **Published as stable:** ${{ inputs.publish_as_stable }} ### Provenance - The executables are Authenticode-signed and timestamped. The ZIP and both executables carry a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify: + The executables are Authenticode-signed and timestamped. Every file in the ZIP is listed in `AutopilotMonitor.Agent.cat`, a catalog signed with the same certificate, and third-party libraries carry their vendor's signature. The ZIP and both executables carry a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify: ``` gh attestation verify AutopilotMonitor-Agent-v${{ needs.reserve.outputs.version }}.zip --repo okieselbach/AutopilotMonitor ``` diff --git a/infra b/infra index 0af46893..0085deab 160000 --- a/infra +++ b/infra @@ -1 +1 @@ -Subproject commit 0af4689368740005cc40cf6b48e50fe2adddce94 +Subproject commit 0085deab45d0c208138fa3725baf0bbc2c6bde98 diff --git a/internal b/internal index ac48bcb9..8f1bfa7c 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit ac48bcb940d22e51677dd8195b17119d1984d807 +Subproject commit 8f1bfa7cc296a2000da0c636aee474118bfcae0f diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/BootstrapProvenanceReaderTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/BootstrapProvenanceReaderTests.cs new file mode 100644 index 00000000..012b8418 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/BootstrapProvenanceReaderTests.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime +{ + /// + /// The bootstrap provenance in agent_started. Fixture lines are the scripts' real + /// formats: installer [ts] Bootstrap script version: vX.Y + /// (Install-AutopilotMonitor.ps1), loader [ts] === Autopilot Monitor bootstrap loader vX.Y + /// started (user=..., pid=...) === (Start-AutopilotMonitor.ps1). + /// + public sealed class BootstrapProvenanceReaderTests : IDisposable + { + private readonly string _dir = Path.Combine(Path.GetTempPath(), "apm-provenance-" + Guid.NewGuid().ToString("N")); + + public BootstrapProvenanceReaderTests() => Directory.CreateDirectory(_dir); + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { /* best effort */ } + } + + private void Write(string name, params string[] lines) => + File.WriteAllText(Path.Combine(_dir, name), string.Join("\r\n", lines) + "\r\n", Encoding.ASCII); + + private static string InstallerRun(string version) => $"[2026-10-13 09:00:00] Bootstrap script version: v{version}"; + + private static string LoaderRun(string version) => + $"[2026-10-13 08:59:58] === Autopilot Monitor bootstrap loader v{version} started (user=SECRETUSER, pid=4242) ==="; + + [Fact] + public void Loader_path_reports_installer_and_loader_versions() + { + Write(BootstrapProvenanceReader.LoaderLogName, LoaderRun("1.2"), "[2026-10-13 08:59:59] Installer signature verified"); + Write(BootstrapProvenanceReader.InstallerLogName, "[2026-10-13 09:00:00] ===== Autopilot Monitor Bootstrap Started =====", InstallerRun("2.5")); + + var p = BootstrapProvenanceReader.Read(_dir); + + Assert.True(p.ScriptLogPresent); + Assert.Equal("2.5", p.ScriptVersion); + Assert.Equal("loader", p.LoaderLog); + Assert.Equal("1.2", p.LoaderVersion); + Assert.False(p.OobeLogPresent); + } + + [Fact] + public void The_last_run_wins_when_the_log_grew_over_several_runs() + { + Write(BootstrapProvenanceReader.InstallerLogName, InstallerRun("2.3"), "x", InstallerRun("2.4"), "y", InstallerRun("2.5"), "z"); + + Assert.Equal("2.5", BootstrapProvenanceReader.Read(_dir).ScriptVersion); + } + + [Fact] + public void A_directly_assigned_installer_has_no_loader_log() + { + Write(BootstrapProvenanceReader.InstallerLogName, InstallerRun("2.4")); + + var p = BootstrapProvenanceReader.Read(_dir); + + Assert.Equal("2.4", p.ScriptVersion); + Assert.Null(p.LoaderLog); + Assert.Null(p.LoaderVersion); + } + + [Fact] + public void An_installer_from_before_the_version_line_is_present_but_without_version() + { + Write(BootstrapProvenanceReader.InstallerLogName, "[2026-03-01 09:00:00] ===== Autopilot Monitor Bootstrap Started ====="); + + var p = BootstrapProvenanceReader.Read(_dir); + + Assert.True(p.ScriptLogPresent); + Assert.Null(p.ScriptVersion); + } + + [Fact] + public void The_msi_loader_log_is_told_apart_from_the_intune_loader() + { + Write(BootstrapProvenanceReader.MsiLoaderLogName, LoaderRun("1.1")); + + var p = BootstrapProvenanceReader.Read(_dir); + + Assert.Equal("msi", p.LoaderLog); + Assert.Equal("1.1", p.LoaderVersion); + } + + [Fact] + public void With_both_loader_logs_the_most_recently_written_one_counts() + { + Write(BootstrapProvenanceReader.LoaderLogName, LoaderRun("1.0")); + Write(BootstrapProvenanceReader.MsiLoaderLogName, LoaderRun("1.2")); + File.SetLastWriteTimeUtc(Path.Combine(_dir, BootstrapProvenanceReader.LoaderLogName), DateTime.UtcNow.AddDays(-3)); + File.SetLastWriteTimeUtc(Path.Combine(_dir, BootstrapProvenanceReader.MsiLoaderLogName), DateTime.UtcNow.AddMinutes(-1)); + + var p = BootstrapProvenanceReader.Read(_dir); + + Assert.Equal("msi", p.LoaderLog); + Assert.Equal("1.2", p.LoaderVersion); + } + + [Fact] + public void The_oobe_go_script_is_recognised_by_its_log() + { + Write(BootstrapProvenanceReader.OobeLogName, "[2026-10-13 09:00:00] ===== Autopilot Monitor OOBE Bootstrap ====="); + + Assert.True(BootstrapProvenanceReader.Read(_dir).OobeLogPresent); + } + + [Fact] + public void Only_the_tail_of_a_large_log_is_read() + { + var filler = Enumerable.Repeat(new string('.', 200), 600).ToArray(); // ~120 KB + var lines = new List { InstallerRun("2.1") }; + lines.AddRange(filler); + lines.Add(InstallerRun("2.5")); + Write(BootstrapProvenanceReader.InstallerLogName, lines.ToArray()); + + Assert.Equal("2.5", BootstrapProvenanceReader.Read(_dir).ScriptVersion); + } + + [Fact] + public void A_missing_directory_reads_as_unknown_and_never_throws() + { + var p = BootstrapProvenanceReader.Read(Path.Combine(_dir, "does-not-exist")); + + Assert.False(p.ScriptLogPresent); + Assert.Null(p.ScriptVersion); + Assert.Null(p.LoaderLog); + Assert.False(p.OobeLogPresent); + } + + [Fact] + public void The_event_keys_carry_versions_only_never_the_account_name() + { + Write(BootstrapProvenanceReader.LoaderLogName, LoaderRun("1.2")); + Write(BootstrapProvenanceReader.InstallerLogName, InstallerRun("2.5")); + var data = new Dictionary(); + + BootstrapProvenanceReader.Read(_dir).AddTo(data); + + Assert.Equal(true, data["bootstrapScriptLogPresent"]); + Assert.Equal("2.5", data["bootstrapScriptVersion"]); + Assert.Equal("loader", data["bootstrapLoaderLog"]); + Assert.Equal("1.2", data["bootstrapLoaderVersion"]); + Assert.Equal(false, data["oobeBootstrapLogPresent"]); + Assert.DoesNotContain(data.Values, v => v is string s && s.Contains("SECRETUSER")); + } + + [Fact] + public void Keys_that_were_not_found_are_left_out() + { + var data = new Dictionary(); + + BootstrapProvenanceReader.Read(_dir).AddTo(data); + + Assert.Equal(new[] { "bootstrapScriptLogPresent", "oobeBootstrapLogPresent" }, data.Keys.OrderBy(k => k).ToArray()); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/SelfUpdaterPayloadCatalogTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/SelfUpdaterPayloadCatalogTests.cs new file mode 100644 index 00000000..2684e0e9 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/SelfUpdaterPayloadCatalogTests.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.IO; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; +using AutopilotMonitor.Agent.V2.Core.Security; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime +{ + /// + /// Step 5b of the self-update: the staged payload must match its signed catalog before a + /// single file is swapped in. The verifier is replaced through the static hook; each test + /// restores it, as in . + /// + public sealed class SelfUpdaterPayloadCatalogTests : IDisposable + { + private const string StagingDir = @"C:\ProgramData\AutopilotMonitor\Agent-Update"; + + private readonly Func _savedVerifier; + private readonly List _log = new List(); + + public SelfUpdaterPayloadCatalogTests() + { + _savedVerifier = SelfUpdater.VerifyPayloadCatalog; + } + + public void Dispose() => SelfUpdater.VerifyPayloadCatalog = _savedVerifier; + + [Fact] + public void Production_default_is_the_real_verifier() + { + Assert.Equal(typeof(PayloadCatalogVerifier), _savedVerifier.Method.DeclaringType); + Assert.Equal(nameof(PayloadCatalogVerifier.Verify), _savedVerifier.Method.Name); + } + + [Fact] + public void Accepted_payload_lets_the_update_continue() + { + string? checkedDir = null; + SelfUpdater.VerifyPayloadCatalog = dir => { checkedDir = dir; return PayloadCatalogVerdict.Accept(); }; + + var ok = SelfUpdater.VerifyStagedPayload(StagingDir, _log.Add, out var reason); + + Assert.True(ok); + Assert.Null(reason); + Assert.Equal(StagingDir, checkedDir); + Assert.Contains(_log, line => line.Contains("payload catalog verified")); + } + + [Fact] + public void Rejected_payload_stops_the_update_and_hands_over_the_reason() + { + SelfUpdater.VerifyPayloadCatalog = _ => + PayloadCatalogVerdict.Reject("payload differs from catalog (missing: none; extra: evil.dll; changed: none)"); + + var ok = SelfUpdater.VerifyStagedPayload(StagingDir, _log.Add, out var reason); + + Assert.False(ok); + Assert.Contains("extra: evil.dll", reason); + Assert.Contains(_log, line => line.Contains("REJECTED")); + } + + [Fact] + public void A_throwing_verifier_is_a_rejection_not_a_pass() + { + SelfUpdater.VerifyPayloadCatalog = _ => throw new InvalidOperationException("runspace broke"); + + var ok = SelfUpdater.VerifyStagedPayload(StagingDir, _log.Add, out var reason); + + Assert.False(ok); + Assert.Contains("runspace broke", reason); + } + + [Fact] + public void The_catalog_check_runs_before_any_file_is_swapped() + { + // Source-level pin: CheckAndApplyUpdateAsync has no seam for the download, so the + // ordering that makes the check meaningful is pinned on the source itself. + var source = File.ReadAllText(Path.Combine(FindRepoRoot(), + "src", "Agent", "AutopilotMonitor.Agent.V2.Core", "Monitoring", "Runtime", "SelfUpdater.cs")); + + var check = source.IndexOf("if (!VerifyStagedPayload(stagingDir, log, out var catalogRejection))", StringComparison.Ordinal); + var swap = source.IndexOf("var swapOk = SwapFiles(agentDir, stagingDir, log);", StringComparison.Ordinal); + + Assert.True(check > 0, "step 5b not found in SelfUpdater.cs"); + Assert.True(swap > check, "SwapFiles must come after the catalog check"); + Assert.Contains("WriteSkipMarker(\"catalog_rejected\"", source); + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln"))) + { + dir = dir.Parent; + } + Assert.NotNull(dir); + return dir.FullName; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/LifecycleEmittersRemoteConfigTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/LifecycleEmittersRemoteConfigTests.cs index a27b2713..a0fd2946 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/LifecycleEmittersRemoteConfigTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/LifecycleEmittersRemoteConfigTests.cs @@ -44,6 +44,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program Assert.Equal("Succeeded", data["remoteConfigOutcome"]); } + [Fact] + public void EmitAgentStarted_CarriesTheBootstrapProvenance() + { + // Values depend on the machine's %ProgramData%\AutopilotMonitor\Logs (see + // BootstrapProvenanceReaderTests); the two presence flags are always on the wire. + using var rig = new EmitterRig(); + rig.RemoteConfig.SetFakeOutcome(RemoteConfigFetchOutcome.Succeeded, configVersion: 28); + + LifecycleEmitters.EmitAgentStarted( + rig.Post, rig.AgentConfig, rig.PreviousExit, "2.0.806", rig.RemoteConfig, rig.Logger); + + var data = AssertSingleEmit(rig.Sink, expectedEventType: "agent_started"); + Assert.IsType(data["bootstrapScriptLogPresent"]); + Assert.IsType(data["oobeBootstrapLogPresent"]); + } + [Fact] public void EmitAgentStarted_OnFallbackToDefaults_FlagsRemoteConfigFalse() { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/PayloadCatalogVerifierTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/PayloadCatalogVerifierTests.cs new file mode 100644 index 00000000..778820cb --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/PayloadCatalogVerifierTests.cs @@ -0,0 +1,142 @@ +using System.Collections.Generic; +using System.IO; +using AutopilotMonitor.Agent.V2.Core.Security; +using AutopilotMonitor.Shared; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Security +{ + /// + /// The decision over what Test-FileCatalog -Detailed reports. The runspace itself is + /// exercised on a lab device (plan agent-payload-catalog, V1/V3), never in the test host. + /// + public sealed class PayloadCatalogVerifierTests + { + private const string OurSubject = "CN=glueckkanja AG, O=glueckkanja AG, L=Offenbach, S=Hessen, C=DE"; + + private static Dictionary Files(params string[] namesAndHashes) + { + var map = new Dictionary(); + for (var i = 0; i < namesAndHashes.Length; i += 2) map[namesAndHashes[i]] = namesAndHashes[i + 1]; + return map; + } + + private static PayloadCatalogFacts Facts( + string status = "Valid", string? signatureStatus = "Valid", string? subject = OurSubject, + Dictionary? catalog = null, Dictionary? path = null) + => new PayloadCatalogFacts(status, signatureStatus, subject, + catalog ?? Files("a.dll", "AA"), path ?? Files("a.dll", "AA")); + + private static PayloadCatalogVerdict Evaluate(PayloadCatalogFacts facts) + => PayloadCatalogVerifier.Evaluate(facts, Constants.CodeSigningPublisherPattern); + + [Fact] + public void Signed_by_us_and_matching_is_accepted() + { + var verdict = Evaluate(Facts()); + + Assert.True(verdict.Accepted); + Assert.Null(verdict.Reason); + } + + [Fact] + public void Matching_hashes_under_an_unsigned_catalog_are_rejected() + { + // Status covers the hashes only: an unsigned catalog over matching files reports Valid. + var verdict = Evaluate(Facts(signatureStatus: "NotSigned", subject: null)); + + Assert.False(verdict.Accepted); + Assert.Contains("catalog signature status NotSigned", verdict.Reason); + } + + [Fact] + public void A_validly_signed_catalog_from_another_publisher_is_rejected() + { + var verdict = Evaluate(Facts(subject: "CN=Contoso Ltd, O=Contoso Ltd, C=US")); + + Assert.False(verdict.Accepted); + Assert.Contains("does not match", verdict.Reason); + } + + [Fact] + public void Missing_signature_details_are_a_rejection_not_a_pass() + { + Assert.False(Evaluate(Facts(signatureStatus: null, subject: null)).Accepted); + Assert.False(Evaluate(Facts(subject: "")).Accepted); + } + + [Fact] + public void A_differing_payload_names_missing_extra_and_changed_files() + { + var verdict = Evaluate(Facts( + status: "ValidationFailed", + catalog: Files("a.dll", "AA", "b.dll", "BB", "c.config", "CC"), + path: Files("a.dll", "AA", "b.dll", "XX", "evil.dll", "EE"))); + + Assert.False(verdict.Accepted); + Assert.Contains("missing: c.config", verdict.Reason); + Assert.Contains("extra: evil.dll", verdict.Reason); + Assert.Contains("changed: b.dll", verdict.Reason); + } + + [Fact] + public void Long_difference_lists_are_capped() + { + var path = Files("a.dll", "AA"); + for (var i = 0; i < 12; i++) path[$"extra{i:00}.txt"] = "00"; + + var verdict = Evaluate(Facts(status: "ValidationFailed", path: path)); + + Assert.Contains("(+7 more)", verdict.Reason); + } + + [Fact] + public void Signature_and_payload_problems_are_both_reported() + { + var verdict = Evaluate(Facts(status: "ValidationFailed", signatureStatus: "HashMismatch", subject: null, + path: Files("a.dll", "AA", "x.dll", "11"))); + + Assert.Contains("catalog signature status HashMismatch", verdict.Reason); + Assert.Contains("extra: x.dll", verdict.Reason); + } + + [Theory] + [InlineData(OurSubject, true)] + [InlineData("cn=glueckkanja ag, o=glueckkanja ag, c=de", true)] // -like is case-insensitive + [InlineData("CN=x, O=glueckkanja AG Holding, C=DE", true)] // the pattern is open-ended by design + [InlineData("CN=glueckkanja AG, O=Contoso, C=DE", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void Publisher_pattern_behaves_like_powershell_like(string? subject, bool expected) + { + Assert.Equal(expected, PayloadCatalogVerifier.MatchesPublisherPattern(subject, Constants.CodeSigningPublisherPattern)); + } + + [Fact] + public void Regex_metacharacters_in_the_pattern_are_literal() + { + Assert.True(PayloadCatalogVerifier.MatchesPublisherPattern("O=Json.NET (.NET Foundation)", "*O=Json.NET (.NET Foundation)*")); + Assert.False(PayloadCatalogVerifier.MatchesPublisherPattern("O=JsonXNET (.NET Foundation)", "*O=Json.NET (.NET Foundation)*")); + } + + [Fact] + public void A_payload_without_catalog_is_rejected_before_any_runspace_starts() + { + var dir = Path.Combine(Path.GetTempPath(), "apm-catalog-test-" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + File.WriteAllText(Path.Combine(dir, "readme.txt"), "x"); + + var verdict = PayloadCatalogVerifier.Verify(dir); + + Assert.False(verdict.Accepted); + Assert.Contains("missing", verdict.Reason); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/AutopilotMonitor.Agent.V2.Core.csproj b/src/Agent/AutopilotMonitor.Agent.V2.Core/AutopilotMonitor.Agent.V2.Core.csproj index 4243dcd8..b1e7d122 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/AutopilotMonitor.Agent.V2.Core.csproj +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/AutopilotMonitor.Agent.V2.Core.csproj @@ -62,8 +62,15 @@ + $(windir)\Microsoft.NET\assembly\GAC_MSIL\System.Management.Automation\v4.0_3.0.0.0__31bf3856ad364e35\System.Management.Automation.dll + false diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/BootstrapProvenanceReader.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/BootstrapProvenanceReader.cs new file mode 100644 index 00000000..94060c07 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/BootstrapProvenanceReader.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime +{ + /// + /// Which bootstrap installed this agent, read from the bootstrap scripts' own logs next to the + /// agent log. The scripts cannot report it themselves (they run before any agent exists), and + /// passing a version to --install would only ever cover installers released after that + /// change -- the old installers still assigned in Intune are exactly the ones worth counting. + /// + /// bootstrap-script.log: Bootstrap script version: vX.Y, written by every + /// installer run since 2026-04-09. A log without that line means an older installer. + /// bootstrap-loader.log (Intune) / bootstrap-msi.log (MSI): + /// bootstrap loader vX.Y started, written by every loader since v1.0. + /// oobe_bootstrap.log: the OOBE go-script (generated by the backend, no version). + /// + /// Only the version number is extracted; the loader line also names the account, which is + /// never read out. Observational only, and it never throws: an unreadable log reads as absent. + /// + internal static class BootstrapProvenanceReader + { + internal const string InstallerLogName = "bootstrap-script.log"; + internal const string LoaderLogName = "bootstrap-loader.log"; + internal const string MsiLoaderLogName = "bootstrap-msi.log"; + internal const string OobeLogName = "oobe_bootstrap.log"; + + // The logs grow across runs; the last run's lines sit at the end. One run writes a few KB. + private const int TailBytes = 64 * 1024; + + private static readonly Regex InstallerVersionLine = new Regex( + @"Bootstrap script version: v(?\d+(?:\.\d+){0,3})\b", RegexOptions.CultureInvariant); + + private static readonly Regex LoaderStartLine = new Regex( + @"bootstrap loader v(?\d+(?:\.\d+){0,3}) started\b", RegexOptions.CultureInvariant); + + public static BootstrapProvenance Read(string logDirectory) + { + try + { + var installerLog = ReadTail(Path.Combine(logDirectory, InstallerLogName)); + + // Intune loader and MSI loader write separate files; when a device has both, the + // more recently written one belongs to the bootstrap that ran last. + var loader = new[] { (Name: LoaderLogName, Channel: "loader"), (Name: MsiLoaderLogName, Channel: "msi") } + .Select(l => (l.Channel, Path: Path.Combine(logDirectory, l.Name))) + .Where(l => File.Exists(l.Path)) + .OrderByDescending(l => File.GetLastWriteTimeUtc(l.Path)) + .Select(l => (l.Channel, Version: LastVersion(ReadTail(l.Path), LoaderStartLine))) + .FirstOrDefault(); + + return new BootstrapProvenance( + scriptLogPresent: installerLog != null, + scriptVersion: LastVersion(installerLog, InstallerVersionLine), + loaderLog: loader.Channel, + loaderVersion: loader.Version, + oobeLogPresent: File.Exists(Path.Combine(logDirectory, OobeLogName))); + } + catch + { + return BootstrapProvenance.Unknown; + } + } + + internal static string LastVersion(string text, Regex line) + { + if (string.IsNullOrEmpty(text)) return null; + string version = null; + foreach (Match match in line.Matches(text)) + version = match.Groups["v"].Value; + return version; + } + + /// The last of the file, or null when it is missing or unreadable. + private static string ReadTail(string path) + { + try + { + if (!File.Exists(path)) return null; + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) + { + var start = Math.Max(0, stream.Length - TailBytes); + stream.Seek(start, SeekOrigin.Begin); + var buffer = new byte[stream.Length - start]; + var read = 0; + while (read < buffer.Length) + { + var n = stream.Read(buffer, read, buffer.Length - read); + if (n == 0) break; + read += n; + } + // The scripts write ASCII (their own pure-ASCII rule); UTF-8 decodes it either way. + return Encoding.UTF8.GetString(buffer, 0, read); + } + } + catch + { + return null; + } + } + } + + /// What found; null fields were not found. + internal sealed class BootstrapProvenance + { + public static readonly BootstrapProvenance Unknown = new BootstrapProvenance(false, null, null, null, false); + + public BootstrapProvenance(bool scriptLogPresent, string scriptVersion, string loaderLog, string loaderVersion, bool oobeLogPresent) + { + ScriptLogPresent = scriptLogPresent; + ScriptVersion = scriptVersion; + LoaderLog = loaderLog; + LoaderVersion = loaderVersion; + OobeLogPresent = oobeLogPresent; + } + + public bool ScriptLogPresent { get; } + public string ScriptVersion { get; } + public string LoaderLog { get; } + public string LoaderVersion { get; } + public bool OobeLogPresent { get; } + + /// + /// Adds the agent_started keys. Version and loader keys only when found, so a + /// missing key always means "not found" and never an empty string. + /// + public void AddTo(IDictionary data) + { + data["bootstrapScriptLogPresent"] = ScriptLogPresent; + if (ScriptVersion != null) data["bootstrapScriptVersion"] = ScriptVersion; + if (LoaderLog != null) data["bootstrapLoaderLog"] = LoaderLog; + if (LoaderVersion != null) data["bootstrapLoaderVersion"] = LoaderVersion; + data["oobeBootstrapLogPresent"] = OobeLogPresent; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SelfUpdater.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SelfUpdater.cs index af414937..7a909865 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SelfUpdater.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SelfUpdater.cs @@ -139,6 +139,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime /// internal const int GracefulExitFallbackMs = 15000; + /// + /// The payload catalog check of step 5b. Production keeps the default, + /// ; tests swap it so the update path runs + /// without a PowerShell runspace. Same static-hook idiom as . + /// + internal static Func VerifyPayloadCatalog { get; set; } = PayloadCatalogVerifier.Verify; + /// /// Checks for a newer agent version and applies the update if available. /// On success, restarts the process and never returns. @@ -381,6 +388,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime return; } + // Step 5b: The payload must match its signed catalog -- the check the installer and + // the OOBE script run before --install. Covers what the ZIP hash cannot: a hash + // from the download host vouches for nothing if that host is compromised, and + // without any hash there was no check at all. + if (!VerifyStagedPayload(stagingDir, log, out var catalogRejection)) + { + CleanupStaging(stagingDir, zipPath); + if (writeMarkers) + WriteSkipMarker("catalog_rejected", currentVersion, latestVersion, catalogRejection, log); + return; + } + // Step 6: Swap files (rename locked → .old, copy new) var swapTimerSw = Stopwatch.StartNew(); var swapOk = SwapFiles(agentDir, stagingDir, log); @@ -597,6 +616,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime } } + /// + /// Step 5b. Fail-closed: a rejected payload is never swapped in, and the running agent -- + /// verified when it was installed -- stays. A verifier that throws counts as a rejection. + /// + internal static bool VerifyStagedPayload(string stagingDir, Action log, out string rejectionReason) + { + var sw = Stopwatch.StartNew(); + PayloadCatalogVerdict verdict; + try + { + verdict = VerifyPayloadCatalog(stagingDir); + } + catch (Exception ex) + { + verdict = PayloadCatalogVerdict.Reject($"catalog check failed to run ({ex.Message})"); + } + sw.Stop(); + + if (verdict.Accepted) + { + log($"Self-update: payload catalog verified ({sw.ElapsedMilliseconds}ms)"); + rejectionReason = null; + return true; + } + + log($"Self-update: payload catalog REJECTED ({sw.ElapsedMilliseconds}ms) — {verdict.Reason}. Aborting update."); + rejectionReason = verdict.Reason; + return false; + } + /// /// Extracts the ZIP to the staging directory. Returns false on failure. /// diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/PayloadCatalogVerifier.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/PayloadCatalogVerifier.cs new file mode 100644 index 00000000..1ffdef72 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/PayloadCatalogVerifier.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Management.Automation; +using System.Management.Automation.Runspaces; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using AutopilotMonitor.Shared; + +namespace AutopilotMonitor.Agent.V2.Core.Security +{ + /// + /// Verifies an extracted agent payload against its signed catalog + /// () with Windows PowerShell's own + /// Test-FileCatalog, the cmdlet the bootstrap installer and the OOBE script run as well, + /// so all three verifiers share one semantics: the catalog must carry a valid signature from + /// , and every file of the payload must match + /// it, with nothing missing, changed or extra. + /// + /// A reimplementation of the catalog format (WinVerifyTrust plus the CryptCAT APIs) was the + /// alternative and was rejected: a second parser for the same contract would drift from the + /// cmdlet the other two verifiers use. The runspace costs about a second, paid only on the + /// rare update path. + /// + /// + public static class PayloadCatalogVerifier + { + private const int InvokeTimeoutMs = 60000; + private const int MaxNamesPerList = 5; + + /// + /// Checks . Never throws: any failure to run the check is a + /// rejection, because an unverifiable payload must not be installed. + /// + public static PayloadCatalogVerdict Verify(string payloadDir) + { + var catalogPath = Path.Combine(payloadDir, Constants.AgentPayloadCatalogFileName); + if (!File.Exists(catalogPath)) + return PayloadCatalogVerdict.Reject($"catalog {Constants.AgentPayloadCatalogFileName} missing"); + + return RunCatalogCheck(payloadDir, catalogPath); + } + + // Kept out of Verify (and never inlined) so the PowerShell engine is loaded only when a + // catalog is actually checked, not when the method that guards it is compiled. + [MethodImpl(MethodImplOptions.NoInlining)] + private static PayloadCatalogVerdict RunCatalogCheck(string payloadDir, string catalogPath) + { + try + { + using (var runspace = RunspaceFactory.CreateRunspace()) + { + runspace.Open(); + using (var ps = PowerShell.Create()) + { + ps.Runspace = runspace; + ps.AddCommand(@"Microsoft.PowerShell.Security\Test-FileCatalog") + .AddParameter("Path", payloadDir) + .AddParameter("CatalogFilePath", catalogPath) + .AddParameter("FilesToSkip", new[] { Constants.AgentPayloadCatalogFileName }) + .AddParameter("Detailed"); + + var asyncResult = ps.BeginInvoke(); + if (!asyncResult.AsyncWaitHandle.WaitOne(InvokeTimeoutMs)) + { + ps.Stop(); + return PayloadCatalogVerdict.Reject($"catalog check timed out after {InvokeTimeoutMs / 1000}s"); + } + + var results = ps.EndInvoke(asyncResult); + if (ps.HadErrors && ps.Streams.Error.Count > 0) + return PayloadCatalogVerdict.Reject($"catalog check failed to run ({ps.Streams.Error[0]})"); + + var info = results.FirstOrDefault(); + if (info == null) + return PayloadCatalogVerdict.Reject("catalog check returned nothing"); + + var signature = info.Properties["Signature"]?.Value as Signature; + return Evaluate(new PayloadCatalogFacts( + status: info.Properties["Status"]?.Value?.ToString(), + signatureStatus: signature?.Status.ToString(), + signerSubject: signature?.SignerCertificate?.Subject, + catalogItems: ToStringMap(info.Properties["CatalogItems"]?.Value), + pathItems: ToStringMap(info.Properties["PathItems"]?.Value)), + Constants.CodeSigningPublisherPattern); + } + } + } + catch (Exception ex) + { + return PayloadCatalogVerdict.Reject($"catalog check failed to run ({ex.Message})"); + } + } + + /// + /// The decision over the facts Test-FileCatalog -Detailed reports. Separate from the + /// runspace so it is testable without PowerShell. Status covers the hashes only: an + /// unsigned catalog over matching files reports Valid, which is why the signature is + /// judged on its own. + /// + internal static PayloadCatalogVerdict Evaluate(PayloadCatalogFacts facts, string publisherPattern) + { + var reasons = new List(); + + if (!string.Equals(facts.SignatureStatus, "Valid", StringComparison.Ordinal)) + { + reasons.Add($"catalog signature status {facts.SignatureStatus ?? "unknown"}"); + } + else if (!MatchesPublisherPattern(facts.SignerSubject, publisherPattern)) + { + reasons.Add($"catalog publisher '{facts.SignerSubject}' does not match {publisherPattern}"); + } + + if (!string.Equals(facts.Status, "Valid", StringComparison.Ordinal)) + reasons.Add(DescribeDifference(facts.CatalogItems, facts.PathItems)); + + return reasons.Count == 0 + ? PayloadCatalogVerdict.Accept() + : PayloadCatalogVerdict.Reject(string.Join("; ", reasons)); + } + + /// + /// PowerShell's case-insensitive -like for the two wildcards the pin uses (*, + /// ?), without loading the PowerShell engine. Bracket classes are not supported; + /// CodeSigningParityTests pins the pattern's shape. + /// + internal static bool MatchesPublisherPattern(string subject, string pattern) + { + if (string.IsNullOrEmpty(subject) || string.IsNullOrEmpty(pattern)) return false; + var regex = "^" + Regex.Escape(pattern).Replace(@"\*", ".*").Replace(@"\?", ".") + "$"; + return Regex.IsMatch(subject, regex, RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.CultureInvariant); + } + + private static string DescribeDifference( + IReadOnlyDictionary expected, IReadOnlyDictionary actual) + { + var missing = expected.Keys.Where(k => !actual.ContainsKey(k)); + var extra = actual.Keys.Where(k => !expected.ContainsKey(k)); + var changed = expected.Keys.Where(k => actual.TryGetValue(k, out var hash) + && !string.Equals(hash, expected[k], StringComparison.OrdinalIgnoreCase)); + return $"payload differs from catalog (missing: {Cap(missing)}; extra: {Cap(extra)}; changed: {Cap(changed)})"; + } + + private static string Cap(IEnumerable names) + { + var list = names.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList(); + if (list.Count == 0) return "none"; + var shown = string.Join(", ", list.Take(MaxNamesPerList)); + return list.Count > MaxNamesPerList ? $"{shown} (+{list.Count - MaxNamesPerList} more)" : shown; + } + + private static IReadOnlyDictionary ToStringMap(object value) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (value is IDictionary dictionary) + { + foreach (DictionaryEntry entry in dictionary) + map[Convert.ToString(entry.Key)] = Convert.ToString(entry.Value); + } + return map; + } + } + + /// What Test-FileCatalog -Detailed reported, reduced to the fields the decision needs. + internal sealed class PayloadCatalogFacts + { + public PayloadCatalogFacts( + string status, string signatureStatus, string signerSubject, + IReadOnlyDictionary catalogItems, IReadOnlyDictionary pathItems) + { + Status = status; + SignatureStatus = signatureStatus; + SignerSubject = signerSubject; + CatalogItems = catalogItems ?? new Dictionary(); + PathItems = pathItems ?? new Dictionary(); + } + + public string Status { get; } + public string SignatureStatus { get; } + public string SignerSubject { get; } + public IReadOnlyDictionary CatalogItems { get; } + public IReadOnlyDictionary PathItems { get; } + } + + /// Outcome of a payload catalog check; is null when accepted. + public sealed class PayloadCatalogVerdict + { + private PayloadCatalogVerdict(bool accepted, string reason) + { + Accepted = accepted; + Reason = reason; + } + + public bool Accepted { get; } + public string Reason { get; } + + public static PayloadCatalogVerdict Accept() => new PayloadCatalogVerdict(true, null); + + public static PayloadCatalogVerdict Reject(string reason) => new PayloadCatalogVerdict(false, reason); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj b/src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj index 28fdd208..1ffe4ced 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj +++ b/src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj @@ -33,6 +33,13 @@ $(Version) true + + + .pdb diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs index d36ae1f9..978bcc62 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs @@ -92,6 +92,13 @@ namespace AutopilotMonitor.Agent.V2.Runtime if (recovered) data["remoteConfigRecovered"] = true; + // Which bootstrap installed this agent (installer and loader versions, MSI or + // Intune loader, OOBE go-script), read from the scripts' own logs: makes the + // fleet's share of old or directly assigned installers countable. + BootstrapProvenanceReader + .Read(Environment.ExpandEnvironmentVariables(SharedConstants.LogDirectory)) + .AddTo(data); + if (!string.IsNullOrEmpty(previousExit?.CrashExceptionType)) data["previousCrashException"] = previousExit.CrashExceptionType; diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/CodeSigningParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/CodeSigningParityTests.cs new file mode 100644 index 00000000..464fc298 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/CodeSigningParityTests.cs @@ -0,0 +1,62 @@ +using System.Text.RegularExpressions; +using AutopilotMonitor.Shared; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// The publisher pin and the payload catalog name live in places that cannot share code: the C# +/// constants (OOBE bootstrap template, agent self-updater), the bootstrap installer, the frozen +/// loader and the release workflow. A drift would make one verifier refuse what the others +/// accept, or the pipeline publish a catalog no device looks for, so each literal is pinned +/// against its constant here. +/// +public class CodeSigningParityTests +{ + private static readonly Regex ExpectedPublisherLine = new( + "^\\$ExpectedPublisher = \"(?[^\"]+)\"\\r?$", RegexOptions.Multiline); + + [Theory] + [InlineData("Install-AutopilotMonitor.ps1")] + [InlineData("Start-AutopilotMonitor.ps1")] + public void Bootstrap_script_pin_matches_the_constant(string scriptName) + { + var source = File.ReadAllText(Path.Combine(FindRepoRoot(), "scripts", "Bootstrap", scriptName)); + + var matches = ExpectedPublisherLine.Matches(source); + + Assert.Single(matches); + Assert.Equal(Constants.CodeSigningPublisherPattern, matches[0].Groups["pin"].Value); + } + + [Fact] + public void Pin_is_a_subject_pattern_not_a_thumbprint() + { + Assert.StartsWith("*O=", Constants.CodeSigningPublisherPattern); + Assert.EndsWith("*", Constants.CodeSigningPublisherPattern); + Assert.DoesNotMatch("^[0-9A-Fa-f]{40}$", Constants.CodeSigningPublisherPattern.Trim('*')); + // The agent's matcher implements -like for * and ? only (PayloadCatalogVerifier). + Assert.DoesNotContain("[", Constants.CodeSigningPublisherPattern); + } + + [Fact] + public void Release_workflow_catalog_name_matches_the_constant() + { + var workflow = File.ReadAllText(Path.Combine(FindRepoRoot(), ".github", "workflows", "build-agent.yml")); + + var match = Regex.Match(workflow, "^\\s+PAYLOAD_CATALOG: (?\\S+)\\r?$", RegexOptions.Multiline); + + Assert.True(match.Success, "build-agent.yml no longer declares PAYLOAD_CATALOG"); + Assert.Equal(Constants.AgentPayloadCatalogFileName, match.Groups["name"].Value); + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln"))) + { + dir = dir.Parent; + } + Assert.NotNull(dir); + return dir!.FullName; + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 947e13e2..5365eef0 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -309,6 +309,22 @@ namespace AutopilotMonitor.Shared /// public const string AgentDirectory = @"%ProgramData%\AutopilotMonitor\Agent"; + /// + /// Publisher pin for everything the release pipeline signs: a -like pattern on the + /// Authenticode signer's subject, never a thumbprint (certificates are renewed, deployed + /// verifiers are not). The bootstrap loader and installer carry the same literal as + /// $ExpectedPublisher; a parity test keeps all three in step. + /// + public const string CodeSigningPublisherPattern = "*O=glueckkanja AG*"; + + /// + /// Signed catalog at the root of every agent ZIP, holding the SHA-256 of every other file + /// in the payload. Installer, OOBE bootstrap script and self-updater accept a payload only + /// when this catalog is signed by and matches the + /// extracted files exactly: nothing missing, changed or extra. + /// + public const string AgentPayloadCatalogFileName = "AutopilotMonitor.Agent.cat"; + // ----------------------------------------------------------------------- // Upload / batching defaults // -----------------------------------------------------------------------