mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-23 20:26:10 +00:00
feat(bootstrap): verify the publisher of the extracted agent binaries before --install (D-227)
The SHA-256 check proves the ZIP is the one version.json names, but that manifest lives in the same container as the ZIP: it 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 is publisher-anchored end to end. Install-AutopilotMonitor.ps1 v2.4 -> v2.5: - Get-UnverifiedAgentBinaries checks every AutopilotMonitor.*.exe/.dll in the extracted payload for status Valid and the same subject pin the loader carries; the gate runs immediately before --install and throws on any rejection, exactly like the hash check. - Third-party assemblies keep their own publisher's signature and are not checked: System.Management.Automation.dll ships unsigned, so a gate over all files would refuse the live payload. - An extracted folder with no binary of ours is a rejection, not a pass. - The SHA-256 manifest check is unchanged and still runs first: it proves the bytes, the signature proves the author. Verified against the live payload 2.0.1454 (all five of our PEs Valid and glueckkanja AG, gate passes); appending one byte to AutopilotMonitor.Shared.dll makes it reject. Pester 55/55. The portal privacy and about pages carried the pre-signing wording and are corrected in the same change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
Submodule internal updated: 9a3a61f56f...e94af0c32e
@@ -14,6 +14,22 @@
|
||||
BeforeAll {
|
||||
. (Join-Path $PSScriptRoot 'Install-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 }
|
||||
}
|
||||
|
||||
function New-FakePayload {
|
||||
param([string[]]$Files)
|
||||
$dir = Join-Path $TestDrive ([guid]::NewGuid().ToString())
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
foreach ($f in $Files) { Set-Content -Path (Join-Path $dir $f) -Value 'x' }
|
||||
return $dir
|
||||
}
|
||||
|
||||
function New-FakeProfile {
|
||||
param([string]$Name, [double]$AgeMinutes)
|
||||
$p = Join-Path $TestDrive $Name
|
||||
@@ -305,3 +321,93 @@ Describe 'logging purity' {
|
||||
Get-Content $LogFile -Raw | Should -Match 'SKIP: Agent was previously deployed'
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
$script:ours = 'CN=glueckkanja AG, O=glueckkanja AG, C=DE'
|
||||
}
|
||||
|
||||
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 '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
|
||||
}
|
||||
|
||||
Get-UnverifiedAgentBinaries -Path $dir | Should -BeNullOrEmpty
|
||||
$script:checked | Should -Be @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Shared.dll')
|
||||
}
|
||||
|
||||
It 'refuses an unsigned agent executable' {
|
||||
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'NotSigned' -Subject $null }
|
||||
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
|
||||
}
|
||||
|
||||
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 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 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 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
|
||||
}
|
||||
}
|
||||
$rejected = Get-UnverifiedAgentBinaries -Path $dir
|
||||
$rejected.Count | Should -Be 1
|
||||
$rejected[0] | Should -BeLike 'AutopilotMonitor.Shared.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 '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
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'publisher pin' {
|
||||
It 'pins the expected publisher by subject, not by thumbprint' {
|
||||
$ExpectedPublisher | Should -Be '*O=glueckkanja AG*'
|
||||
$ExpectedPublisher | Should -Not -Match '^[0-9A-Fa-f]{40}$'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
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. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
|
||||
6. Verifies the runtime process actually launched
|
||||
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
|
||||
Agent self-destructs when enrollment completes.
|
||||
|
||||
Relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
|
||||
@@ -76,6 +77,11 @@
|
||||
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
|
||||
|
||||
.CHANGELOG
|
||||
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
|
||||
check stays and is unchanged; it proves the bytes, the signature
|
||||
proves the author.
|
||||
2026-08-08 v2.3 Windows 365 Cloud PC support: a positively identified Cloud PC
|
||||
(Windows365 registry key AND CloudManagedDesktopExtension
|
||||
service) now triggers the guard 2+3 relax alongside OOBE
|
||||
@@ -128,7 +134,12 @@ param(
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.4"
|
||||
$ScriptVersion = "2.5"
|
||||
|
||||
# The publisher every binary we author 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*"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
@@ -329,6 +340,56 @@ 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') }
|
||||
)
|
||||
|
||||
# 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")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if ($null -eq $sig) {
|
||||
$rejected += "$($binary.Name): signature check returned nothing"
|
||||
continue
|
||||
}
|
||||
|
||||
$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
|
||||
}
|
||||
}
|
||||
|
||||
return , $rejected
|
||||
}
|
||||
|
||||
# Main bootstrap flow. Only invoked when the script is run directly (see entry guard
|
||||
# at the bottom); never on dot-source.
|
||||
function Invoke-Bootstrap {
|
||||
@@ -445,6 +506,20 @@ function Invoke-Bootstrap {
|
||||
}
|
||||
}
|
||||
|
||||
# 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."
|
||||
}
|
||||
Write-Log "Authenticode verification passed (publisher pin: $ExpectedPublisher)"
|
||||
|
||||
Write-Log "Calling agent install mode (--install)..."
|
||||
& $agentExePath --install
|
||||
$installExitCode = $LASTEXITCODE
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function AboutPage() {
|
||||
data — into a central dashboard with intelligent analysis built in.
|
||||
</p>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
Deployed by assigning a bootstrapper script in Intune, the platform requires no infrastructure
|
||||
Deployed by assigning a signed loader script in Intune, the platform requires no infrastructure
|
||||
changes and no additional certificates on end user devices. It runs entirely on
|
||||
<strong> Azure</strong>, authenticates via <strong>Microsoft Entra ID</strong>, and provides
|
||||
multi-tenant support with strict per-tenant data isolation.
|
||||
@@ -239,7 +239,7 @@ export default function AboutPage() {
|
||||
items: [
|
||||
".NET binary — lightweight, low-overhead monitoring",
|
||||
"Runs via scheduled task (no Windows service — easy, residue-free removal)",
|
||||
"Deployed via an Intune platform script (PowerShell bootstrapper); for Autopilot Device Preparation additionally as a thin MSI line-of-business app",
|
||||
"Deployed via a signed Intune platform script (PowerShell loader); for Autopilot Device Preparation additionally as a thin MSI line-of-business app",
|
||||
"Mutual TLS using the existing Intune MDM device certificate",
|
||||
"Self-destruct on enrollment completion (on by default — removes task and files)",
|
||||
],
|
||||
|
||||
@@ -208,7 +208,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> — Sigstore build attestation plus a four-stage integrity chain through download, backend cross-check, and runtime self-verification</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>Manifest-first deletion</strong> — what will be deleted is captured, restorably, before anything is removed</li>
|
||||
</ul>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user