mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
40323ca57e
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>
893 lines
42 KiB
PowerShell
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
|
|
}
|
|
}
|