mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user