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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-13 18:06:41 +02:00
parent d95640c597
commit fd44518e14
9 changed files with 596 additions and 245 deletions
+16 -26
View File
@@ -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
@@ -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'
}
}
+200 -146
View File
@@ -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 { '<unsigned>' }
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
@@ -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 = \"(?<name>[^\"]+)\"\\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()
{
@@ -1,15 +1,16 @@
using AutopilotMonitor.Functions.Functions.Bootstrap;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// 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.
/// </summary>
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()
@@ -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"
@@ -70,6 +70,8 @@
<EmbeddedResource Include="..\..\..\rules\dist\gather-rules.json" LogicalName="AutopilotMonitor.Functions.Rules.gather-rules.json" />
<EmbeddedResource Include="..\..\..\rules\dist\analyze-rules.json" LogicalName="AutopilotMonitor.Functions.Rules.analyze-rules.json" />
<EmbeddedResource Include="..\..\..\rules\dist\ime-log-patterns.json" LogicalName="AutopilotMonitor.Functions.Rules.ime-log-patterns.json" />
<!-- The OOBE go-script embeds the installer's payload-verification block verbatim (D-354). -->
<EmbeddedResource Include="..\..\..\scripts\Bootstrap\Install-AutopilotMonitor.ps1" LogicalName="AutopilotMonitor.Functions.Bootstrap.Install-AutopilotMonitor.ps1" />
<!-- CPE mapping seed + community files are loaded from GitHub via reseed, not embedded -->
<!-- Files at rules/dist/cpe-mapping-seed.json and rules/dist/cpe-community-mappings.json -->
</ItemGroup>
@@ -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<string> 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"
@@ -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() {
<li><strong>Encryption</strong> — HTTPS with a TLS 1.2 floor in transit, Azure Storage encryption at rest with platform-managed keys</li>
<li><strong>Managed identity instead of storage keys</strong>, and secret-less OIDC deployment pipelines</li>
<li><strong>Rate limiting</strong> per device, per portal user, and per MCP user</li>
<li><strong>Verifiable agent binaries</strong> — 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</li>
<li><strong>Verifiable agent binaries</strong> — Authenticode-signed scripts and binaries, third-party libraries only with their vendor&apos;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</li>
<li><strong>Manifest-first deletion</strong> — what will be deleted is captured, restorably, before anything is removed</li>
</ul>