mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+46
-7
@@ -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'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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user