Files
AutopilotMonitor/scripts/Bootstrap/Install-AutopilotMonitor.ps1
T
Oliver Kieselbach 40323ca57e Bootstrap hardening after a security review, and the MSI channel installs again (D-377)
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>
2026-10-22 18:07:51 +02:00

893 lines
42 KiB
PowerShell

<#
.SYNOPSIS
Installs the Autopilot Monitor agent during Windows Autopilot enrollment.
.DESCRIPTION
Runs as SYSTEM, assigned as an Intune platform script or started by
Start-AutopilotMonitor.ps1. It installs the agent only while the device is in enrollment
and skips productive devices: earlier deployment, real user profile, last logged-on user,
or uptime beyond the bootstrap window. One profile created minutes ago is accepted during
OOBE (Windows Backup for Organizations restore) and on a Windows 365 Cloud PC at first
connect.
The agent is installed only when its ZIP matches the SHA-256 in its manifest and every
file matches the catalog signed by glueckkanja AG. The agent removes itself when
enrollment completes; the registry value HKLM\SOFTWARE\AutopilotMonitor\Deployed stays so
that the device is not installed again. A skipped device without an agent keeps nothing.
Log: %ProgramData%\AutopilotMonitor\Logs\bootstrap-script.log when the agent is installed
or the install fails; a skip is logged to the script output only. Exit code 0: installed
or skipped. Exit code 1 with one error line: failed, or not run as SYSTEM or administrator.
.PARAMETER AgentDownloadUrl
Agent ZIP to install. Default: the published agent.
.PARAMETER VersionJsonName
Manifest next to the agent ZIP that holds its SHA-256. Default: version.json.
.PARAMETER MaxBootstrapWindowHours
Devices up longer than this since their last boot are skipped; not applied to a Cloud PC
at first connect. Default: 12.
.PARAMETER OobeProfileMaxAgeMinutes
Maximum age of the one new profile accepted during OOBE or on a Cloud PC. Default: 15.
.NOTES
Requires Windows PowerShell 5.1.
Changes: https://docs.autopilotmonitor.com/changelog/platform-changelog
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip",
[Parameter(Mandatory = $false)]
[string]$VersionJsonName = "version.json",
[Parameter(Mandatory = $false)]
[int]$MaxBootstrapWindowHours = 12,
[Parameter(Mandatory = $false)]
[int]$OobeProfileMaxAgeMinutes = 15
)
# The loader checks these two lines before it runs this script.
$ScriptVersion = "2.8"
$ScriptRole = "AutopilotMonitor-Installer"
# Signer of the agent's file catalog; a subject pattern, so certificate renewals keep working.
$ExpectedPublisher = "*O=glueckkanja AG*"
$AgentCatalogName = "AutopilotMonitor.Agent.cat"
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
$AgentBinPath = "$AgentBasePath\Agent"
$AgentLogPath = "$AgentBasePath\Logs"
$LogFile = "$AgentLogPath\bootstrap-script.log"
# Where loaders before v1.3 saved this script, and the name prefix of loader v1.3 run folders.
$LegacyInstallerPath = "$AgentBasePath\Install-AutopilotMonitor.ps1"
$LoaderRunFolderPrefix = "AutopilotMonitor-Loader-"
$AgentExeName = "AutopilotMonitor.Agent.exe"
$AgentTaskName = "AutopilotMonitor-Agent"
$DeploymentMarkerKey = "SOFTWARE\AutopilotMonitor"
$DeploymentMarkerValue = "Deployed"
# Lines wait in memory until an install has prepared the Logs folder.
$LogState = [pscustomobject]@{
Ready = $false
Pending = (New-Object System.Collections.Generic.List[string])
}
# Write-Host, so log lines never end up in a function's return value.
function Write-Log {
param([string]$Message)
$line = "[{0}] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Message
Write-Host $line
if ($LogState.Ready) {
Add-Content -Path $LogFile -Value $line -ErrorAction SilentlyContinue
} else {
$LogState.Pending.Add($line)
}
}
function Open-LogFile {
if ($LogState.Pending.Count -gt 0) {
[System.IO.File]::AppendAllText($LogFile, (($LogState.Pending -join "`r`n") + "`r`n"))
$LogState.Pending.Clear()
}
$LogState.Ready = $true
}
# 64-bit registry view: a 32-bit host, the Intune default, would read WOW6432Node.
function Get-RegistryValue64 {
param([string]$SubKey, [string]$Name)
$hive = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]::Registry64)
try {
$key = $hive.OpenSubKey($SubKey)
if ($null -eq $key) { return $null }
try { return $key.GetValue($Name) } finally { $key.Close() }
} finally {
$hive.Close()
}
}
function Test-RegistryKey64 {
param([string]$SubKey)
$hive = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]::Registry64)
try {
$key = $hive.OpenSubKey($SubKey)
if ($null -eq $key) { return $false }
$key.Close()
return $true
} finally {
$hive.Close()
}
}
# OOBE state from Windows 10 1809 or later; 'Unavailable' otherwise, which relaxes no guard.
function Get-OobeState {
try {
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString()
}
catch {
return 'Unavailable'
}
}
# Windows 365 Cloud PC: both markers are required, since each alone has look-alikes.
function Test-IsCloudPc {
try {
$cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
Write-Log "Computer identity: Manufacturer='$($cs.Manufacturer)' Model='$($cs.Model)'."
}
catch { Write-Log "INFO: computer identity query failed: $($_.Exception.Message)" }
try {
$regMarker = Test-RegistryKey64 -SubKey 'SOFTWARE\Microsoft\Windows365'
$svcMarker = $null -ne (Get-Service -Name 'CloudManagedDesktopExtension' -ErrorAction SilentlyContinue)
Write-Log "Cloud PC markers: Windows365 registry key=$regMarker; CloudManagedDesktopExtension service=$svcMarker."
return ($regMarker -and $svcMarker)
}
catch {
Write-Log "INFO: Cloud PC marker check failed: $($_.Exception.Message)"
return $false
}
}
# Real user profiles from WMI and C:\Users. Callers wrap the result in @(): a single path
# would otherwise come back as a string.
function Get-RealUserProfilePaths {
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
@(
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { $_.LocalPath }
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
Select-Object -Unique
}
# The only exemption from guards 2 and 3: OOBE in progress or a Cloud PC, plus exactly one
# profile created less than OobeProfileMaxAgeMinutes ago.
function Get-RelaxDecision {
param(
[string[]]$ProfilePaths,
[int]$OobeProfileMaxAgeMinutes
)
if ($null -eq $ProfilePaths) { $ProfilePaths = @() }
$relaxActive = $false
$oobeState = Get-OobeState
$oobeInProgress = ($oobeState -eq 'InProgress')
$isCloudPc = Test-IsCloudPc
if ($isCloudPc) {
Write-Log "Cloud PC detected (OOBE state: $oobeState)."
}
$relaxTrigger = $null
if ($oobeInProgress) {
$relaxTrigger = "OOBE InProgress (Windows Backup restore case)"
} elseif ($isCloudPc) {
$relaxTrigger = "Cloud PC (headless provisioning, OOBE state: $oobeState)"
}
if ($ProfilePaths.Count -eq 1 -and $relaxTrigger) {
try {
$created = (Get-Item -LiteralPath $ProfilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
$ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes
if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) {
$relaxActive = $true
Write-Log ("Relax active: {0} + single profile created {1:N1}min ago (< {2}min). Guards 2+3 relaxed." -f $relaxTrigger, $ageMin, $OobeProfileMaxAgeMinutes)
} else {
Write-Log ("Relax NOT applied: {0} + single profile '{1}', but profile created {2:N1}min ago (created {3:u}) is outside the [0, {4})min window." -f $relaxTrigger, (Split-Path $ProfilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
}
} catch { Write-Log "INFO: profile age check failed; relax not applied. $($_.Exception.Message)" }
} elseif ($ProfilePaths.Count -eq 1) {
Write-Log "Relax NOT applied: single profile present but neither OOBE InProgress nor Cloud PC (OOBE state: $oobeState)."
} elseif ($ProfilePaths.Count -gt 1) {
Write-Log ("Relax NOT applied: {0} real profiles present (relax requires exactly one). OOBE state: {1}; CloudPC: {2}." -f $ProfilePaths.Count, $oobeState, $isCloudPc)
}
return [pscustomobject]@{
Active = $relaxActive
OobeState = $oobeState
IsCloudPc = $isCloudPc
}
}
# Guards 1-4; reads only. Returns Install, ReasonCode and RelaxActive.
function Get-BootstrapDecision {
param(
[int]$MaxBootstrapWindowHours,
[int]$OobeProfileMaxAgeMinutes
)
# Guard 1: deployed before; the marker outlives the agent.
$deployed = $null
try { $deployed = Get-RegistryValue64 -SubKey $DeploymentMarkerKey -Name $DeploymentMarkerValue } catch { }
if ($deployed) {
Write-Log "SKIP: Agent was previously deployed at $deployed."
return [pscustomobject]@{ Install = $false; ReasonCode = 'AlreadyDeployed'; RelaxActive = $false }
}
$profilePaths = @(Get-RealUserProfilePaths)
$relax = Get-RelaxDecision -ProfilePaths $profilePaths -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes
# Guard 2: real user profiles.
if ($profilePaths.Count -gt 0 -and -not $relax.Active) {
$details = (($profilePaths | ForEach-Object {
$leaf = Split-Path $_ -Leaf
try {
$c = (Get-Item -LiteralPath $_ -Force -ErrorAction Stop).CreationTimeUtc
"{0} (created {1:u}, {2:N1}min ago)" -f $leaf, $c, ((Get-Date).ToUniversalTime() - $c).TotalMinutes
} catch { "$leaf (age unknown)" }
}) | Select-Object -First 3) -join '; '
Write-Log "SKIP: Real user profile(s) found ($details). OOBE state: $($relax.OobeState). CloudPC: $($relax.IsCloudPc). Device appears productive."
return [pscustomobject]@{ Install = $false; ReasonCode = 'ProfilesFound'; RelaxActive = $false }
}
# Guard 3: a real user has logged on.
$lastLoggedOnUser = $null
try { $lastLoggedOnUser = Get-RegistryValue64 -SubKey 'SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' } catch { }
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $relax.Active) {
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
return [pscustomobject]@{ Install = $false; ReasonCode = 'LastLoggedOnUser'; RelaxActive = $false }
}
# Guard 4: uptime since the last boot. A Cloud PC can wait days for its first connect, so
# there the fresh profile is the time anchor.
$lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
if ($relax.Active -and $relax.IsCloudPc) {
Write-Log "Uptime $([int]$uptimeHours)h exceeds the ${MaxBootstrapWindowHours}h window, but the Cloud PC relax is active (fresh first-connect profile is the time anchor). Continuing."
} else {
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
return [pscustomobject]@{ Install = $false; ReasonCode = 'UptimeExceeded'; RelaxActive = $relax.Active }
}
}
return [pscustomobject]@{ Install = $true; ReasonCode = $null; RelaxActive = $relax.Active }
}
# Why the agent folder belongs to an agent (marker, executable or task), or $null. What
# cannot be read counts as an agent, whose folder is never changed.
function Get-AgentPresence {
try {
if ($null -ne (Get-RegistryValue64 -SubKey $DeploymentMarkerKey -Name $DeploymentMarkerValue)) { return 'deployment marker' }
} catch { return "deployment marker unreadable ($($_.Exception.Message))" }
try {
$null = Get-Item -LiteralPath (Join-Path $AgentBinPath $AgentExeName) -Force -ErrorAction Stop
return 'agent executable'
} catch {
if ($_.CategoryInfo.Category -ne [System.Management.Automation.ErrorCategory]::ObjectNotFound) {
return "agent executable unreadable ($($_.Exception.Message))"
}
}
# The category, not the localized message, tells a missing task from a failed query.
for ($attempt = 1; $attempt -le 2; $attempt++) {
try {
$null = Get-ScheduledTask -TaskName $AgentTaskName -ErrorAction Stop
return 'agent task'
} catch {
if ($_.CategoryInfo.Category -eq [System.Management.Automation.ErrorCategory]::ObjectNotFound) { return $null }
if ($attempt -eq 2) { return "agent task unreadable ($($_.Exception.Message))" }
Start-Sleep -Seconds 2
}
}
}
# The loader repeats the first six functions of this block word for word.
### BEGIN shared install helpers (the OOBE script embeds this block; keep it self-contained)
function Test-IsElevated {
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
return (New-Object System.Security.Principal.WindowsPrincipal($identity)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
}
# SYSTEM and Administrators full control, Users read, nothing inherited. Owner Administrators,
# so a folder that any elevated account created counts as ours.
function New-ProtectedDirectorySecurity {
$security = New-Object System.Security.AccessControl.DirectorySecurity
$security.SetAccessRuleProtection($true, $false)
$inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
$propagation = [System.Security.AccessControl.PropagationFlags]::None
$system = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::LocalSystemSid, $null)
$admins = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::BuiltinAdministratorsSid, $null)
$users = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::BuiltinUsersSid, $null)
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($system, 'FullControl', $inherit, $propagation, 'Allow')))
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($admins, 'FullControl', $inherit, $propagation, 'Allow')))
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($users, 'ReadAndExecute', $inherit, $propagation, 'Allow')))
$security.SetOwner($admins)
return $security
}
function Test-IsReparsePoint {
param([string]$Path)
return (([System.IO.File]::GetAttributes($Path) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)
}
# Owner SYSTEM or Administrators. Any other owner, or one that cannot be read, is another
# account's.
function Test-IsOwnedByUs {
param([string]$Path)
try {
$owner = (Get-Item -LiteralPath $Path -Force -ErrorAction Stop).GetAccessControl([System.Security.AccessControl.AccessControlSections]::Owner).GetOwner([System.Security.Principal.SecurityIdentifier])
return ($owner.IsWellKnown([System.Security.Principal.WellKnownSidType]::LocalSystemSid) -or
$owner.IsWellKnown([System.Security.Principal.WellKnownSidType]::BuiltinAdministratorsSid))
} catch {
return $false
}
}
# Random, so nobody can create a run folder's name ahead of the run.
function New-RunFolderName {
$bytes = New-Object byte[] 4
$generator = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try { $generator.GetBytes($bytes) } finally { $generator.Dispose() }
return -join ($bytes | ForEach-Object { $_.ToString('x2') })
}
# CreateDirectory leaves an existing folder as it is, so only a new name is used and the new
# folder is checked.
function New-RunFolder {
param([string]$Parent, [string]$Prefix)
for ($attempt = 1; $attempt -le 3; $attempt++) {
$path = Join-Path $Parent ($Prefix + (New-RunFolderName))
if (Test-Path -LiteralPath $path) { continue }
[System.IO.Directory]::CreateDirectory($path, (New-ProtectedDirectorySecurity)) | Out-Null
if (-not (Test-IsReparsePoint $path) -and (Test-IsOwnedByUs $path)) { return $path }
}
throw "Could not create a run folder under $Parent."
}
# 'Missing', 'Ours', or why the folder is not ours: 'Link', 'File' or 'Foreign'.
function Get-FolderState {
param([string]$Path)
try { $attributes = [System.IO.File]::GetAttributes($Path) }
catch [System.IO.FileNotFoundException] { return 'Missing' }
catch [System.IO.DirectoryNotFoundException] { return 'Missing' }
if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { return 'Link' }
if (($attributes -band [System.IO.FileAttributes]::Directory) -eq 0) { return 'File' }
if (-not (Test-IsOwnedByUs $Path)) { return 'Foreign' }
return 'Ours'
}
function Remove-OwnFile {
param([string]$Path)
# A virus scanner may hold the file for a moment.
for ($attempt = 1; ; $attempt++) {
try {
[System.IO.File]::Delete($Path)
return
} catch {
if ($attempt -ge 3) { throw }
Start-Sleep -Milliseconds 500
}
}
}
function Remove-OwnTreeEntries {
param([string]$Path, $Left)
try { $entries = [System.IO.Directory]::GetFileSystemEntries($Path) } catch { $Left.Locked.Add($Path); return }
foreach ($entry in $entries) {
try {
$attributes = [System.IO.File]::GetAttributes($entry)
$isDirectory = ($attributes -band [System.IO.FileAttributes]::Directory) -ne 0
if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
if ($isDirectory) { [System.IO.Directory]::Delete($entry, $false) } else { [System.IO.File]::Delete($entry) }
continue
}
if (-not (Test-IsOwnedByUs $entry)) {
$Left.Foreign.Add($entry)
continue
}
if ($isDirectory) {
Remove-OwnTreeEntries -Path $entry -Left $Left
[System.IO.Directory]::Delete($entry, $false)
} else {
if (($attributes -band [System.IO.FileAttributes]::ReadOnly) -ne 0) {
[System.IO.File]::SetAttributes($entry, ($attributes -band (-bnot [System.IO.FileAttributes]::ReadOnly)))
}
Remove-OwnFile -Path $entry
}
} catch {
$Left.Locked.Add($entry)
}
}
}
# Deletes our own entries in a folder we own. Links are removed, never followed; other
# accounts' entries stay. Returns what stayed; never throws.
function Remove-OwnTree {
param([string]$Path, [switch]$KeepRoot)
$left = [pscustomobject]@{
Foreign = New-Object System.Collections.Generic.List[string]
Locked = New-Object System.Collections.Generic.List[string]
}
Remove-OwnTreeEntries -Path $Path -Left $left
if (-not $KeepRoot) {
try { [System.IO.Directory]::Delete($Path, $false) } catch { $left.Locked.Add($Path) }
}
return $left
}
# Creates the agent folder protected, or protects and empties one of ours. Throws for a link,
# a file, or a folder that is, or holds entries that are, another account's.
function Initialize-AgentBase {
param([string]$Path)
$state = Get-FolderState $Path
if ($state -eq 'Missing') {
[System.IO.Directory]::CreateDirectory($Path, (New-ProtectedDirectorySecurity)) | Out-Null
$state = Get-FolderState $Path
if ($state -ne 'Ours') { throw "$Path could not be created as a protected folder ($state)." }
return
}
if ($state -ne 'Ours') {
throw "$Path is not a folder that SYSTEM or administrators created ($state); not installing into it."
}
(Get-Item -LiteralPath $Path -Force -ErrorAction Stop).SetAccessControl((New-ProtectedDirectorySecurity))
$left = Remove-OwnTree -Path $Path -KeepRoot
if ($left.Foreign.Count -gt 0) {
throw ("$Path holds entries another account created ({0}); not installing into it." -f (($left.Foreign | Select-Object -First 3) -join ', '))
}
}
# Why the extracted payload must not run; empty when it matches its signed catalog. The
# catalog Status covers the hashes only, so the signer is checked on its own. The comma keeps
# a single rejection an array.
function Get-AgentPayloadRejections {
param(
[string]$Path,
[string]$ExpectedPublisher,
[string]$CatalogName = 'AutopilotMonitor.Agent.cat'
)
$catalog = Join-Path $Path $CatalogName
if (-not (Test-Path -LiteralPath $catalog -PathType Leaf)) {
return , @("catalog $CatalogName missing")
}
try {
$result = Test-FileCatalog -Path $Path -CatalogFilePath $catalog -FilesToSkip $CatalogName -Detailed -ErrorAction Stop
}
catch {
return , @("catalog check failed to run ($($_.Exception.Message))")
}
if ($null -eq $result) {
return , @('catalog check returned nothing')
}
$rejected = @()
$signature = $result.Signature
if ($null -eq $signature -or "$($signature.Status)" -ne 'Valid') {
$status = if ($null -eq $signature) { 'missing' } else { "$($signature.Status)" }
$rejected += "catalog signature status $status"
}
elseif ($null -eq $signature.SignerCertificate -or $signature.SignerCertificate.Subject -notlike $ExpectedPublisher) {
$subject = if ($null -eq $signature.SignerCertificate) { '' } else { $signature.SignerCertificate.Subject }
$rejected += "catalog publisher '$subject' does not match $ExpectedPublisher"
}
if ("$($result.Status)" -ne 'Valid') {
$expected = if ($null -eq $result.CatalogItems) { @{} } else { $result.CatalogItems }
$actual = if ($null -eq $result.PathItems) { @{} } else { $result.PathItems }
$names = {
param($list)
if ($list.Count -eq 0) { return 'none' }
$shown = ($list | Select-Object -First 5) -join ', '
if ($list.Count -gt 5) { $shown += " (+$($list.Count - 5) more)" }
return $shown
}
$missing = @($expected.Keys | Where-Object { -not $actual.ContainsKey($_) } | Sort-Object)
$extra = @($actual.Keys | Where-Object { -not $expected.ContainsKey($_) } | Sort-Object)
$changed = @($expected.Keys | Where-Object { $actual.ContainsKey($_) -and $actual[$_] -ne $expected[$_] } | Sort-Object)
$rejected += "payload differs from catalog (missing: $(& $names $missing); extra: $(& $names $extra); changed: $(& $names $changed))"
}
return , $rejected
}
# Extracts the agent ZIP into the payload folder. Every entry is checked before the first one
# is written: a rooted name, or one whose resolved path lies outside the folder, refuses the
# whole package. The guarantee is this script's own, not the device's Expand-Archive's.
function Expand-AgentPayload {
param([string]$ZipPath, [string]$Destination)
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
$root = [System.IO.Path]::GetFullPath($Destination).TrimEnd('\') + '\'
$archive = [System.IO.Compression.ZipFile]::OpenRead($ZipPath)
try {
$planned = New-Object System.Collections.Generic.List[object]
foreach ($entry in $archive.Entries) {
$name = $entry.FullName
$target = $null
if (-not [System.IO.Path]::IsPathRooted($name) -and -not $name.Contains(':')) {
$target = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($root, $name))
}
if ($null -eq $target -or -not $target.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "The agent package has an entry that would be written outside the payload folder ($name); refusing the package."
}
$planned.Add([pscustomobject]@{ Entry = $entry; Target = $target })
}
[System.IO.Directory]::CreateDirectory($root) | Out-Null
foreach ($item in $planned) {
if ($item.Entry.FullName.EndsWith('/') -or $item.Entry.FullName.EndsWith('\')) {
[System.IO.Directory]::CreateDirectory($item.Target) | Out-Null
continue
}
[System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($item.Target)) | Out-Null
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($item.Entry, $item.Target, $false)
}
} finally {
$archive.Dispose()
}
}
# Verifies the payload in the run folder and moves it in as the agent folder. The move fails
# when that folder exists, so nothing is ever merged into it.
function Install-VerifiedAgentPayload {
param(
[string]$ZipPath,
[string]$StagingPath,
[string]$TargetPath,
[string]$ExpectedPublisher,
[string]$CatalogName = 'AutopilotMonitor.Agent.cat'
)
$payload = Join-Path $StagingPath 'Agent'
Expand-AgentPayload -ZipPath $ZipPath -Destination $payload
$rejections = Get-AgentPayloadRejections -Path $payload -ExpectedPublisher $ExpectedPublisher -CatalogName $CatalogName
if ($rejections.Count -gt 0) {
throw "Payload verification FAILED ($($rejections -join '; ')). Refusing to install an agent that is not the published one."
}
for ($attempt = 1; ; $attempt++) {
try {
[System.IO.Directory]::Move($payload, $TargetPath)
return
} catch {
if ($attempt -ge 3 -or [System.IO.Directory]::Exists($TargetPath)) { throw }
Start-Sleep -Seconds 1
}
}
}
# The agent writes its output to the host; only the exit code comes back.
function Invoke-AgentInstallMode {
param([string]$ExePath, [string[]]$Arguments = @('--install'))
& $ExePath @Arguments | Out-Host
return $LASTEXITCODE
}
### END shared install helpers
# Skip without an agent: removes the agent folder. Other accounts' entries and files in use
# stay, and the folder with them. Returns a line for the log; never throws.
function Remove-AgentBaseOnSkip {
param([string]$Path)
try {
$state = Get-FolderState $Path
if ($state -eq 'Missing') { return "$Path does not exist; nothing to remove." }
if ($state -ne 'Ours') { return "$Path left as it is ($state)." }
(Get-Item -LiteralPath $Path -Force -ErrorAction Stop).SetAccessControl((New-ProtectedDirectorySecurity))
$left = Remove-OwnTree -Path $Path
if ($left.Foreign.Count -eq 0 -and $left.Locked.Count -eq 0) { return "$Path removed." }
return ("$Path kept: {0} entries of another account, {1} in use ({2})." -f $left.Foreign.Count, $left.Locked.Count, ((@($left.Foreign) + @($left.Locked) | Select-Object -First 3) -join ', '))
} catch {
return "$Path left as it is ($($_.Exception.Message))."
}
}
function Test-IsLoaderRunFolder {
param([string]$Folder)
if (-not $Folder) { return $false }
$parent = [System.IO.Path]::GetDirectoryName([System.IO.Path]::GetFullPath($Folder))
return ([string]::Equals($parent, [System.IO.Path]::GetFullPath($env:ProgramData).TrimEnd('\'), [System.StringComparison]::OrdinalIgnoreCase) -and
[System.IO.Path]::GetFileName($Folder).StartsWith($LoaderRunFolderPrefix, [System.StringComparison]::OrdinalIgnoreCase))
}
function Test-IsLegacyInstallerPath {
param([string]$ScriptPath)
if (-not $ScriptPath) { return $false }
return [string]::Equals([System.IO.Path]::GetFullPath($ScriptPath), [System.IO.Path]::GetFullPath($LegacyInstallerPath), [System.StringComparison]::OrdinalIgnoreCase)
}
# The loader's log, carried into Logs for the agent and the diagnostics package: whole from a
# loader run folder that is ours; from older loaders, whose log folder users could write to,
# only the start line. Callers wrap the result in @(); never throws.
function Read-LoaderLogs {
param([string]$ScriptPath, [string]$ScriptRoot)
$logs = New-Object System.Collections.Generic.List[object]
try {
if ((Test-IsLoaderRunFolder $ScriptRoot) -and (Get-FolderState $ScriptRoot) -eq 'Ours') {
foreach ($file in [System.IO.Directory]::GetFiles($ScriptRoot, '*.log')) {
$logs.Add([pscustomobject]@{ Name = [System.IO.Path]::GetFileName($file); Text = [System.IO.File]::ReadAllText($file) })
}
} elseif (Test-IsLegacyInstallerPath $ScriptPath) {
foreach ($name in @('bootstrap-loader.log', 'bootstrap-msi.log')) {
$path = Join-Path $AgentLogPath $name
if (-not [System.IO.File]::Exists($path)) { continue }
$stream = New-Object System.IO.FileStream($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]'ReadWrite, Delete')
try {
$start = [Math]::Max([long]0, $stream.Length - 65536)
$null = $stream.Seek($start, [System.IO.SeekOrigin]::Begin)
$buffer = New-Object byte[] ($stream.Length - $start)
$read = $stream.Read($buffer, 0, $buffer.Length)
$tail = [System.Text.Encoding]::ASCII.GetString($buffer, 0, $read)
} finally {
$stream.Dispose()
}
$starts = [regex]::Matches($tail, '(?m)^\[\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\] === Autopilot Monitor bootstrap loader v\d+(\.\d+){0,3} started')
if ($starts.Count -gt 0) {
$logs.Add([pscustomobject]@{ Name = $name; Text = $starts[$starts.Count - 1].Value + " (carried over by the bootstrapper) ===`r`n" })
}
}
}
} catch { }
return $logs.ToArray()
}
function Write-LoaderLogs {
param($Logs)
foreach ($log in $Logs) {
try { [System.IO.File]::AppendAllText((Join-Path $AgentLogPath $log.Name), $log.Text) } catch { }
}
}
# Run folders that failed loader runs kept for diagnosis. A folder younger than an hour may
# belong to a loader that is still running and stays. Never throws.
function Remove-StaleLoaderRunFolders {
param([string]$ScriptRoot)
try { $folders = [System.IO.Directory]::GetDirectories($env:ProgramData, $LoaderRunFolderPrefix + '*') } catch { return }
$cutoff = [System.DateTime]::UtcNow.AddHours(-1)
foreach ($folder in $folders) {
if ($ScriptRoot -and [string]::Equals([System.IO.Path]::GetFullPath($folder), [System.IO.Path]::GetFullPath($ScriptRoot), [System.StringComparison]::OrdinalIgnoreCase)) { continue }
try {
if ((Get-FolderState $folder) -eq 'Ours' -and [System.IO.Directory]::GetCreationTimeUtc($folder) -lt $cutoff) { $null = Remove-OwnTree -Path $folder }
} catch { }
}
}
# The copy that loaders before v1.3 leave at a fixed path. Never throws: it runs in a finally.
function Remove-LegacyInstallerCopy {
param([string]$ScriptPath)
try {
if ((Test-IsLegacyInstallerPath $ScriptPath) -and [System.IO.File]::Exists($ScriptPath)) {
[System.IO.File]::Delete($ScriptPath)
}
} catch { }
}
function Invoke-Bootstrap {
# One error preference, whatever the caller set; anything unexpected ends in the catch.
$ErrorActionPreference = 'Stop'
try {
if (-not (Test-IsElevated)) {
$message = "AutopilotMonitor bootstrap: not running as SYSTEM or administrator; nothing was changed."
Write-Host $message
[Console]::Error.WriteLine($message)
exit 1
}
# The bootstrap MSI starts PowerShell inside the agent folder: a working directory cannot
# be deleted, and PowerShell starts programs from its own location, which must not point
# at a folder that is gone by then.
[System.IO.Directory]::SetCurrentDirectory("$env:SystemRoot\System32")
Set-Location -LiteralPath "$env:SystemRoot\System32"
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
Write-Log "Bootstrap script version: v$ScriptVersion"
$decision = Get-BootstrapDecision -MaxBootstrapWindowHours $MaxBootstrapWindowHours -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes
$agent = Get-AgentPresence
if ($agent) {
Write-Log "Agent present ($agent): $AgentBasePath is left as it is."
Remove-StaleLoaderRunFolders -ScriptRoot $PSScriptRoot
exit 0
}
if (-not $decision.Install) {
Write-Log "No agent on this device: $(Remove-AgentBaseOnSkip -Path $AgentBasePath)"
Remove-StaleLoaderRunFolders -ScriptRoot $PSScriptRoot
exit 0
}
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (relaxActive=$($decision.RelaxActive))"
$loaderLogs = @(Read-LoaderLogs -ScriptPath $PSCommandPath -ScriptRoot $PSScriptRoot)
Initialize-AgentBase -Path $AgentBasePath
[System.IO.Directory]::CreateDirectory($AgentLogPath) | Out-Null
Write-LoaderLogs $loaderLogs
Open-LogFile
Write-Log "Agent folder prepared: $AgentBasePath (protected, emptied)."
Remove-StaleLoaderRunFolders -ScriptRoot $PSScriptRoot
$agentExePath = Join-Path $AgentBinPath $AgentExeName
$staging = New-RunFolder -Parent $AgentBasePath -Prefix 'Agent-Install-'
Write-Log "Downloading agent from $AgentDownloadUrl into $staging..."
try {
# The manifest sits next to the agent ZIP.
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
$expectedSha256 = $null
$manifestAttempts = 3
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
try {
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
if ($versionJsonResponse.sha256) {
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
} else {
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
}
break # manifest reached (with or without hash); retrying would not help
}
catch {
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
if ($attempt -lt $manifestAttempts) {
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
}
}
}
$zipPath = Join-Path $staging "AutopilotMonitor-Agent.zip"
$maxDownloadAttempts = 3
$downloadAttempt = 0
do {
$downloadAttempt++
try {
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
Invoke-WebRequest `
-Uri $AgentDownloadUrl `
-OutFile $zipPath `
-UseBasicParsing `
-TimeoutSec 30 `
-ErrorAction Stop
$downloadStopwatch.Stop()
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
break
}
catch {
if ($downloadAttempt -ge $maxDownloadAttempts) {
throw
}
$retryDelaysInSeconds = @(2, 4, 8)
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
Start-Sleep -Seconds $retryDelaySeconds
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
# No hash, no install.
if (-not $expectedSha256) {
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
}
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Log "Validating SHA-256 hash against manifest"
if ($actualSha256 -ne $expectedSha256) {
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
}
Write-Log "SHA-256 integrity check passed"
# The manifest comes from the same host as the ZIP; the signed catalog proves that
# every file comes from the publisher.
Write-Log "Verifying the payload against its signed catalog ($AgentCatalogName, publisher pin: $ExpectedPublisher)..."
Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $staging -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName
Write-Log "Payload verified against its signed catalog and installed to $AgentBinPath"
if (-not (Test-Path $agentExePath)) {
throw "Agent executable not found after extraction at $agentExePath"
}
Write-Log "Agent installation completed successfully"
}
catch {
Write-Log "ERROR downloading/verifying agent: $($_.Exception.Message)"
throw
}
finally {
$left = Remove-OwnTree -Path $staging
if ($left.Foreign.Count -gt 0 -or $left.Locked.Count -gt 0) {
Write-Log "Run folder $staging not fully removed ($($left.Foreign.Count + $left.Locked.Count) entries stay)."
}
}
Write-Log "Calling agent install mode (--install)..."
try {
$installExitCode = Invoke-AgentInstallMode -ExePath $agentExePath
}
catch [System.Management.Automation.ApplicationFailedException] {
# The program never started (bad directory, not a program, blocked by policy). An
# agent that ran owns its folder; one that never ran leaves only payload there, which
# every later run would take for an agent.
$left = Remove-OwnTree -Path $AgentBinPath
if ($left.Foreign.Count -eq 0 -and $left.Locked.Count -eq 0) { Write-Log "The agent did not start; $AgentBinPath removed." }
else { Write-Log "The agent did not start; $AgentBinPath not fully removed ($($left.Foreign.Count + $left.Locked.Count) entries stay)." }
throw
}
if ($installExitCode -ne 0) {
throw "Agent install failed with exit code $installExitCode"
}
Write-Log "Agent install mode completed successfully"
$runtimeProcessName = 'AutopilotMonitor.Agent'
$verifyTimeoutSec = 10
$verifyDeadline = (Get-Date).AddSeconds($verifyTimeoutSec)
$runtimeProc = $null
while ((Get-Date) -lt $verifyDeadline) {
$runtimeProc = Get-Process -Name $runtimeProcessName -ErrorAction SilentlyContinue | Select-Object -First 1
if ($null -ne $runtimeProc) { break }
Start-Sleep -Milliseconds 500
}
if ($null -ne $runtimeProc) {
$startedUtc = 'unavailable'
try { $startedUtc = $runtimeProc.StartTime.ToUniversalTime().ToString('o') } catch { }
Write-Log ("Runtime process verified: name={0}.exe pid={1} startedUtc={2}" -f $runtimeProcessName, $runtimeProc.Id, $startedUtc)
} else {
Write-Log ("WARNING: Runtime process verification FAILED. Agent --install reported success but no '{0}.exe' process appeared within {1}s. Likely silent block (AV/EDR, AppLocker/WDAC) of the runtime launch. Agent should still come up at next boot via the BootTrigger task. Check Event Viewer > Microsoft > Windows > TaskScheduler/Operational and AV/EDR logs for '{0}.exe'." -f $runtimeProcessName, $verifyTimeoutSec)
}
Write-Log "===== Bootstrap Completed Successfully ====="
exit 0
}
catch {
Write-Log "===== Bootstrap FAILED ====="
Write-Log "ERROR: $($_.Exception.Message)"
Write-Log "Stack trace: $($_.ScriptStackTrace)"
if ($LogState.Ready) { Write-Log "Please check log file: $LogFile" }
# One line: a failed program start carries PowerShell's position lines in its message.
$errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)" -replace '\s*\r?\n\s*', ' '
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 2045) + '...' }
[Console]::Error.WriteLine($errMsg)
exit 1
}
}
# Dot-sourcing (tests, release pipeline) loads the functions without running the bootstrap.
if ($MyInvocation.InvocationName -ne '.') {
try {
Invoke-Bootstrap
}
finally {
Remove-LegacyInstallerCopy -ScriptPath $PSCommandPath
}
}