mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
40323ca57e
Findings of a Claude security scan against b4af8c7, each measured first, plus
the MSI channel incident a parallel session found.
- Installer 2.8 extracts the agent ZIP with its own containment check
(Expand-AgentPayload): every entry is resolved against the payload folder
before the first write, a rooted or escaping name refuses the package. The
inbox Expand-Archive of Windows PowerShell 5.1 already refuses such entries
(measured), so the finding was not exploitable as reported; the guarantee is
now the script's own, pinned by tests, and Expand-Archive is banned.
- Installer 2.8 also moves PowerShell's own location out of the agent folder
(Set-Location): on the MSI channel the location pointed at the deleted Msi
folder and the agent start failed with error 267 since 2.7 went live. When
the agent program never started, the Agent folder is removed again so a later
run can install; the stderr line is flattened to one line.
- Run folders younger than an hour stay (a loader may still run there); the
loader's log is taken only from a run folder that is ours.
- Loader 1.4 (minimum installer 2.8) checks before every download attempt that
its run folder is still a real folder of ours, opens the download for reading
before the signature and identity checks and holds it until the run ends, so
what it checked is what runs and nothing can replace or delete it meanwhile.
- Agent: the protected folder security names Administrators as owner; a folder
with another owner or a reparse point is not taken as protected, reparse points
are refused, the base is created protected, install mode protects base and
Agent and refuses to install when it cannot; every start re-checks both.
- Bootstrap MSI 1.3.0 installs its loader under Program Files, where a standard
user cannot create the folder first.
- Tests: containment table, hour rule, held handle, owner and junction tests
(ElevatedFact for the owner assignment), MSI location contract; harness
scenarios for an escaping package (installer and OOBE script), the hour rule,
the Program Files layout and an agent that does not start, with the install
stand-in starting a real program from PowerShell's location. OOBE golden
regenerated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
478 lines
25 KiB
PowerShell
478 lines
25 KiB
PowerShell
# Pester tests (Pester 5+) for the stage-1 loader Start-AutopilotMonitor.ps1.
|
|
#
|
|
# The script under test is dot-sourced; its entry guard prevents the loader from
|
|
# executing, so loading it here is side-effect free. Network and signature checks
|
|
# are mocked -- what is verified is the DECISION: what does the loader accept, what
|
|
# does it refuse, how often does it retry, and what does it leave behind.
|
|
#
|
|
# Run under Windows PowerShell 5.1 to match the IME runtime:
|
|
# powershell.exe -NoProfile -Command "& { $c = New-PesterConfiguration; $c.Run.Path = 'scripts\Bootstrap'; $c.Output.Verbosity = 'Detailed'; Invoke-Pester -Configuration $c }"
|
|
#
|
|
# This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
|
|
|
|
BeforeAll {
|
|
. (Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1')
|
|
|
|
function New-FakeSignature {
|
|
param([string]$Status, [string]$Subject)
|
|
$cert = if ($Subject) {
|
|
[pscustomobject]@{ Subject = $Subject; Thumbprint = '0123456789ABCDEF' }
|
|
} else { $null }
|
|
return [pscustomobject]@{ Status = $Status; SignerCertificate = $cert }
|
|
}
|
|
|
|
# Arguments as PowerShell hands them to a script, names marked as names; an array of
|
|
# plain strings would bind by position instead.
|
|
function ConvertTo-ScriptArguments { return , $args }
|
|
|
|
function Get-ScriptAst {
|
|
param([string]$Path)
|
|
$tokens = $null; $errors = $null
|
|
$ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors)
|
|
return [pscustomobject]@{ Ast = $ast; Tokens = $tokens; Errors = $errors }
|
|
}
|
|
|
|
$script:loaderPath = Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1'
|
|
$script:installerPath = Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1'
|
|
}
|
|
|
|
Describe 'Test-BootstrapSignature' {
|
|
BeforeEach {
|
|
Mock Write-Log { }
|
|
}
|
|
|
|
It 'accepts a valid signature from the expected publisher' {
|
|
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject 'CN=glueckkanja AG, O=glueckkanja AG, C=DE' }
|
|
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeTrue
|
|
}
|
|
|
|
# An EV subject is a line of street address and registration numbers; the log gets the CN.
|
|
It 'logs the signer CN, not the whole subject' {
|
|
Mock Get-AuthenticodeSignature {
|
|
New-FakeSignature -Status 'Valid' -Subject 'CN=glueckkanja AG, O=glueckkanja AG, L=Offenbach am Main, C=DE, SERIALNUMBER=HRB 12381'
|
|
}
|
|
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeTrue
|
|
Should -Invoke Write-Log -ParameterFilter { $Message -eq 'Signature status: Valid. Signer: glueckkanja AG' }
|
|
}
|
|
|
|
It 'refuses an unsigned script' {
|
|
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'NotSigned' -Subject $null }
|
|
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse
|
|
}
|
|
|
|
It 'refuses a tampered script (hash mismatch)' {
|
|
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'HashMismatch' -Subject 'CN=glueckkanja AG, O=glueckkanja AG, C=DE' }
|
|
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse
|
|
}
|
|
|
|
It 'refuses a signature whose chain does not validate' {
|
|
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'UnknownError' -Subject 'CN=glueckkanja AG, O=glueckkanja AG, C=DE' }
|
|
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse
|
|
}
|
|
|
|
# The whole point of the pinned publisher: a validly signed script from someone else
|
|
# is exactly what an attacker with any code-signing certificate can produce.
|
|
It 'refuses a valid signature from a different publisher' {
|
|
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }
|
|
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse
|
|
}
|
|
|
|
It 'refuses when the signature check itself fails' {
|
|
Mock Get-AuthenticodeSignature { throw 'file not found' }
|
|
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse
|
|
}
|
|
}
|
|
|
|
Describe 'Get-InstallerIdentity' {
|
|
BeforeAll {
|
|
$script:roleLine = '$ScriptRole = "AutopilotMonitor-Installer"'
|
|
}
|
|
|
|
It 'reads the role and the version of LF text' {
|
|
$id = Get-InstallerIdentity -Text ("param()`n`$ScriptVersion = `"2.7`"`n" + $script:roleLine + "`n")
|
|
$id.Role | Should -BeExactly 'AutopilotMonitor-Installer'
|
|
$id.Version | Should -Be ([version]'2.7')
|
|
}
|
|
|
|
It 'reads CRLF text that ends in an Authenticode signature block' {
|
|
$text = (@('$ScriptVersion = "2.7"', $script:roleLine, '# SIG # Begin signature block', '# MIIFxwYJKoZIhvcNAQcCoIIFuDCCBbQCAQExCzAJBgUrDgMCGgUAMGkGCisGAQQB', '# SIG # End signature block') -join "`r`n") + "`r`n"
|
|
(Get-InstallerIdentity -Text $text).Version | Should -Be ([version]'2.7')
|
|
}
|
|
|
|
It 'compares versions as numbers, so 2.10 is above 2.7' {
|
|
$id = Get-InstallerIdentity -Text ("`$ScriptVersion = `"2.10`"`n" + $script:roleLine + "`n")
|
|
$id.Version -gt [version]'2.7' | Should -BeTrue
|
|
}
|
|
|
|
It 'returns nothing for <Case>' -TestCases @(
|
|
@{ Case = 'a missing role line'; Lines = @('$ScriptVersion = "2.7"') }
|
|
@{ Case = 'a missing version line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"') }
|
|
@{ Case = 'a role line in a comment'; Lines = @('# $ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7"') }
|
|
@{ Case = 'an indented version line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', ' $ScriptVersion = "2.7"') }
|
|
@{ Case = 'a second version line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7"', '$ScriptVersion = "9.9"') }
|
|
@{ Case = 'a second role line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptRole = "Other"', '$ScriptVersion = "2.7"') }
|
|
@{ Case = 'a pre-release version'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7-beta"') }
|
|
@{ Case = 'a version line with more code'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7"; $x = 1') }
|
|
) {
|
|
Get-InstallerIdentity -Text (($Lines -join "`n") + "`n") | Should -BeNullOrEmpty
|
|
}
|
|
|
|
# The pair the loader and the installer in this repository must keep: the published
|
|
# installer has to pass the loader that ships with it.
|
|
It 'accepts the installer in this repository' {
|
|
$id = Get-InstallerIdentity -Text ([System.IO.File]::ReadAllText($script:installerPath))
|
|
$id.Role | Should -BeExactly $ExpectedInstallerRole
|
|
$id.Version -ge $MinimumInstallerVersion | Should -BeTrue
|
|
}
|
|
}
|
|
|
|
Describe 'Test-InstallerIdentity' {
|
|
BeforeEach {
|
|
Mock Write-Log { }
|
|
$script:candidate = Join-Path $TestDrive ("installer-" + [guid]::NewGuid().ToString('N') + '.ps1')
|
|
}
|
|
|
|
It 'accepts version <Version>' -TestCases @(@{ Version = '2.8' }, @{ Version = '2.10' }, @{ Version = '3.0.1' }) {
|
|
[System.IO.File]::WriteAllText($script:candidate, "`$ScriptVersion = `"$Version`"`n`$ScriptRole = `"AutopilotMonitor-Installer`"`n")
|
|
Test-InstallerIdentity -Path $script:candidate | Should -BeTrue
|
|
}
|
|
|
|
# Every installer the publisher ever signed keeps a valid signature; the minimum keeps the
|
|
# older ones from running.
|
|
It 'refuses an installer below the minimum version' {
|
|
[System.IO.File]::WriteAllText($script:candidate, "`$ScriptVersion = `"2.7`"`n`$ScriptRole = `"AutopilotMonitor-Installer`"`n")
|
|
Test-InstallerIdentity -Path $script:candidate | Should -BeFalse
|
|
Should -Invoke Write-Log -ParameterFilter { $Message -like '*below the minimum 2.8*' }
|
|
}
|
|
|
|
# The publisher signs other scripts too.
|
|
It 'refuses another role' {
|
|
[System.IO.File]::WriteAllText($script:candidate, "`$ScriptVersion = `"2.8`"`n`$ScriptRole = `"AutopilotMonitor-Uninstaller`"`n")
|
|
Test-InstallerIdentity -Path $script:candidate | Should -BeFalse
|
|
}
|
|
|
|
It 'refuses a script without the identity lines' {
|
|
[System.IO.File]::WriteAllText($script:candidate, "Write-Host 'signed, but not the installer'`n")
|
|
Test-InstallerIdentity -Path $script:candidate | Should -BeFalse
|
|
}
|
|
|
|
It 'refuses a file it cannot read' {
|
|
Test-InstallerIdentity -Path (Join-Path $TestDrive 'missing.ps1') | Should -BeFalse
|
|
}
|
|
}
|
|
|
|
Describe 'Get-VerifiedBootstrapScript' {
|
|
BeforeEach {
|
|
Mock Write-Log { }
|
|
Mock Start-Sleep { }
|
|
Mock Invoke-WebRequest { [System.IO.File]::WriteAllText($OutFile, 'downloaded') }
|
|
Mock Test-InstallerIdentity { $true }
|
|
# The test account owns the folder; the loader asks for SYSTEM or Administrators.
|
|
Mock Test-IsOwnedByUs { $true }
|
|
$script:run = Join-Path $TestDrive ('run-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
|
New-Item -ItemType Directory -Path $script:run | Out-Null
|
|
$script:dest = Join-Path $script:run 'Install-AutopilotMonitor.ps1'
|
|
$RunState = [pscustomobject]@{ LogFile = $null; Installer = $null }
|
|
}
|
|
AfterEach {
|
|
if ($RunState.Installer) { $RunState.Installer.Dispose() }
|
|
}
|
|
|
|
It 'returns after the first attempt when the download verifies' {
|
|
Mock Test-BootstrapSignature { $true }
|
|
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeTrue
|
|
Should -Invoke Invoke-WebRequest -Times 1 -Exactly
|
|
}
|
|
|
|
It 'gives up after three attempts when verification keeps failing' {
|
|
Mock Test-BootstrapSignature { $false }
|
|
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
|
|
Should -Invoke Invoke-WebRequest -Times 3 -Exactly
|
|
}
|
|
|
|
# A truncated or proxy-mangled body fails verification, so the retry has to cover the
|
|
# download AND the check, not just the transport.
|
|
It 'retries a failed verification and accepts a later attempt' {
|
|
$script:calls = 0
|
|
Mock Test-BootstrapSignature { $script:calls++; return ($script:calls -ge 2) }
|
|
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeTrue
|
|
Should -Invoke Invoke-WebRequest -Times 2 -Exactly
|
|
}
|
|
|
|
It 'retries a transport failure' {
|
|
$script:downloads = 0
|
|
Mock Invoke-WebRequest { $script:downloads++; if ($script:downloads -eq 1) { throw 'connection reset' }; [System.IO.File]::WriteAllText($OutFile, 'downloaded') }
|
|
Mock Test-BootstrapSignature { $true }
|
|
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeTrue
|
|
Should -Invoke Invoke-WebRequest -Times 2 -Exactly
|
|
}
|
|
|
|
It 'refuses a validly signed script that is not the current installer' {
|
|
Mock Test-BootstrapSignature { $true }
|
|
Mock Test-InstallerIdentity { $false }
|
|
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
|
|
Should -Invoke Invoke-WebRequest -Times 3 -Exactly
|
|
}
|
|
|
|
# The identity lines count only once the signature proves they are the publisher's.
|
|
It 'reads the identity only after the signature passed' {
|
|
Mock Test-BootstrapSignature { $false }
|
|
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
|
|
Should -Invoke Test-InstallerIdentity -Times 0 -Exactly
|
|
}
|
|
|
|
It 'stops without downloading when the run folder is no longer a folder of ours' {
|
|
Mock Test-IsOwnedByUs { $false }
|
|
Mock Test-BootstrapSignature { $true }
|
|
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
|
|
Should -Invoke Invoke-WebRequest -Times 0 -Exactly
|
|
}
|
|
|
|
# The file that was checked is the file that runs: nobody can replace it in between.
|
|
It 'keeps the verified file open, so it cannot be replaced or deleted' {
|
|
Mock Test-BootstrapSignature { $true }
|
|
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeTrue
|
|
$RunState.Installer | Should -Not -BeNullOrEmpty
|
|
{ [System.IO.File]::WriteAllText($script:dest, 'replaced') } | Should -Throw
|
|
{ [System.IO.File]::Delete($script:dest) } | Should -Throw
|
|
[System.IO.File]::ReadAllText($script:dest) | Should -Be 'downloaded'
|
|
}
|
|
|
|
It 'holds nothing when no attempt verifies' {
|
|
Mock Test-BootstrapSignature { $false }
|
|
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
|
|
$RunState.Installer | Should -BeNullOrEmpty
|
|
{ [System.IO.File]::Delete($script:dest) } | Should -Not -Throw
|
|
}
|
|
}
|
|
|
|
Describe 'Get-DownloadSecurityProtocol' {
|
|
It 'keeps the system default, which includes TLS 1.3' {
|
|
Get-DownloadSecurityProtocol -Current ([Net.SecurityProtocolType]::SystemDefault) | Should -Be ([Net.SecurityProtocolType]::SystemDefault)
|
|
}
|
|
|
|
It 'adds TLS 1.2 to an explicit list' {
|
|
Get-DownloadSecurityProtocol -Current ([Net.SecurityProtocolType]'Ssl3, Tls') | Should -Be ([Net.SecurityProtocolType]'Ssl3, Tls, Tls12')
|
|
}
|
|
}
|
|
|
|
Describe 'Remove-LoaderRunFiles' {
|
|
BeforeEach {
|
|
Mock Write-Host { }
|
|
$run = Join-Path $TestDrive ("run-" + [guid]::NewGuid().ToString('N'))
|
|
New-Item -ItemType Directory -Path $run | Out-Null
|
|
$installer = Join-Path $run 'Install-AutopilotMonitor.ps1'
|
|
$log = Join-Path $run 'bootstrap-loader.log'
|
|
Set-Content -Path $installer -Value 'downloaded'
|
|
Set-Content -Path $log -Value 'log'
|
|
$RunState = [pscustomobject]@{ LogFile = $log; Installer = $null }
|
|
}
|
|
|
|
It 'removes the download, the log and the folder' {
|
|
Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer
|
|
Test-Path -LiteralPath $run | Should -BeFalse
|
|
$RunState.LogFile | Should -BeNullOrEmpty
|
|
}
|
|
|
|
It 'releases the held download before deleting it' {
|
|
$RunState.Installer = New-Object System.IO.FileStream($installer, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)
|
|
Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer
|
|
Test-Path -LiteralPath $run | Should -BeFalse
|
|
$RunState.Installer | Should -BeNullOrEmpty
|
|
}
|
|
|
|
It 'keeps the log and the folder with -KeepLog, but never the download' {
|
|
Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer -KeepLog
|
|
@(Get-ChildItem -LiteralPath $run -Force | ForEach-Object Name) | Should -Be @('bootstrap-loader.log')
|
|
}
|
|
|
|
It 'leaves a folder that holds anything else, without throwing' {
|
|
Set-Content -Path (Join-Path $run 'other.txt') -Value 'other'
|
|
{ Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer } | Should -Not -Throw
|
|
@(Get-ChildItem -LiteralPath $run -Force | ForEach-Object Name) | Should -Be @('other.txt')
|
|
Should -Invoke Write-Host -ParameterFilter { "$Object" -like 'INFO: run folder*' }
|
|
}
|
|
}
|
|
|
|
Describe 'Start-BootstrapLoader' {
|
|
BeforeEach {
|
|
$script:savedProgramData = $env:ProgramData
|
|
$env:ProgramData = Join-Path $TestDrive ("pd-" + [guid]::NewGuid().ToString('N'))
|
|
New-Item -ItemType Directory -Path $env:ProgramData | Out-Null
|
|
$script:runFolder = Join-Path $env:ProgramData 'AutopilotMonitor-Loader-0a1b2c3d'
|
|
$script:marker = Join-Path $TestDrive ("ran-" + [guid]::NewGuid().ToString('N') + '.txt')
|
|
|
|
$RunState = [pscustomobject]@{ LogFile = $null; Installer = $null }
|
|
$InstallerArguments = @()
|
|
|
|
Mock Write-Host { }
|
|
Mock Start-Sleep { }
|
|
Mock Test-IsElevated { $true }
|
|
# A standard user cannot make Administrators the owner, so the run folder is a plain
|
|
# one here; New-RunFolder itself is tested with the installer, which shares it.
|
|
Mock New-RunFolder { [System.IO.Directory]::CreateDirectory($script:runFolder) | Out-Null; return $script:runFolder }
|
|
}
|
|
|
|
AfterEach {
|
|
$env:ProgramData = $script:savedProgramData
|
|
}
|
|
|
|
It 'refuses to run without SYSTEM or administrator rights and creates nothing' {
|
|
Mock Test-IsElevated { $false }
|
|
$stderr = New-Object System.IO.StringWriter
|
|
$previous = [Console]::Error
|
|
[Console]::SetError($stderr)
|
|
try { Start-BootstrapLoader } finally { [Console]::SetError($previous) }
|
|
$stderr.ToString() | Should -Match 'not running as SYSTEM or administrator'
|
|
Should -Invoke New-RunFolder -Times 0 -Exactly
|
|
@(Get-ChildItem -LiteralPath $env:ProgramData -Force).Count | Should -Be 0
|
|
}
|
|
|
|
It 'runs nothing and keeps only its log when no download verifies' {
|
|
Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "Set-Content -LiteralPath '$($script:marker)' -Value ran"); $false }
|
|
Start-BootstrapLoader
|
|
Test-Path -LiteralPath $script:marker | Should -BeFalse
|
|
@(Get-ChildItem -LiteralPath $script:runFolder -Force | ForEach-Object Name) | Should -Be @('bootstrap-loader.log')
|
|
Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') -Raw | Should -Match 'Nothing was executed'
|
|
}
|
|
|
|
It 'runs the verified installer with the passed arguments by name and removes its folder after exit 0' {
|
|
$agentLog = Join-Path $env:ProgramData 'AutopilotMonitor\Logs\agent.log'
|
|
New-Item -ItemType Directory -Path (Split-Path $agentLog) -Force | Out-Null
|
|
Set-Content -Path $agentLog -Value 'agent'
|
|
Mock Get-VerifiedBootstrapScript {
|
|
[System.IO.File]::WriteAllText($Destination, "param([string]`$AgentDownloadUrl = 'default', [int]`$MaxBootstrapWindowHours = 12)`nSet-Content -LiteralPath '$($script:marker)' -Value `"`$AgentDownloadUrl `$MaxBootstrapWindowHours`"`nexit 0`n")
|
|
$true
|
|
}
|
|
$InstallerArguments = ConvertTo-ScriptArguments -MaxBootstrapWindowHours 24
|
|
|
|
Start-BootstrapLoader
|
|
|
|
Get-Content -LiteralPath $script:marker | Should -Be 'default 24'
|
|
Test-Path -LiteralPath $script:runFolder | Should -BeFalse
|
|
Get-Content -LiteralPath $agentLog | Should -Be 'agent'
|
|
}
|
|
|
|
It 'keeps its log but not the installer when the installer fails' {
|
|
Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "exit 1`n"); $true }
|
|
Start-BootstrapLoader
|
|
@(Get-ChildItem -LiteralPath $script:runFolder -Force | ForEach-Object Name) | Should -Be @('bootstrap-loader.log')
|
|
Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') -Raw | Should -Match 'Installer finished \(exit code 1\)'
|
|
}
|
|
|
|
It 'keeps its log when the installer throws' {
|
|
Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "throw 'installer broke'`n"); $true }
|
|
Start-BootstrapLoader
|
|
Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') -Raw | Should -Match 'Installer threw: installer broke'
|
|
}
|
|
|
|
# An installer that ends without an exit code must not inherit a 0 from earlier work.
|
|
It 'does not take an earlier exit code for the installer''s' {
|
|
Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "Write-Output 'no exit code'`n"); $true }
|
|
$global:LASTEXITCODE = 0
|
|
Start-BootstrapLoader
|
|
Test-Path -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') | Should -BeTrue
|
|
}
|
|
|
|
It 'names its version first in its log, in the form the installer carries over' {
|
|
Mock Get-VerifiedBootstrapScript { $false }
|
|
Start-BootstrapLoader
|
|
$first = (Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log'))[0]
|
|
$first | Should -Match '^\[\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\] === Autopilot Monitor bootstrap loader v\d+(\.\d+){0,3} started'
|
|
}
|
|
}
|
|
|
|
Describe 'loader contract' {
|
|
BeforeAll {
|
|
$script:loader = Get-ScriptAst $script:loaderPath
|
|
}
|
|
|
|
It 'parses without errors' {
|
|
$script:loader.Errors.Count | Should -Be 0
|
|
}
|
|
|
|
# Unbound arguments reach the installer only while the script is not an advanced one.
|
|
It 'stays a plain script, so unbound arguments are kept for the installer' {
|
|
$script:loader.Ast.ParamBlock.Attributes.Count | Should -Be 0
|
|
@($script:loader.Ast.ParamBlock.Parameters.Attributes | Where-Object { $_.TypeName.Name -eq 'Parameter' }) | Should -BeNullOrEmpty
|
|
}
|
|
|
|
# The six functions also run in the installer and, embedded, in the OOBE script.
|
|
It 'carries the shared functions word for word as the installer has them' {
|
|
$installer = Get-ScriptAst $script:installerPath
|
|
foreach ($name in @('Test-IsElevated', 'New-ProtectedDirectorySecurity', 'Test-IsReparsePoint', 'Test-IsOwnedByUs', 'New-RunFolderName', 'New-RunFolder')) {
|
|
$mine = $script:loader.Ast.Find({ param($a) $a -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $a.Name -eq $name }, $true)
|
|
$theirs = $installer.Ast.Find({ param($a) $a -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $a.Name -eq $name }, $true)
|
|
$mine | Should -Not -BeNullOrEmpty -Because $name
|
|
$mine.Extent.Text | Should -BeExactly $theirs.Extent.Text -Because $name
|
|
}
|
|
}
|
|
|
|
It 'calls no command beyond the few it needs' {
|
|
$own = @($script:loader.Ast.FindAll({ param($a) $a -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) | ForEach-Object { $_.Name })
|
|
$allowed = $own + @('Add-Content', 'ForEach-Object', 'Get-AuthenticodeSignature', 'Get-Date', 'Get-Item', 'Invoke-WebRequest', 'Join-Path', 'New-Object', 'Out-Null', 'Start-Sleep', 'Test-Path', 'Write-Host')
|
|
$used = @($script:loader.Ast.FindAll({ param($a) $a -is [System.Management.Automation.Language.CommandAst] }, $true) | ForEach-Object { $_.GetCommandName() } | Where-Object { $_ } | Sort-Object -Unique)
|
|
@($used | Where-Object { $allowed -notcontains $_ }) | Should -BeNullOrEmpty
|
|
}
|
|
|
|
It 'deletes only the download, its log and its own empty folder' {
|
|
$deletes = @($script:loader.Ast.FindAll({ param($a) $a -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and $a.Member.Extent.Text -eq 'Delete' }, $true))
|
|
$deletes.Count | Should -Be 3
|
|
foreach ($delete in $deletes) {
|
|
if ($delete.Expression.Extent.Text -eq '[System.IO.Directory]') {
|
|
$delete.Arguments[1].Extent.Text | Should -Be '$false'
|
|
} else {
|
|
$delete.Expression.Extent.Text | Should -Be '[System.IO.File]'
|
|
}
|
|
}
|
|
}
|
|
|
|
It 'never names the agent folder' {
|
|
$strings = @($script:loader.Tokens | Where-Object { $_ -is [System.Management.Automation.Language.StringToken] } | ForEach-Object { $_.Value })
|
|
@($strings | Where-Object { $_ -match '(^|\\)AutopilotMonitor($|\\)' }) | Should -BeNullOrEmpty
|
|
}
|
|
}
|
|
|
|
Describe 'publish contract' {
|
|
BeforeAll {
|
|
$script:loaderText = Get-Content $script:loaderPath -Raw
|
|
}
|
|
|
|
# Publish-BootstrapScripts.ps1 renders the -Dev loader by substituting this exact
|
|
# literal and fails the publish when it is gone. Catching that here means the PR fails
|
|
# instead of the publish.
|
|
It 'carries the dev-render anchor the publisher substitutes' {
|
|
$anchor = '$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1"'
|
|
$script:loaderText.Contains($anchor) | Should -BeTrue
|
|
}
|
|
|
|
# The publisher parses this to write version.json.loaderVersion and to run the
|
|
# version-bump guard.
|
|
It 'declares a parsable ScriptVersion' {
|
|
$script:loaderText -match '(?m)^\$ScriptVersion = "\d+(\.\d+){1,3}"\r?$' | Should -BeTrue
|
|
}
|
|
|
|
It 'pins the expected publisher by subject, not by thumbprint' {
|
|
$script:loaderText -match '\$ExpectedPublisher\s*=\s*"\*O=' | Should -BeTrue
|
|
}
|
|
|
|
# The bootstrap MSI installs this same file and runs it with -LogFileName so the delivery
|
|
# channel stays visible in a diagnostics package. Renaming the parameter on either side
|
|
# would leave the MSI channel silently logging under the wrong name.
|
|
It 'keeps the -LogFileName contract the bootstrap MSI relies on' {
|
|
$script:loaderText -match '\[string\]\$LogFileName' | Should -BeTrue
|
|
|
|
$wxs = Get-Content (Join-Path $PSScriptRoot '..\..\src\Agent\AutopilotMonitor.BootstrapMsi\Package.wxs') -Raw
|
|
$wxs.Contains('[INSTALLFOLDER]Start-AutopilotMonitor.ps1') | Should -BeTrue
|
|
$wxs.Contains('-LogFileName bootstrap-msi.log') | Should -BeTrue
|
|
}
|
|
|
|
# The file a SYSTEM custom action runs must not sit where a standard user could have
|
|
# created the folder first: Program Files, never ProgramData.
|
|
It 'is installed by the bootstrap MSI under Program Files' {
|
|
$wxs = Get-Content (Join-Path $PSScriptRoot '..\..\src\Agent\AutopilotMonitor.BootstrapMsi\Package.wxs') -Raw
|
|
$wxs | Should -Match '<StandardDirectory Id="ProgramFiles64Folder">'
|
|
$wxs | Should -Not -Match 'CommonAppDataFolder'
|
|
}
|
|
}
|