From fd44518e14d42ee11fbfdaf4293f5f9dbf0dd674 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 13 Oct 2026 18:06:41 +0200 Subject: [PATCH] Bootstrap: installer v2.6 and the OOBE go-script verify the payload catalog (D-354, phase B) - Install-AutopilotMonitor.ps1 v2.6: Get-AgentPayloadRejections (Test-FileCatalog -Detailed; signature Valid, signer -like the pin, files exactly as catalogued) and Install-VerifiedAgentPayload (extract into Agent-Install, verify there, then move into the agent folder; a rejection deletes the staging folder, so the next run starts clean). Replaces the name-filtered Get-UnverifiedAgentBinaries; the dead "already installed" branch behind guard 5 is gone. - OOBE go-script: embeds the installer's marked block verbatim (EmbeddedResource) and verifies before --install; pin and catalog name come from the Shared constants; the golden is updated, new tests pin the embedding and the order check-before-install. - build-agent.yml: the release verify step dot-sources the installer and runs its own function on the final ZIP. - Pester: every decision branch, a real unsigned catalog, the staging flow (64/64). - Privacy page: the catalog claim; LAST_UPDATED. Merge only after a catalog ZIP is live on stable (plan step V0): pushing this to main publishes the installer to every loader on its next run. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build-agent.yml | 42 +-- .../Install-AutopilotMonitor.Tests.ps1 | 204 ++++++++--- .../Bootstrap/Install-AutopilotMonitor.ps1 | 346 ++++++++++-------- .../CodeSigningParityTests.cs | 11 + .../OobeBootstrapScriptGeneratorTests.cs | 67 +++- .../TestData/oobe-bootstrap-golden.ps1 | 112 +++++- .../AutopilotMonitor.Functions.csproj | 2 + .../Bootstrap/OobeBootstrapScriptGenerator.cs | 53 ++- .../app/privacy/page.tsx | 4 +- 9 files changed, 596 insertions(+), 245 deletions(-) diff --git a/.github/workflows/build-agent.yml b/.github/workflows/build-agent.yml index 3ae28d3e..5d0b961f 100644 --- a/.github/workflows/build-agent.yml +++ b/.github/workflows/build-agent.yml @@ -615,8 +615,8 @@ jobs: # 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. + # Windows PowerShell 5.1 and the installer's own functions (dot-sourced; its entry guard + # keeps that side-effect free) -- the very code the installer and the OOBE go-script run. - name: Verify payload catalog (device check) shell: powershell env: @@ -629,32 +629,22 @@ jobs: 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'." + . (Resolve-Path $env:BOOTSTRAP_SOURCE).Path + if (-not [string]::IsNullOrWhiteSpace($env:CODE_SIGN_EXPECTED_SUBJECT) -and $ExpectedPublisher -ne $env:CODE_SIGN_EXPECTED_SUBJECT) { + throw "Installer pin '$ExpectedPublisher' differs from CODE_SIGN_EXPECTED_SUBJECT '$env:CODE_SIGN_EXPECTED_SUBJECT'." + } + if ($AgentCatalogName -ne $env:PAYLOAD_CATALOG) { + throw "Installer catalog name '$AgentCatalogName' differs from PAYLOAD_CATALOG '$env:PAYLOAD_CATALOG'." } - $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 + # Assign first, then flatten: the function returns its list as ONE object (comma + # operator), so @(Get-AgentPayloadRejections ...) would nest it inside another array. + $problems = Get-AgentPayloadRejections -Path $payload -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName + $problems = @($problems) - $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'" - } + # Release-only checks a device need not repeat: a timestamp (without it the signature + # dies with the certificate) and the same certificate as the binaries. + $signature = Get-AuthenticodeSignature (Join-Path $payload $AgentCatalogName) if (-not $signature.TimeStamperCertificate) { $problems += 'catalog signature carries no RFC-3161 timestamp -- it would expire with the certificate' } @@ -667,7 +657,7 @@ jobs: $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)" + Write-Host "Payload catalog verified on the final ZIP with the installer's own check, signer $($signature.SignerCertificate.Subject)" - name: Upload to Azure Blob Storage if: inputs.upload_blob diff --git a/scripts/Bootstrap/Install-AutopilotMonitor.Tests.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor.Tests.ps1 index a4bb3db9..881afc1a 100644 --- a/scripts/Bootstrap/Install-AutopilotMonitor.Tests.ps1 +++ b/scripts/Bootstrap/Install-AutopilotMonitor.Tests.ps1 @@ -30,6 +30,17 @@ BeforeAll { return $dir } + # What Test-FileCatalog -Detailed returns, reduced to the fields the check reads. + function New-FakeCatalogResult { + param( + [string]$Status = 'Valid', + [object]$Signature, + [hashtable]$CatalogItems = @{ 'AutopilotMonitor.Agent.exe' = 'AA' }, + [hashtable]$PathItems = @{ 'AutopilotMonitor.Agent.exe' = 'AA' } + ) + return [pscustomobject]@{ Status = $Status; Signature = $Signature; CatalogItems = $CatalogItems; PathItems = $PathItems } + } + function New-FakeProfile { param([string]$Name, [double]$AgeMinutes) $p = Join-Path $TestDrive $Name @@ -322,86 +333,163 @@ Describe 'logging purity' { } } -Describe 'Get-UnverifiedAgentBinaries' { - # The publisher gate on the extracted payload. What matters is the DECISION: which - # payload is accepted, which is refused, and which files are looked at at all. +Describe 'Get-AgentPayloadRejections' { + # The payload catalog check, shared verbatim with the OOBE go-script. What matters is the + # DECISION: which payload is accepted and which is refused. Test-FileCatalog is mocked for + # the branches; one test runs the real cmdlet over a real (unsigned) catalog. BeforeEach { Mock Write-Log { } $script:ours = 'CN=glueckkanja AG, O=glueckkanja AG, C=DE' + $script:dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.cat') } - It 'accepts a payload where every binary of ours is validly signed by us' { - $dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Shared.dll') - Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject $script:ours } - Get-UnverifiedAgentBinaries -Path $dir | Should -BeNullOrEmpty + It 'accepts a payload whose catalog is signed by us and matches every file' { + Mock Test-FileCatalog { New-FakeCatalogResult -Signature (New-FakeSignature -Status 'Valid' -Subject $script:ours) } + Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher | Should -BeNullOrEmpty } - It 'checks the binaries we author and leaves third-party assemblies alone' { - $dir = New-FakePayload -Files @( - 'AutopilotMonitor.Agent.exe', - 'AutopilotMonitor.Agent.exe.config', - 'AutopilotMonitor.Shared.dll', - 'Newtonsoft.Json.dll', - 'System.Management.Automation.dll' - ) - $script:checked = @() - Mock Get-AuthenticodeSignature { - $script:checked += (Split-Path $FilePath -Leaf) - New-FakeSignature -Status 'Valid' -Subject $script:ours + It 'asks for the detailed result and skips the catalog itself' { + Mock Test-FileCatalog { + $script:call = @{ Skip = $FilesToSkip; Detailed = [bool]$Detailed; Catalog = $CatalogFilePath; Path = $Path } + New-FakeCatalogResult -Signature (New-FakeSignature -Status 'Valid' -Subject $script:ours) } - - Get-UnverifiedAgentBinaries -Path $dir | Should -BeNullOrEmpty - $script:checked | Should -Be @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Shared.dll') + Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher | Out-Null + $script:call.Skip | Should -Be @('AutopilotMonitor.Agent.cat') + $script:call.Detailed | Should -BeTrue + $script:call.Catalog | Should -Be (Join-Path $script:dir 'AutopilotMonitor.Agent.cat') + $script:call.Path | Should -Be $script:dir } - It 'refuses an unsigned agent executable' { + It 'refuses a payload without catalog and does not run the check at all' { $dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe') - Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'NotSigned' -Subject $null } - (Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1 + Mock Test-FileCatalog { throw 'must not run' } + $r = Get-AgentPayloadRejections -Path $dir -ExpectedPublisher $ExpectedPublisher + $r.Count | Should -Be 1 + $r[0] | Should -BeLike '*AutopilotMonitor.Agent.cat missing*' + Should -Invoke Test-FileCatalog -Times 0 } - It 'refuses a tampered binary (hash mismatch)' { - $dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe') - Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'HashMismatch' -Subject $script:ours } - (Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1 + It 'refuses matching files under an unsigned catalog (Status alone is not the verdict)' { + Mock Test-FileCatalog { New-FakeCatalogResult -Status 'Valid' -Signature (New-FakeSignature -Status 'NotSigned' -Subject $null) } + $r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher + $r.Count | Should -Be 1 + $r[0] | Should -BeLike '*signature status NotSigned*' } - It 'refuses a signature whose chain does not validate' { - $dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe') - Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'UnknownError' -Subject $script:ours } - (Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1 + It 'refuses a catalog whose signature does not validate' { + Mock Test-FileCatalog { New-FakeCatalogResult -Signature (New-FakeSignature -Status 'HashMismatch' -Subject $script:ours) } + (Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher).Count | Should -Be 1 } - It 'refuses a validly signed binary from a different publisher' { - $dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe') - Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US' } - (Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1 + It 'refuses a validly signed catalog from another publisher' { + Mock Test-FileCatalog { New-FakeCatalogResult -Signature (New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US') } + $r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher + $r.Count | Should -Be 1 + $r[0] | Should -BeLike '*does not match*' } - It 'refuses the whole payload when a single DLL of ours is swapped' { - $dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Shared.dll') - Mock Get-AuthenticodeSignature { - if ((Split-Path $FilePath -Leaf) -eq 'AutopilotMonitor.Shared.dll') { - New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US' - } else { - New-FakeSignature -Status 'Valid' -Subject $script:ours - } + It 'names the missing, extra and changed files of a payload that differs' { + Mock Test-FileCatalog { + New-FakeCatalogResult -Status 'ValidationFailed' -Signature (New-FakeSignature -Status 'Valid' -Subject $script:ours) ` + -CatalogItems @{ 'a.dll' = 'AA'; 'b.dll' = 'BB'; 'c.config' = 'CC' } ` + -PathItems @{ 'a.dll' = 'AA'; 'b.dll' = 'XX'; 'evil.dll' = 'EE' } } - $rejected = Get-UnverifiedAgentBinaries -Path $dir - $rejected.Count | Should -Be 1 - $rejected[0] | Should -BeLike 'AutopilotMonitor.Shared.dll*' + $r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher + $r.Count | Should -Be 1 + $r[0] | Should -BeLike '*missing: c.config;*' + $r[0] | Should -BeLike '*extra: evil.dll;*' + $r[0] | Should -BeLike '*changed: b.dll)*' } - It 'refuses when the signature check itself throws' { - $dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe') - Mock Get-AuthenticodeSignature { throw 'access denied' } - (Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1 + It 'reports a bad signature and a differing payload together' { + Mock Test-FileCatalog { + New-FakeCatalogResult -Status 'ValidationFailed' -Signature (New-FakeSignature -Status 'NotSigned' -Subject $null) ` + -PathItems @{ 'AutopilotMonitor.Agent.exe' = 'AA'; 'extra.dll' = 'EE' } + } + (Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher).Count | Should -Be 2 } - It 'refuses an empty payload instead of passing it' { - $dir = New-FakePayload -Files @('Newtonsoft.Json.dll') - Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject $script:ours } - (Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1 + It 'refuses when the catalog check itself throws' { + Mock Test-FileCatalog { throw 'access denied' } + $r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher + $r.Count | Should -Be 1 + $r[0] | Should -BeLike '*failed to run*access denied*' + } + + It 'refuses when the catalog check returns nothing' { + Mock Test-FileCatalog { } + $r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher + $r[0] | Should -BeLike '*returned nothing*' + } + + It 'returns a single rejection as an array' { + Mock Test-FileCatalog { throw 'boom' } + $r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher + ($r -is [array]) | Should -BeTrue + } + + It 'catches an extra file with the real cmdlet over a real catalog' { + # Real New-FileCatalog/Test-FileCatalog over text files: no PE, no process, no mock. + $dir = New-FakePayload -Files @('a.txt', 'b.txt') + $catalog = Join-Path $TestDrive "real-$([guid]::NewGuid()).cat" + New-FileCatalog -Path $dir -CatalogFilePath $catalog -CatalogVersion 2.0 | Out-Null + Copy-Item $catalog (Join-Path $dir 'AutopilotMonitor.Agent.cat') + Set-Content -Path (Join-Path $dir 'extra.txt') -Value 'planted' + + $r = Get-AgentPayloadRejections -Path $dir -ExpectedPublisher $ExpectedPublisher + + ($r -join ' | ') | Should -BeLike '*extra: extra.txt;*' + ($r -join ' | ') | Should -BeLike '*signature status NotSigned*' + } +} + +Describe 'Install-VerifiedAgentPayload' { + BeforeEach { + Mock Write-Log { } + $src = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.cat', 'Newtonsoft.Json.dll') + $script:zip = Join-Path $TestDrive "payload-$([guid]::NewGuid()).zip" + Compress-Archive -Path (Join-Path $src '*') -DestinationPath $script:zip + $script:staging = Join-Path $TestDrive "staging-$([guid]::NewGuid())" + $script:target = Join-Path $TestDrive "agent-$([guid]::NewGuid())" + } + + It 'moves an accepted payload into the agent folder and removes the staging folder' { + Mock Get-AgentPayloadRejections { , @() } + Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher + @(Get-ChildItem $script:target | ForEach-Object Name | Sort-Object) | + Should -Be @('AutopilotMonitor.Agent.cat', 'AutopilotMonitor.Agent.exe', 'Newtonsoft.Json.dll') + Test-Path $script:staging | Should -BeFalse + } + + It 'verifies the staging folder, never the agent folder' { + Mock Get-AgentPayloadRejections { $script:checked = $Path; , @() } + Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher + $script:checked | Should -Be $script:staging + } + + It 'leaves the agent folder untouched when the payload is refused, and cleans up' { + Mock Get-AgentPayloadRejections { , @('payload differs from catalog (missing: none; extra: evil.dll; changed: none)') } + { Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher } | + Should -Throw '*Payload verification FAILED*evil.dll*' + Test-Path $script:target | Should -BeFalse + Test-Path $script:staging | Should -BeFalse + } + + It 'removes leftovers from the agent folder before the payload moves in' { + New-Item -ItemType Directory -Path $script:target | Out-Null + Set-Content -Path (Join-Path $script:target 'stale.dll') -Value 'x' + Mock Get-AgentPayloadRejections { , @() } + Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher + Test-Path (Join-Path $script:target 'stale.dll') | Should -BeFalse + Test-Path (Join-Path $script:target 'AutopilotMonitor.Agent.exe') | Should -BeTrue + } + + It 'starts from an empty staging folder even when a previous run left one behind' { + New-Item -ItemType Directory -Path $script:staging | Out-Null + Set-Content -Path (Join-Path $script:staging 'leftover.dll') -Value 'x' + Mock Get-AgentPayloadRejections { $script:seen = @(Get-ChildItem $Path | ForEach-Object Name); , @() } + Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher + $script:seen | Should -Not -Contain 'leftover.dll' } } @@ -410,4 +498,8 @@ Describe 'publisher pin' { $ExpectedPublisher | Should -Be '*O=glueckkanja AG*' $ExpectedPublisher | Should -Not -Match '^[0-9A-Fa-f]{40}$' } + + It 'names the payload catalog the release pipeline signs' { + $AgentCatalogName | Should -Be 'AutopilotMonitor.Agent.cat' + } } diff --git a/scripts/Bootstrap/Install-AutopilotMonitor.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor.ps1 index d96f3113..caa2123f 100644 --- a/scripts/Bootstrap/Install-AutopilotMonitor.ps1 +++ b/scripts/Bootstrap/Install-AutopilotMonitor.ps1 @@ -11,10 +11,12 @@ freshly provisioned Windows 365 Cloud PC. 2. Downloads the monitoring agent ZIP from Azure Blob Storage 3. Verifies integrity via SHA-256 hash from the version manifest (mandatory) - 4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent - 5. Verifies the Authenticode signature and publisher of every binary we author - 6. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime) - 7. Verifies the runtime process actually launched + 4. Extracts the agent into a fresh staging folder + 5. Verifies the payload against its signed catalog (AutopilotMonitor.Agent.cat): + signed by the expected publisher, every file present and unchanged, nothing extra + 6. Moves the verified payload into %ProgramData%\AutopilotMonitor\Agent + 7. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime) + 8. Verifies the runtime process actually launched Agent self-destructs when enrollment completes. Relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser) @@ -77,6 +79,14 @@ PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars. .CHANGELOG + 2026-10-13 v2.6 Payload catalog: the agent ZIP carries AutopilotMonitor.Agent.cat, + a catalog of every payload file signed by the expected publisher. + The ZIP is extracted into a staging folder and moved into the + agent folder only when Test-FileCatalog finds the catalog signed + by the expected publisher and every file present, unchanged and + nothing extra. A rejected payload is deleted, so the next run + starts clean. Replaces the v2.5 gate, which covered only the + AutopilotMonitor binaries. 2026-09-08 v2.5 Publisher gate: every AutopilotMonitor binary in the extracted payload must carry a valid Authenticode signature from the expected publisher before --install runs. The SHA-256 manifest @@ -134,16 +144,20 @@ param( ) # Script version (bump on meaningful changes; see .CHANGELOG above) -$ScriptVersion = "2.5" +$ScriptVersion = "2.6" -# The publisher every binary we author must carry. A -like pattern on the subject, NOT a +# The publisher the payload catalog must carry. A -like pattern on the subject, NOT a # thumbprint: certificates are renewed, deployed copies of this script are not. Same value # the loader pins for this script. $ExpectedPublisher = "*O=glueckkanja AG*" +# Signed catalog at the root of every agent ZIP: the SHA-256 of every other payload file. +$AgentCatalogName = "AutopilotMonitor.Agent.cat" + # Configuration - Everything in ProgramData for easy cleanup $AgentBasePath = "$env:ProgramData\AutopilotMonitor" $AgentBinPath = "$AgentBasePath\Agent" +$AgentStagingPath = "$AgentBasePath\Agent-Install" $AgentLogPath = "$AgentBasePath\Logs" $LogFile = "$AgentLogPath\bootstrap-script.log" @@ -340,56 +354,107 @@ function Get-BootstrapDecision { return [pscustomobject]@{ Install = $true; ReasonCode = $null; RelaxActive = $relax.Active } } -# Returns the list of reasons why the extracted payload is not ours; an empty result means -# every binary we author carries a valid Authenticode signature from the expected publisher. -# Third-party assemblies keep their own publisher's signature and are not checked here. -# Any error is a rejection: an unreadable signature is an unverified binary. -function Get-UnverifiedAgentBinaries { - param([string]$Path) - - $rejected = @() - $ourBinaries = @( - Get-ChildItem -Path $Path -Filter 'AutopilotMonitor.*' -File -ErrorAction SilentlyContinue | - Where-Object { $_.Extension -in @('.exe', '.dll') } +### BEGIN shared payload verification -- embedded verbatim into the OOBE go-script by +### OobeBootstrapScriptGenerator (D-354). Keep it self-contained: parameters only, no +### script-scope variables, no Write-Log. +# Returns the reasons why the extracted payload in $Path must not run; an empty result means +# it matches its signed catalog exactly. Test-FileCatalog's Status covers the hashes only (an +# unsigned catalog over matching files reports Valid), so the signature and its publisher are +# judged on their own. Any error is a rejection: a payload that cannot be verified is an +# unverified payload. The comma keeps a single rejection an ARRAY: PowerShell unrolls a +# one-element result to a bare string, and the caller reads .Count. +function Get-AgentPayloadRejections { + param( + [string]$Path, + [string]$ExpectedPublisher, + [string]$CatalogName = 'AutopilotMonitor.Agent.cat' ) - # An empty set is a rejection, not a pass: it means the extraction produced nothing we - # recognise, and the alternative would be a gate that silently approves an empty folder. - # The comma keeps a single rejection an ARRAY: PowerShell unrolls a one-element result to - # a bare string, and the caller reads .Count and indexes into it. - if ($ourBinaries.Count -eq 0) { - return , @("no AutopilotMonitor binaries found in $Path") + $catalog = Join-Path $Path $CatalogName + if (-not (Test-Path -LiteralPath $catalog -PathType Leaf)) { + return , @("catalog $CatalogName missing") } - foreach ($binary in $ourBinaries) { - try { - $sig = Get-AuthenticodeSignature -FilePath $binary.FullName -ErrorAction Stop - } - catch { - $rejected += "$($binary.Name): signature check failed to run ($($_.Exception.Message))" - continue - } + try { + $result = Test-FileCatalog -Path $Path -CatalogFilePath $catalog -FilesToSkip $CatalogName -Detailed -ErrorAction Stop + } + catch { + return , @("catalog check failed to run ($($_.Exception.Message))") + } + if ($null -eq $result) { + return , @('catalog check returned nothing') + } - if ($null -eq $sig) { - $rejected += "$($binary.Name): signature check returned nothing" - continue - } + $rejected = @() + $signature = $result.Signature + if ($null -eq $signature -or "$($signature.Status)" -ne 'Valid') { + $status = if ($null -eq $signature) { 'missing' } else { "$($signature.Status)" } + $rejected += "catalog signature status $status" + } + elseif ($null -eq $signature.SignerCertificate -or $signature.SignerCertificate.Subject -notlike $ExpectedPublisher) { + $subject = if ($null -eq $signature.SignerCertificate) { '' } else { $signature.SignerCertificate.Subject } + $rejected += "catalog publisher '$subject' does not match $ExpectedPublisher" + } - $subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '' } - - if ($sig.Status -ne 'Valid') { - $rejected += "$($binary.Name): signature status $($sig.Status)" - continue - } - if ($subject -notlike $ExpectedPublisher) { - $rejected += "$($binary.Name): unexpected publisher $subject" - continue + if ("$($result.Status)" -ne 'Valid') { + $expected = if ($null -eq $result.CatalogItems) { @{} } else { $result.CatalogItems } + $actual = if ($null -eq $result.PathItems) { @{} } else { $result.PathItems } + $names = { + param($list) + if ($list.Count -eq 0) { return 'none' } + $shown = ($list | Select-Object -First 5) -join ', ' + if ($list.Count -gt 5) { $shown += " (+$($list.Count - 5) more)" } + return $shown } + $missing = @($expected.Keys | Where-Object { -not $actual.ContainsKey($_) } | Sort-Object) + $extra = @($actual.Keys | Where-Object { -not $expected.ContainsKey($_) } | Sort-Object) + $changed = @($expected.Keys | Where-Object { $actual.ContainsKey($_) -and $actual[$_] -ne $expected[$_] } | Sort-Object) + $rejected += "payload differs from catalog (missing: $(& $names $missing); extra: $(& $names $extra); changed: $(& $names $changed))" } return , $rejected } +# Extracts the agent ZIP into a fresh staging folder, verifies it there and moves it into the +# agent folder only when it matches its signed catalog. A rejected payload is deleted with the +# staging folder: nothing unverified lands where SYSTEM runs the agent, and the next run starts +# clean instead of finding a half-installed agent. The caller has made sure no agent is +# installed, so anything already in the agent folder is a leftover and goes first. +function Install-VerifiedAgentPayload { + param( + [string]$ZipPath, + [string]$StagingPath, + [string]$TargetPath, + [string]$ExpectedPublisher, + [string]$CatalogName = 'AutopilotMonitor.Agent.cat' + ) + + if (Test-Path -LiteralPath $StagingPath) { + Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction Stop + } + New-Item -Path $StagingPath -ItemType Directory -Force | Out-Null + try { + Expand-Archive -Path $ZipPath -DestinationPath $StagingPath -Force -ErrorAction Stop + + $rejections = Get-AgentPayloadRejections -Path $StagingPath -ExpectedPublisher $ExpectedPublisher -CatalogName $CatalogName + if ($rejections.Count -gt 0) { + throw "Payload verification FAILED ($($rejections -join '; ')). Refusing to install an agent that is not the published one." + } + + if (Test-Path -LiteralPath $TargetPath) { + Get-ChildItem -LiteralPath $TargetPath -Force | Remove-Item -Recurse -Force -ErrorAction Stop + } + else { + New-Item -Path $TargetPath -ItemType Directory -Force | Out-Null + } + Get-ChildItem -LiteralPath $StagingPath -Force | Move-Item -Destination $TargetPath -Force -ErrorAction Stop + } + finally { + Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction SilentlyContinue + } +} +### END shared payload verification + # Main bootstrap flow. Only invoked when the script is run directly (see entry guard # at the bottom); never on dot-source. function Invoke-Bootstrap { @@ -408,117 +473,106 @@ function Invoke-Bootstrap { Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (relaxActive=$($decision.RelaxActive))" - # Download and extract agent binaries + # Download, verify and install the agent binaries. Guard 5 has already returned when an + # agent exe exists, so this always starts without an installed agent. $agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe" + Write-Log "Downloading agent from $AgentDownloadUrl..." - if (Test-Path $agentExePath) { - Write-Log "Agent already installed at $agentExePath" - } - else { - Write-Log "Downloading agent from $AgentDownloadUrl..." + try { + # Derive manifest URL from the agent download URL (same blob container) + $versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName + $expectedSha256 = $null + $manifestAttempts = 3 + $manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment - try { - # Derive manifest URL from the agent download URL (same blob container) - $versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName - $expectedSha256 = $null - $manifestAttempts = 3 - $manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment - - for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) { - try { - Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..." - $versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop - if ($versionJsonResponse.sha256) { - $expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant() - Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))" - } else { - Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified." - } - break # manifest reached (with or without hash); retrying would not help + for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) { + try { + Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..." + $versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop + if ($versionJsonResponse.sha256) { + $expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant() + Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))" + } else { + Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified." } - catch { - Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)" - if ($attempt -lt $manifestAttempts) { - Start-Sleep -Seconds $manifestDelays[$attempt - 1] - } + break # manifest reached (with or without hash); retrying would not help + } + catch { + Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)" + if ($attempt -lt $manifestAttempts) { + Start-Sleep -Seconds $manifestDelays[$attempt - 1] } } - - $zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip" - $maxDownloadAttempts = 3 - $downloadAttempt = 0 - - do { - $downloadAttempt++ - try { - Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}" - $downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew() - Invoke-WebRequest ` - -Uri $AgentDownloadUrl ` - -OutFile $zipPath ` - -UseBasicParsing ` - -TimeoutSec 30 ` - -ErrorAction Stop - $downloadStopwatch.Stop() - $downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1) - Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)" - break - } - catch { - if ($downloadAttempt -ge $maxDownloadAttempts) { - throw - } - - $retryDelaysInSeconds = @(2, 4, 8) - $retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1] - Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..." - Start-Sleep -Seconds $retryDelaySeconds - } - } while ($downloadAttempt -lt $maxDownloadAttempts) - - # SHA-256 from the version manifest is the only accepted integrity proof. - # No hash -> refuse to install an unverified ZIP (SKIP-safe security posture). - if (-not $expectedSha256) { - throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP." - } - $actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() - Write-Log "Validating SHA-256 hash against manifest" - if ($actualSha256 -ne $expectedSha256) { - throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted." - } - Write-Log "SHA-256 integrity check passed" - - Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force - Write-Log "Extracted agent to $AgentBinPath" - - Remove-Item $zipPath -Force -ErrorAction SilentlyContinue - Write-Log "Cleaned up temporary files" - - if (-not (Test-Path $agentExePath)) { - throw "Agent executable not found after extraction at $agentExePath" - } - - Write-Log "Agent installation completed successfully" } - catch { - Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)" - throw - } - } - # Publisher gate. The SHA-256 above proves the ZIP is the one the manifest names, but - # that manifest lives in the same container as the ZIP and cannot outlive a compromise - # of the download host. The expected publisher travels inside THIS script, which the - # loader verified before running it, so the chain from the file an administrator - # assigns to the process that starts here stays publisher-anchored end to end. - # Fail-closed, exactly like the hash check: running unverified code as SYSTEM is the - # risk being removed. - Write-Log "Verifying Authenticode signatures of the agent binaries..." - $unverified = Get-UnverifiedAgentBinaries -Path $AgentBinPath - if ($unverified.Count -gt 0) { - throw "Authenticode verification FAILED ($($unverified -join '; ')). Refusing to run an agent that is not signed by the expected publisher." + $zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip" + $maxDownloadAttempts = 3 + $downloadAttempt = 0 + + do { + $downloadAttempt++ + try { + Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}" + $downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew() + Invoke-WebRequest ` + -Uri $AgentDownloadUrl ` + -OutFile $zipPath ` + -UseBasicParsing ` + -TimeoutSec 30 ` + -ErrorAction Stop + $downloadStopwatch.Stop() + $downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1) + Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)" + break + } + catch { + if ($downloadAttempt -ge $maxDownloadAttempts) { + throw + } + + $retryDelaysInSeconds = @(2, 4, 8) + $retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1] + Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..." + Start-Sleep -Seconds $retryDelaySeconds + } + } while ($downloadAttempt -lt $maxDownloadAttempts) + + # SHA-256 from the version manifest is the only accepted integrity proof. + # No hash -> refuse to install an unverified ZIP (SKIP-safe security posture). + if (-not $expectedSha256) { + throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP." + } + $actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() + Write-Log "Validating SHA-256 hash against manifest" + if ($actualSha256 -ne $expectedSha256) { + throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted." + } + Write-Log "SHA-256 integrity check passed" + + # Payload catalog. The SHA-256 above proves the ZIP is the one the manifest names, but + # that manifest lives in the same container as the ZIP and cannot outlive a compromise + # of the download host. The catalog is signed by the expected publisher, whose pin + # travels inside THIS script -- verified by the loader before it ran -- so the chain + # from the file an administrator assigns to every file the agent runs from stays + # publisher-anchored end to end. Fail-closed like the hash check: running unverified + # code as SYSTEM is the risk being removed. + Write-Log "Verifying the payload against its signed catalog ($AgentCatalogName, publisher pin: $ExpectedPublisher)..." + Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $AgentStagingPath -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName + Write-Log "Payload verified against its signed catalog and installed to $AgentBinPath" + + Remove-Item $zipPath -Force -ErrorAction SilentlyContinue + Write-Log "Cleaned up temporary files" + + if (-not (Test-Path $agentExePath)) { + throw "Agent executable not found after extraction at $agentExePath" + } + + Write-Log "Agent installation completed successfully" + } + catch { + Write-Log "ERROR downloading/verifying agent: $($_.Exception.Message)" + throw } - Write-Log "Authenticode verification passed (publisher pin: $ExpectedPublisher)" Write-Log "Calling agent install mode (--install)..." & $agentExePath --install diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/CodeSigningParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/CodeSigningParityTests.cs index 464fc298..3b2464d8 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/CodeSigningParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/CodeSigningParityTests.cs @@ -38,6 +38,17 @@ public class CodeSigningParityTests Assert.DoesNotContain("[", Constants.CodeSigningPublisherPattern); } + [Fact] + public void Installer_catalog_name_matches_the_constant() + { + var installer = File.ReadAllText(Path.Combine(FindRepoRoot(), "scripts", "Bootstrap", "Install-AutopilotMonitor.ps1")); + + var match = Regex.Match(installer, "^\\$AgentCatalogName = \"(?[^\"]+)\"\\r?$", RegexOptions.Multiline); + + Assert.True(match.Success, "Install-AutopilotMonitor.ps1 no longer declares $AgentCatalogName"); + Assert.Equal(Constants.AgentPayloadCatalogFileName, match.Groups["name"].Value); + } + [Fact] public void Release_workflow_catalog_name_matches_the_constant() { diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/OobeBootstrapScriptGeneratorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/OobeBootstrapScriptGeneratorTests.cs index 34393d87..9cd80abf 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/OobeBootstrapScriptGeneratorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/OobeBootstrapScriptGeneratorTests.cs @@ -1,15 +1,16 @@ using AutopilotMonitor.Functions.Functions.Bootstrap; using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Shared; namespace AutopilotMonitor.Functions.Tests; /// /// Pins the OOBE bootstrap script generator, ported from the portal's -/// app/go/[code]/route.ts. The golden test asserts byte parity (modulo line -/// endings) with the original TypeScript template output — the reference file -/// TestData/oobe-bootstrap-golden.ps1 was captured by evaluating the TS template -/// literal with the same fixed inputs. Ports the behavioral cases of the vitest -/// suite app/go/[code]/__tests__/route.test.ts that concern script generation. +/// app/go/[code]/route.ts. The golden file TestData/oobe-bootstrap-golden.ps1 is the +/// pin of the shipped template (modulo line endings): first captured from the TS +/// template literal, since D-354 extended with the payload catalog check that the +/// generator embeds verbatim from the installer. Ports the behavioral cases of the +/// vitest suite app/go/[code]/__tests__/route.test.ts that concern script generation. /// public class OobeBootstrapScriptGeneratorTests { @@ -72,6 +73,62 @@ public class OobeBootstrapScriptGeneratorTests Assert.Contains("'Bootstrap token expires: 2026-08-01T12:00:00.000Z'", script); } + // ── D-354: the payload is checked against its signed catalog before --install ── + [Fact] + public void SuccessScript_embeds_the_installers_payload_verification_verbatim() + { + var installer = File.ReadAllText(Path.Combine(FindRepoRoot(), "scripts", "Bootstrap", "Install-AutopilotMonitor.ps1")) + .Replace("\r\n", "\n"); + var begin = installer.IndexOf(OobeBootstrapScriptGenerator.SharedBlockBegin, StringComparison.Ordinal); + var end = installer.IndexOf('\n', installer.IndexOf(OobeBootstrapScriptGenerator.SharedBlockEnd, StringComparison.Ordinal)); + var block = installer.Substring(begin, end - begin); + + Assert.Contains("function Get-AgentPayloadRejections", block); + Assert.Contains("function Install-VerifiedAgentPayload", block); + Assert.Contains(block, Generate().Replace("\r\n", "\n")); + } + + [Fact] + public void SuccessScript_verifies_the_payload_before_installing_the_agent() + { + var script = Generate(); + + var verify = script.IndexOf( + "Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $AgentStagingPath -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName", + StringComparison.Ordinal); + var install = script.IndexOf("& $agentExePath --install", StringComparison.Ordinal); + + Assert.True(verify > 0, "the generated script never verifies the payload"); + Assert.True(install > verify, "--install must run after the payload check"); + Assert.DoesNotContain("Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath", script); + } + + [Fact] + public void SuccessScript_takes_pin_and_catalog_name_from_the_constants() + { + var script = Generate(); + + Assert.Contains($"$ExpectedPublisher = '{Constants.CodeSigningPublisherPattern}'", script); + Assert.Contains($"$AgentCatalogName = '{Constants.AgentPayloadCatalogFileName}'", script); + } + + [Fact] + public void SuccessScript_leaves_no_placeholder_unreplaced() + { + Assert.DoesNotMatch("__[A-Z_]+__", Generate()); + } + + 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; + } + // ── Error script: message surfaces via Write-Host, always as a script ── [Fact] public void ErrorScript_contains_the_message() diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1 b/src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1 index 719be5e6..8286c224 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1 +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1 @@ -17,6 +17,10 @@ $AgentBasePath = "$env:ProgramData\AutopilotMonitor" $AgentBinPath = "$AgentBasePath\Agent" $AgentLogPath = "$AgentBasePath\Logs" $LogFile = "$AgentLogPath\oobe_bootstrap.log" +$AgentStagingPath = "$AgentBasePath\Agent-Install" + +$ExpectedPublisher = '*O=glueckkanja AG*' +$AgentCatalogName = 'AutopilotMonitor.Agent.cat' # Create directories New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null @@ -31,6 +35,107 @@ function Write-Log { Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue } +### BEGIN shared payload verification -- embedded verbatim into the OOBE go-script by +### OobeBootstrapScriptGenerator (D-354). Keep it self-contained: parameters only, no +### script-scope variables, no Write-Log. +# Returns the reasons why the extracted payload in $Path must not run; an empty result means +# it matches its signed catalog exactly. Test-FileCatalog's Status covers the hashes only (an +# unsigned catalog over matching files reports Valid), so the signature and its publisher are +# judged on their own. Any error is a rejection: a payload that cannot be verified is an +# unverified payload. The comma keeps a single rejection an ARRAY: PowerShell unrolls a +# one-element result to a bare string, and the caller reads .Count. +function Get-AgentPayloadRejections { + param( + [string]$Path, + [string]$ExpectedPublisher, + [string]$CatalogName = 'AutopilotMonitor.Agent.cat' + ) + + $catalog = Join-Path $Path $CatalogName + if (-not (Test-Path -LiteralPath $catalog -PathType Leaf)) { + return , @("catalog $CatalogName missing") + } + + try { + $result = Test-FileCatalog -Path $Path -CatalogFilePath $catalog -FilesToSkip $CatalogName -Detailed -ErrorAction Stop + } + catch { + return , @("catalog check failed to run ($($_.Exception.Message))") + } + if ($null -eq $result) { + return , @('catalog check returned nothing') + } + + $rejected = @() + $signature = $result.Signature + if ($null -eq $signature -or "$($signature.Status)" -ne 'Valid') { + $status = if ($null -eq $signature) { 'missing' } else { "$($signature.Status)" } + $rejected += "catalog signature status $status" + } + elseif ($null -eq $signature.SignerCertificate -or $signature.SignerCertificate.Subject -notlike $ExpectedPublisher) { + $subject = if ($null -eq $signature.SignerCertificate) { '' } else { $signature.SignerCertificate.Subject } + $rejected += "catalog publisher '$subject' does not match $ExpectedPublisher" + } + + if ("$($result.Status)" -ne 'Valid') { + $expected = if ($null -eq $result.CatalogItems) { @{} } else { $result.CatalogItems } + $actual = if ($null -eq $result.PathItems) { @{} } else { $result.PathItems } + $names = { + param($list) + if ($list.Count -eq 0) { return 'none' } + $shown = ($list | Select-Object -First 5) -join ', ' + if ($list.Count -gt 5) { $shown += " (+$($list.Count - 5) more)" } + return $shown + } + $missing = @($expected.Keys | Where-Object { -not $actual.ContainsKey($_) } | Sort-Object) + $extra = @($actual.Keys | Where-Object { -not $expected.ContainsKey($_) } | Sort-Object) + $changed = @($expected.Keys | Where-Object { $actual.ContainsKey($_) -and $actual[$_] -ne $expected[$_] } | Sort-Object) + $rejected += "payload differs from catalog (missing: $(& $names $missing); extra: $(& $names $extra); changed: $(& $names $changed))" + } + + return , $rejected +} + +# Extracts the agent ZIP into a fresh staging folder, verifies it there and moves it into the +# agent folder only when it matches its signed catalog. A rejected payload is deleted with the +# staging folder: nothing unverified lands where SYSTEM runs the agent, and the next run starts +# clean instead of finding a half-installed agent. The caller has made sure no agent is +# installed, so anything already in the agent folder is a leftover and goes first. +function Install-VerifiedAgentPayload { + param( + [string]$ZipPath, + [string]$StagingPath, + [string]$TargetPath, + [string]$ExpectedPublisher, + [string]$CatalogName = 'AutopilotMonitor.Agent.cat' + ) + + if (Test-Path -LiteralPath $StagingPath) { + Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction Stop + } + New-Item -Path $StagingPath -ItemType Directory -Force | Out-Null + try { + Expand-Archive -Path $ZipPath -DestinationPath $StagingPath -Force -ErrorAction Stop + + $rejections = Get-AgentPayloadRejections -Path $StagingPath -ExpectedPublisher $ExpectedPublisher -CatalogName $CatalogName + if ($rejections.Count -gt 0) { + throw "Payload verification FAILED ($($rejections -join '; ')). Refusing to install an agent that is not the published one." + } + + if (Test-Path -LiteralPath $TargetPath) { + Get-ChildItem -LiteralPath $TargetPath -Force | Remove-Item -Recurse -Force -ErrorAction Stop + } + else { + New-Item -Path $TargetPath -ItemType Directory -Force | Out-Null + } + Get-ChildItem -LiteralPath $StagingPath -Force | Move-Item -Destination $TargetPath -Force -ErrorAction Stop + } + finally { + Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction SilentlyContinue + } +} +### END shared payload verification + try { Write-Log "===== Autopilot Monitor OOBE Bootstrap =====" Write-Log 'Bootstrap token expires: 2026-08-01T12:00:00.000Z' @@ -65,10 +170,11 @@ try { } } while ($downloadAttempt -lt $maxDownloadAttempts) - # Extract - Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force + # Verify against the signed payload catalog before anything lands in the agent folder + Write-Log "Verifying the payload against its signed catalog..." + Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $AgentStagingPath -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName Remove-Item $zipPath -Force -ErrorAction SilentlyContinue - Write-Log "Extracted agent to $AgentBinPath" + Write-Log "Payload verified and installed to $AgentBinPath" if (-not (Test-Path $agentExePath)) { throw "Agent executable not found after extraction at $agentExePath" diff --git a/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj b/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj index 273c9d1f..94261fd1 100644 --- a/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj +++ b/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj @@ -70,6 +70,8 @@ + + diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/OobeBootstrapScriptGenerator.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/OobeBootstrapScriptGenerator.cs index bc675fdf..a528b30a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/OobeBootstrapScriptGenerator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/OobeBootstrapScriptGenerator.cs @@ -34,8 +34,10 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap return $"Write-Host ''; Write-Host 'ERROR: {safeMsg}' -ForegroundColor Red; Write-Host ''"; } - // SECURITY INVARIANT: All template parameters MUST come from - // BootstrapScriptValueValidator.TryValidate. The single-quoted PowerShell + // SECURITY INVARIANT: every template value comes either from + // BootstrapScriptValueValidator.TryValidate (the request-derived ones) or from a + // compile-time constant (portal URL, publisher pin, catalog name, and the installer's + // payload-verification block embedded at build time). The single-quoted PowerShell // string literals below are the second line of defense against injection // (single-quoted PS strings do NOT expand $(...), $var, or backticks). // Do NOT change to double-quoted without re-reviewing the validator's @@ -43,7 +45,8 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap // // The template is a raw string with __TOKEN__ placeholders substituted via // Replace — deliberately NOT string interpolation or string.Format, because - // the script body is full of PowerShell $vars and {braces}. + // the script body is full of PowerShell $vars and {braces}. The embedded block is + // substituted last, so no token inside it could ever be rewritten. public static string GenerateSuccessScript( BootstrapScriptValueValidator.ValidatedValues values, DateTime generatedAtUtc) @@ -61,7 +64,36 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap .Replace("__AGENT_DOWNLOAD_URL__", values.AgentDownloadUrl) .Replace("__BOOTSTRAP_TOKEN__", values.Token) .Replace("__TENANT_ID__", values.TenantId) - .Replace("__PORTAL_URL__", Constants.PortalBaseUrl); + .Replace("__PORTAL_URL__", Constants.PortalBaseUrl) + .Replace("__EXPECTED_PUBLISHER__", Constants.CodeSigningPublisherPattern) + .Replace("__PAYLOAD_CATALOG__", Constants.AgentPayloadCatalogFileName) + .Replace("__PAYLOAD_VERIFICATION__", SharedPayloadVerification.Value); + } + + internal const string InstallerResourceName = "AutopilotMonitor.Functions.Bootstrap.Install-AutopilotMonitor.ps1"; + internal const string SharedBlockBegin = "### BEGIN shared payload verification"; + internal const string SharedBlockEnd = "### END shared payload verification"; + + // The payload check is the installer's own code, embedded at build time and cut out + // between its markers: one text for both install paths, so they can never drift + // (D-354). A missing marker fails the first request loudly instead of serving a + // script that installs without the check. + private static readonly Lazy SharedPayloadVerification = new(LoadSharedPayloadVerification); + + internal static string LoadSharedPayloadVerification() + { + using var stream = typeof(OobeBootstrapScriptGenerator).Assembly.GetManifestResourceStream(InstallerResourceName) + ?? throw new InvalidOperationException($"Embedded resource {InstallerResourceName} is missing."); + using var reader = new StreamReader(stream); + var installer = reader.ReadToEnd().Replace("\r\n", "\n"); + + var begin = installer.IndexOf(SharedBlockBegin, StringComparison.Ordinal); + var end = installer.IndexOf(SharedBlockEnd, StringComparison.Ordinal); + if (begin < 0 || end < begin || installer.IndexOf(SharedBlockBegin, begin + 1, StringComparison.Ordinal) >= 0) + throw new InvalidOperationException("The installer's shared payload-verification markers are missing or ambiguous."); + + var lineEnd = installer.IndexOf('\n', end); + return installer.Substring(begin, (lineEnd < 0 ? installer.Length : lineEnd) - begin); } private const string Template = """ @@ -84,6 +116,10 @@ $AgentBasePath = "$env:ProgramData\AutopilotMonitor" $AgentBinPath = "$AgentBasePath\Agent" $AgentLogPath = "$AgentBasePath\Logs" $LogFile = "$AgentLogPath\oobe_bootstrap.log" +$AgentStagingPath = "$AgentBasePath\Agent-Install" + +$ExpectedPublisher = '__EXPECTED_PUBLISHER__' +$AgentCatalogName = '__PAYLOAD_CATALOG__' # Create directories New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null @@ -98,6 +134,8 @@ function Write-Log { Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue } +__PAYLOAD_VERIFICATION__ + try { Write-Log "===== Autopilot Monitor OOBE Bootstrap =====" Write-Log 'Bootstrap token expires: __EXPIRES_AT__' @@ -132,10 +170,11 @@ try { } } while ($downloadAttempt -lt $maxDownloadAttempts) - # Extract - Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force + # Verify against the signed payload catalog before anything lands in the agent folder + Write-Log "Verifying the payload against its signed catalog..." + Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $AgentStagingPath -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName Remove-Item $zipPath -Force -ErrorAction SilentlyContinue - Write-Log "Extracted agent to $AgentBinPath" + Write-Log "Payload verified and installed to $AgentBinPath" if (-not (Test-Path $agentExePath)) { throw "Agent executable not found after extraction at $agentExePath" diff --git a/src/Web/autopilot-monitor-web/app/privacy/page.tsx b/src/Web/autopilot-monitor-web/app/privacy/page.tsx index 48df2d81..b62ee0a2 100644 --- a/src/Web/autopilot-monitor-web/app/privacy/page.tsx +++ b/src/Web/autopilot-monitor-web/app/privacy/page.tsx @@ -2,7 +2,7 @@ import { LandingNavbar } from "../../components/landing/LandingNavbar"; import { SiteFooter } from "../../components/SiteFooter"; import { DOCS_URL } from "@/lib/config"; -const LAST_UPDATED = "1 October 2026"; +const LAST_UPDATED = "13 October 2026"; const DOCS_SECURITY_FAQ = `${DOCS_URL}/trust/security-faq`; const DOCS_DATA_FLOWS = `${DOCS_URL}/trust/data-flows`; const DOCS_DPA = `${DOCS_URL}/legal/data-privacy-agreement-dpa`; @@ -216,7 +216,7 @@ export default function PrivacyPage() {
  • Encryption — HTTPS with a TLS 1.2 floor in transit, Azure Storage encryption at rest with platform-managed keys
  • Managed identity instead of storage keys, and secret-less OIDC deployment pipelines
  • Rate limiting per device, per portal user, and per MCP user
  • -
  • Verifiable agent binaries — Authenticode-signed scripts and binaries whose publisher is verified on the device before anything runs, Sigstore build attestation, and SHA-256 verification at download, through an independent backend cross-check, and at runtime
  • +
  • Verifiable agent binaries — Authenticode-signed scripts and binaries, third-party libraries only with their vendor's own signature, a catalog signed by glueckkanja AG that lists every file of the agent package and is checked on the device before anything runs, Sigstore build attestation, and SHA-256 verification at download, through an independent backend cross-check, and at runtime
  • Manifest-first deletion — what will be deleted is captured, restorably, before anything is removed