mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
feat(bootstrap): two-stage loader and Authenticode-signed customer scripts (D-224)
Start-AutopilotMonitor.ps1 is the new stage 1 -- the file an administrator assigns in Intune. It carries no enrollment logic: it downloads the published installer, verifies its Authenticode signature, and runs it in the same process, passing through every remaining argument. Assigning the installer directly stays supported and unchanged. The point of the split is that the customer-held artifact stops changing. Today every bootstrap change forces customers to replace their Intune copy, so the fleet runs a distribution of script versions we do not control. It is also what makes script signing worth doing: signing a file that gets swapped by hand each release is a one-off gain, signing a loader that never changes is permanent. The loader is deliberately minimal, because it is the one piece that cannot be fixed remotely -- every guard placed in it would be frozen for years. All guards stay in stage 2. Verification pins the certificate SUBJECT, never a thumbprint: certificates get renewed and a deployed loader cannot be, so a thumbprint pin would strand the fleet at the next renewal. A hash oracle was rejected for a different reason -- it would have to be fetched from the same host as the script, so it could not survive a compromise of that host, while the expected publisher travels inside the loader. Fail-closed on verification, fail-soft on exit: nothing runs without a valid signature from us (running unverified code as SYSTEM is the risk being removed here), but the loader still exits 0, because a device without monitoring is acceptable and a failed enrollment step is not. The accepted cost is that a device which can never fetch or verify stays silent. Publisher changes: - every published script is signed (loader, installer, both -Dev renders, Test-ShouldBootstrapAgent, Grant-AutopilotMonitorAddOn), and signing is the LAST transformation: it covers exactly the CRLF/UTF-8 bytes Get-PublishBytes produced, so nothing may normalise or re-encode them afterwards. - signing is mandatory for a real publish. Only -DryRun may run unsigned, because the PR gate executes the publisher without any Azure login. - comparisons against a published copy strip the signature block first. A timestamped signature differs on every run, so without stripping the version-bump guard and the drift check would report a change for every unchanged script. - the bump guard became Assert-VersionBump and now covers the loader as well. - version.json gains loaderVersion. Additive, no consumer yet: stage 2 is current by construction, so only stage 1's version says anything about a customer's Intune copy. - -SignedOutputDir writes the exact uploaded bytes for the CI artifact, in a dry run too -- that is how a signed script gets inspected before it reaches anyone. publish-scripts.yml logs in with the signing identity first and only to fetch the Key Vault token (azure/login sets the default az account and the storage login has to be the one that stays), then publishes and offers the signed scripts as an artifact. build-agent.yml passes the same parameters on a stable cutover. Tests: 14 new Pester cases, including that a validly signed script from a FOREIGN publisher is refused -- that is what an attacker with any code-signing certificate can produce. Full bootstrap suite on Windows PowerShell 5.1: 43/43. Not covered: the MSI runner still downloads stage 2 without verifying it, and the customer docs still describe the direct assignment only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
# 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, and how often does it retry.
|
||||
#
|
||||
# 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 }
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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-VerifiedBootstrapScript' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
Mock Start-Sleep { }
|
||||
Mock Invoke-WebRequest { }
|
||||
}
|
||||
|
||||
It 'returns after the first attempt when the download verifies' {
|
||||
Mock Test-BootstrapSignature { $true }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | 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 'TestDrive:\s.ps1' | 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 'TestDrive:\s.ps1' | 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' } }
|
||||
Mock Test-BootstrapSignature { $true }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeTrue
|
||||
Should -Invoke Invoke-WebRequest -Times 2 -Exactly
|
||||
}
|
||||
|
||||
It 'never executes anything when no attempt verifies' {
|
||||
Mock Test-BootstrapSignature { $false }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'publish contract' {
|
||||
BeforeAll {
|
||||
$script:loaderText = Get-Content (Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1') -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 '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"' | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'pins the expected publisher by subject, not by thumbprint' {
|
||||
$script:loaderText -match '\$ExpectedPublisher\s*=\s*"\*O=' | Should -BeTrue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Stage 1 of the Autopilot Monitor bootstrap: fetches the current installer and runs it.
|
||||
|
||||
.DESCRIPTION
|
||||
This is the file an administrator assigns in Intune. It carries no enrollment logic at
|
||||
all: it downloads the published Install-AutopilotMonitor.ps1, proves that it was signed
|
||||
by Autopilot Monitor, and runs it in this process.
|
||||
|
||||
Why two stages: the installer holds every guard, relax rule and download path, and those
|
||||
change. Whoever assigns the installer directly has to replace their Intune copy each
|
||||
time; whoever assigns this loader never does, because the part that changes lives on the
|
||||
server. Assigning the installer directly stays supported and unchanged.
|
||||
|
||||
This file is deliberately boring. It is the one piece that cannot be fixed remotely, so
|
||||
it does the least it can: download, verify, invoke, log.
|
||||
|
||||
Verification is by Authenticode publisher, not by hash. A hash oracle would have to be
|
||||
fetched from the same host as the script itself, so it could not survive a compromise of
|
||||
that host, while the expected publisher below travels with this file. It pins the
|
||||
certificate SUBJECT and not a thumbprint, so a certificate renewal does not brick a
|
||||
loader that is already deployed on devices.
|
||||
|
||||
Fail-soft: the loader always exits 0 and never throws into the caller. A device that
|
||||
cannot reach the download endpoint is a device without monitoring, never a failed
|
||||
enrollment step. Verification failures are the same: nothing runs, and the reason is in
|
||||
the log. Refusing to execute unverified code as SYSTEM is the point of this file.
|
||||
|
||||
This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
|
||||
|
||||
.PARAMETER BootstrapUrl
|
||||
Installer URL. Defaults to the published stable installer; the -Dev variant of this
|
||||
loader is rendered from that default at publish time.
|
||||
|
||||
.PARAMETER BootstrapArguments
|
||||
Everything else is passed through to the installer unchanged, so any parameter the
|
||||
installer accepts can be set here without this file knowing about it.
|
||||
|
||||
.EXAMPLE
|
||||
.\Start-AutopilotMonitor.ps1
|
||||
|
||||
.EXAMPLE
|
||||
.\Start-AutopilotMonitor.ps1 -MaxBootstrapWindowHours 24
|
||||
|
||||
.NOTES
|
||||
Author : Oliver Kieselbach (autopilotmonitor.com)
|
||||
Runtime : Windows PowerShell 5.1, SYSTEM context, during Autopilot enrollment
|
||||
|
||||
.CHANGELOG
|
||||
2026-09-08 v1.0 Initial version: two-stage bootstrap, publisher-pinned verification.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1",
|
||||
|
||||
[Parameter(Mandatory = $false, ValueFromRemainingArguments = $true)]
|
||||
[string[]]$BootstrapArguments
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "1.0"
|
||||
|
||||
# The publisher every downloaded stage-2 script must carry. A -like pattern on the subject,
|
||||
# NOT a thumbprint: certificates are renewed, deployed loaders are not.
|
||||
$ExpectedPublisher = "*O=glueckkanja AG*"
|
||||
|
||||
$LoaderBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$LoaderLogPath = "$LoaderBasePath\Logs"
|
||||
$LogFile = "$LoaderLogPath\bootstrap-loader.log"
|
||||
$BootstrapPath = "$LoaderBasePath\Install-AutopilotMonitor.ps1"
|
||||
|
||||
$DownloadAttempts = 3
|
||||
$RetryDelaySeconds = 10
|
||||
|
||||
# Write-Host as well as the file: the Intune script output is what an administrator sees in
|
||||
# the portal, the file is what a support case reads.
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logMessage = "[$timestamp] $Message"
|
||||
Write-Host $logMessage
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# True only when the file is signed, the signature validates against the machine's trust
|
||||
# chain, and the signer is us. Any error is a false -- an unreadable signature is an
|
||||
# unverified script.
|
||||
function Test-BootstrapSignature {
|
||||
param([string]$Path)
|
||||
|
||||
try {
|
||||
$sig = Get-AuthenticodeSignature -FilePath $Path -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
Write-Log "Signature check failed to run: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
|
||||
if ($null -eq $sig) {
|
||||
Write-Log "Signature check returned nothing."
|
||||
return $false
|
||||
}
|
||||
|
||||
$subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { "<unsigned>" }
|
||||
Write-Log "Signature status: $($sig.Status). Signer: $subject"
|
||||
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
Write-Log "REJECTED: expected signature status 'Valid'."
|
||||
return $false
|
||||
}
|
||||
if ($subject -notlike $ExpectedPublisher) {
|
||||
Write-Log "REJECTED: signer does not match the expected publisher."
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Log "Signature accepted (thumbprint $($sig.SignerCertificate.Thumbprint))."
|
||||
return $true
|
||||
}
|
||||
|
||||
# Downloads the installer and returns $true once a downloaded copy passes verification.
|
||||
# Verification lives inside the retry loop on purpose: a truncated or proxy-mangled body
|
||||
# fails the signature check, and that is worth another attempt.
|
||||
function Get-VerifiedBootstrapScript {
|
||||
param([string]$Url, [string]$Destination)
|
||||
|
||||
for ($attempt = 1; $attempt -le $DownloadAttempts; $attempt++) {
|
||||
try {
|
||||
Write-Log "Download attempt $attempt/$DownloadAttempts from $Url"
|
||||
Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
|
||||
|
||||
if (Test-BootstrapSignature -Path $Destination) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "Download attempt $attempt failed: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if ($attempt -lt $DownloadAttempts) {
|
||||
Start-Sleep -Seconds $RetryDelaySeconds
|
||||
}
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
# Main flow. Only invoked when the script is run directly (see entry guard at the bottom),
|
||||
# so the Pester suite can dot-source this file without bootstrapping anything.
|
||||
function Start-BootstrapLoader {
|
||||
New-Item -ItemType Directory -Force -Path $LoaderLogPath -ErrorAction SilentlyContinue | Out-Null
|
||||
|
||||
Write-Log "=== Autopilot Monitor bootstrap loader v$ScriptVersion started (user=$env:USERNAME, pid=$PID) ==="
|
||||
|
||||
# PS 5.1 on an OOBE device still defaults to TLS 1.0 for this process.
|
||||
try {
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
}
|
||||
catch {
|
||||
Write-Log "INFO: could not raise the TLS protocol: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if (-not (Get-VerifiedBootstrapScript -Url $BootstrapUrl -Destination $BootstrapPath)) {
|
||||
Write-Log "No verified installer available after $DownloadAttempts attempt(s). Nothing was executed."
|
||||
return
|
||||
}
|
||||
|
||||
Write-Log "Executing $BootstrapPath"
|
||||
|
||||
# Same process, so the execution policy this loader runs under applies to stage 2 as
|
||||
# well and no second policy decision can strand the installer. Splatting an array of
|
||||
# remaining arguments keeps this file free of any knowledge about the installer's
|
||||
# parameters.
|
||||
$arguments = @()
|
||||
if ($BootstrapArguments) { $arguments = $BootstrapArguments }
|
||||
|
||||
try {
|
||||
& $BootstrapPath @arguments
|
||||
Write-Log "Installer finished (exit code $LASTEXITCODE)."
|
||||
}
|
||||
catch {
|
||||
Write-Log "Installer threw: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Entry guard: dot-sourcing (Pester) loads the functions without running the loader.
|
||||
if ($MyInvocation.InvocationName -ne '.') {
|
||||
try {
|
||||
Start-BootstrapLoader
|
||||
}
|
||||
catch {
|
||||
# Nothing above should throw; if it does, it must not become a failed Intune script.
|
||||
Write-Log "Loader failed: $($_.Exception.Message)"
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
Reference in New Issue
Block a user