diff --git a/internal b/internal index 9a3a61f5..e94af0c3 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 9a3a61f56fc79a1cd076a852052f92d9589b10b2 +Subproject commit e94af0c32e06b5efa875980989f945f960e0331e diff --git a/scripts/Bootstrap/Install-AutopilotMonitor.Tests.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor.Tests.ps1 index b6294da5..a4bb3db9 100644 --- a/scripts/Bootstrap/Install-AutopilotMonitor.Tests.ps1 +++ b/scripts/Bootstrap/Install-AutopilotMonitor.Tests.ps1 @@ -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}$' + } +} diff --git a/scripts/Bootstrap/Install-AutopilotMonitor.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor.ps1 index 751fa4f4..d96f3113 100644 --- a/scripts/Bootstrap/Install-AutopilotMonitor.ps1 +++ b/scripts/Bootstrap/Install-AutopilotMonitor.ps1 @@ -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 { '' } + + 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 diff --git a/src/Web/autopilot-monitor-web/app/about/page.tsx b/src/Web/autopilot-monitor-web/app/about/page.tsx index a77c6362..2dc5027e 100644 --- a/src/Web/autopilot-monitor-web/app/about/page.tsx +++ b/src/Web/autopilot-monitor-web/app/about/page.tsx @@ -111,7 +111,7 @@ export default function AboutPage() { data — into a central dashboard with intelligent analysis built in.

- 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 Azure, authenticates via Microsoft Entra ID, 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)", ], diff --git a/src/Web/autopilot-monitor-web/app/privacy/page.tsx b/src/Web/autopilot-monitor-web/app/privacy/page.tsx index 186912ea..ccae6a91 100644 --- a/src/Web/autopilot-monitor-web/app/privacy/page.tsx +++ b/src/Web/autopilot-monitor-web/app/privacy/page.tsx @@ -208,7 +208,7 @@ export default function PrivacyPage() {

  • Encryption — HTTPS with a TLS 1.2 floor in transit, Azure Storage encryption at rest with platform-managed keys
  • Managed identity instead of storage keys, and secret-less OIDC deployment pipelines
  • Rate limiting per device, per portal user, and per MCP user
  • -
  • Verifiable agent binaries — Sigstore build attestation plus a four-stage integrity chain through download, backend cross-check, and runtime self-verification
  • +
  • Verifiable agent binaries — 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
  • Manifest-first deletion — what will be deleted is captured, restorably, before anything is removed