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 { '
- 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() {