diff --git a/.github/scripts/Invoke-BootstrapSystemPass.ps1 b/.github/scripts/Invoke-BootstrapSystemPass.ps1 new file mode 100644 index 00000000..48912434 --- /dev/null +++ b/.github/scripts/Invoke-BootstrapSystemPass.ps1 @@ -0,0 +1,72 @@ +<# +.SYNOPSIS + Runs the bootstrap integration tests once as SYSTEM, the account the bootstrap runs as on a device. + +.DESCRIPTION + Registers a one-time scheduled task as SYSTEM that runs + scripts/Bootstrap/Bootstrap.Integration.Tests.ps1 under Windows PowerShell 5.1, waits for it, + prints its output and fails when the run did not happen as SYSTEM or a test failed. Run as + SYSTEM, the tests use the real folder permissions, including Administrators as owner. + + CI only (bootstrap-script-gates.yml): it needs an elevated runner and Pester installed for all + users. +#> +[CmdletBinding()] +param( + [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path, + [int]$TimeoutMinutes = 20 +) + +$ErrorActionPreference = 'Stop' + +$work = Join-Path ([System.IO.Path]::GetTempPath()) "apm-system-pass-$PID" +New-Item -ItemType Directory -Force -Path $work | Out-Null +$runner = Join-Path $work 'run.ps1' +$output = Join-Path $work 'output.txt' +$result = Join-Path $work 'result.txt' +$tests = Join-Path $RepoRoot 'scripts\Bootstrap\Bootstrap.Integration.Tests.ps1' + +$runnerText = @' +$ErrorActionPreference = 'Stop' +$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value +try { + Import-Module Pester -MinimumVersion 5.5.0 + $configuration = New-PesterConfiguration + $configuration.Run.Path = '__TESTS__' + $configuration.Run.PassThru = $true + $configuration.Output.Verbosity = 'Detailed' + $run = Invoke-Pester -Configuration $configuration + Set-Content -LiteralPath '__RESULT__' -Value ('{0}|{1}|{2}' -f $identity, $run.PassedCount, $run.FailedCount) +} catch { + Set-Content -LiteralPath '__RESULT__' -Value ('{0}|error|{1}' -f $identity, $_.Exception.Message) +} +'@ +Set-Content -LiteralPath $runner -Encoding ASCII -Value ($runnerText.Replace('__TESTS__', $tests).Replace('__RESULT__', $result)) + +$taskName = "apm-bootstrap-system-pass-$PID" +$arguments = "-NoProfile -ExecutionPolicy Bypass -Command `"& '$runner' *> '$output'`"" +$action = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -Argument $arguments +$principal = New-ScheduledTaskPrincipal -UserId 'S-1-5-18' -LogonType ServiceAccount -RunLevel Highest +Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null +try { + Start-ScheduledTask -TaskName $taskName + $deadline = (Get-Date).AddMinutes($TimeoutMinutes) + do { + Start-Sleep -Seconds 5 + $state = (Get-ScheduledTask -TaskName $taskName).State + } while ($state -eq 'Running' -and (Get-Date) -lt $deadline) + if ($state -eq 'Running') { + Stop-ScheduledTask -TaskName $taskName + throw "The SYSTEM pass did not finish within $TimeoutMinutes minutes." + } +} finally { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false +} + +if (Test-Path -LiteralPath $output) { Get-Content -LiteralPath $output } +if (-not (Test-Path -LiteralPath $result)) { throw 'The SYSTEM pass wrote no result.' } +$identity, $passed, $failed = (Get-Content -LiteralPath $result -Raw).Trim().Split('|', 3) +if ($identity -ne 'S-1-5-18') { throw "The SYSTEM pass ran as $identity, not as SYSTEM." } +if ($passed -eq 'error') { throw "The SYSTEM pass could not run the tests: $failed" } +if ([int]$failed -gt 0 -or [int]$passed -eq 0) { throw "SYSTEM pass: $passed passed, $failed failed." } +Write-Host "SYSTEM pass: $passed passed, 0 failed." diff --git a/.github/scripts/Publish-BootstrapScripts.ps1 b/.github/scripts/Publish-BootstrapScripts.ps1 index bbee78c0..63ddde8c 100644 --- a/.github/scripts/Publish-BootstrapScripts.ps1 +++ b/.github/scripts/Publish-BootstrapScripts.ps1 @@ -17,7 +17,7 @@ cosmetics. Steps, in order: - 1. Parse $ScriptVersion from the loader and the bootstrap source. + 1. Parse the $ScriptVersion line from the loader and the bootstrap source. 2. Render the -Dev variants by literal substitution of the URL/manifest defaults. A missing anchor is a hard failure -- a silently un-substituted dev script would point the dev fleet at the stable agent. @@ -25,17 +25,21 @@ carries the same $ScriptVersion, abort. Otherwise the docs badge, version.json.bootstrapVersion and the portal's "your script is outdated" hint would all keep asserting a version that is no longer what ships. - 4. Build the publish set (loader, bootstrap, both dev renders, the extras). + 4. Build the publish set: both installers, then both loaders, then the extras. + A loader that went live before the installer it requires would refuse the + installer still being served. -Channel dev keeps only the two -Dev renders. 5. Authenticode-sign every script (see -SigningToken). Signing is the LAST transformation: it covers the exact CRLF/UTF-8 bytes that get uploaded, and nothing may touch them afterwards or the signature breaks. - 6. Upload every script blob with Cache-Control: no-cache (they rotate in + 6. Run the loader's own identity check on the exact bytes of both installers: + what fails here, every device would refuse. + 7. Upload every script blob with Cache-Control: no-cache (they rotate in place), mirrored fail-soft to the legacy account. - 7. Reconcile the version oracles: version.json.bootstrapVersion and - .loaderVersion (read-modify-write under If-Match, so a concurrent agent + 8. Reconcile the version oracles (release channel only): version.json.bootstrapVersion + and .loaderVersion (read-modify-write under If-Match, so a concurrent agent release cannot lose its agent fields) and AdminConfiguration.LatestBootstrapV2ScriptVersion. - 8. Verify through the alias -- re-download each blob and compare SHA-256. + 9. Verify through the alias -- re-download each blob and compare SHA-256. Comparisons against a published script always strip its signature block first (Get-ComparableText). A signature carries a timestamp, so signed bytes differ on @@ -67,6 +71,11 @@ Optional directory that receives a copy of every published file exactly as uploaded. Used by CI to offer the signed scripts as a build artifact for local inspection. +.PARAMETER Channel + release (default): every script, plus version.json and the portal's script version. + dev: only Start-AutopilotMonitor-Dev.ps1 and Install-AutopilotMonitor-Dev.ps1, so a + script change can run on dev devices, signed, before it goes to customers. + .EXAMPLE ./Publish-BootstrapScripts.ps1 -DryRun @@ -83,6 +92,8 @@ param( [string]$CertificateName, [string]$ExpectedPublisher, [string]$SignedOutputDir, + [ValidateSet('release', 'dev')] + [string]$Channel = 'release', [switch]$DryRun ) @@ -293,7 +304,9 @@ function Assert-VersionBump { function Get-BootstrapScriptVersion { param([string]$Content, [string]$Origin, [switch]$Optional) - if ($Content -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"') { return $Matches[1] } + # The assignment line itself, as the loader reads it: a comment or help text that mentions + # $ScriptVersion must not count. + if ($Content -match '(?m)^\$ScriptVersion[ \t]*=[ \t]*"([\d\.\-a-zA-Z]+)"[ \t]*\r?$') { return $Matches[1] } # Unreadable SOURCE is fatal. An unreadable PUBLISHED copy must not be: that is exactly # the state a republish is meant to repair, and a guard that blocks the repair is worse # than no guard. @@ -301,6 +314,19 @@ function Get-BootstrapScriptVersion { throw "Could not parse ScriptVersion from $Origin" } +# The loader's own check, run on the exact bytes that will be served: dot-sourced in this +# function's scope, so none of the loader's variables reach the publisher's. +function Assert-LoaderAcceptsInstaller { + param([object]$Item, [string]$WorkDir) + New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null + $path = Join-Path $WorkDir $Item.BlobName + [System.IO.File]::WriteAllBytes($path, [byte[]]$Item.Bytes) + . $LoaderSource + if (-not (Test-InstallerIdentity -Path $path)) { + throw "The loader would refuse $($Item.BlobName) (role or minimum version). Nothing was uploaded." + } +} + # What the script actually DOES, with comments and layout removed: the PowerShell token # stream minus comments, newlines and line continuations. Lets the version-bump guard tell # a behaviour change (must be versioned -- consumers act on the version) apart from a @@ -353,8 +379,11 @@ $devLoaderContent = $loaderContent.Replace($stableLoaderLiteral, $devLoaderLiter Write-Host "Rendered $LoaderDevBlob (points at $DevBlob)" # ------------------------------------------------------------------ 3. bump guards -Assert-VersionBump -BlobName $BootstrapBlob -SourceContent $sourceContent -SourceOrigin $BootstrapSource -SourceVersion $scriptVersion -Assert-VersionBump -BlobName $LoaderBlob -SourceContent $loaderContent -SourceOrigin $LoaderSource -SourceVersion $loaderVersion +# The versions are what customers act on, so the guard watches the release blobs only. +if ($Channel -eq 'release') { + Assert-VersionBump -BlobName $BootstrapBlob -SourceContent $sourceContent -SourceOrigin $BootstrapSource -SourceVersion $scriptVersion + Assert-VersionBump -BlobName $LoaderBlob -SourceContent $loaderContent -SourceOrigin $LoaderSource -SourceVersion $loaderVersion +} # ------------------------------------------------------------------ 4. publish set # Sources beyond the bootstrap pair, read straight from the repo. @@ -369,10 +398,15 @@ $extraSources = @( # Text is the unsigned publish form and stays the reference for every comparison against a # published copy; Bytes is what goes on the wire and carries the signature after step 4. $publishSet = [System.Collections.Generic.List[object]]::new() -$publishSet.Add([pscustomobject]@{ BlobName = $LoaderBlob; Text = $loaderContent; Bytes = (Get-PublishBytes $loaderContent) }) -$publishSet.Add([pscustomobject]@{ BlobName = $LoaderDevBlob; Text = $devLoaderContent; Bytes = (Get-PublishBytes $devLoaderContent) }) $publishSet.Add([pscustomobject]@{ BlobName = $BootstrapBlob; Text = $sourceContent; Bytes = $sourceBytes }) $publishSet.Add([pscustomobject]@{ BlobName = $DevBlob; Text = $devContent; Bytes = (Get-PublishBytes $devContent) }) +$publishSet.Add([pscustomobject]@{ BlobName = $LoaderBlob; Text = $loaderContent; Bytes = (Get-PublishBytes $loaderContent) }) +$publishSet.Add([pscustomobject]@{ BlobName = $LoaderDevBlob; Text = $devLoaderContent; Bytes = (Get-PublishBytes $devLoaderContent) }) +if ($Channel -eq 'dev') { + $publishSet = [System.Collections.Generic.List[object]]@($publishSet | Where-Object { $_.BlobName -in @($DevBlob, $LoaderDevBlob) }) + $extraSources = @() + Write-Host "Channel dev: publishing $DevBlob and $LoaderDevBlob only" +} foreach ($extra in $extraSources) { $extraPath = Join-Path $RepoRoot $extra.Path if (-not (Test-Path $extraPath)) { @@ -391,13 +425,18 @@ if ($SigningEnabled) { Write-Host '::warning::No signing token supplied -- dry run continues with unsigned scripts.' } +# ------------------------------------------------------------------ 6. loader check +foreach ($item in @($publishSet | Where-Object { $_.BlobName -in @($BootstrapBlob, $DevBlob) })) { + Assert-LoaderAcceptsInstaller -Item $item -WorkDir (Join-Path ([System.IO.Path]::GetTempPath()) "apm-publish-check-$PID") +} + $legacySas = if ($LegacySasToken) { $LegacySasToken.TrimStart('?') } else { '' } # Acquired lazily: a dry run must stay runnable without any Azure login -- the PR gate # (bootstrap-script-gates.yml) executes this script with -DryRun and never logs in. $authHeaders = if ($DryRun) { $null } else { & (Join-Path $PSScriptRoot 'Get-StorageAuthHeaders.ps1') } -# ------------------------------------------------------------------ 6. upload +# ------------------------------------------------------------------ 7. upload foreach ($item in $publishSet) { # Explicit type, not a convenience: an Object[] body is uploaded as space-separated # decimals rather than raw bytes, and every hash check still passes because [byte[]] @@ -464,11 +503,12 @@ foreach ($item in $publishSet) { } } -# ------------------------------------------------------------------ 7. version oracles +# ------------------------------------------------------------------ 8. version oracles # version.json is the agent manifest; only bootstrapVersion belongs to us. Read from the # blob (not the alias) for an authoritative ETag, write back under If-Match so a concurrent -# agent release cannot lose its version/sha256 fields. -if (-not $DryRun) { +# agent release cannot lose its version/sha256 fields. They describe the release scripts, so +# a dev publish leaves them alone. +if (-not $DryRun -and $Channel -eq 'release') { try { $manifestResp = Invoke-WebRequest -Uri "$ContainerUrl/version.json" -Method Get -UseBasicParsing $manifestRaw = if ($manifestResp.Content -is [byte[]]) { @@ -512,7 +552,9 @@ if (-not $DryRun) { } } -if ($DryRun) { +if ($Channel -ne 'release') { + Write-Host "Channel dev: version.json and AdminConfiguration.LatestBootstrapV2ScriptVersion stay as they are" +} elseif ($DryRun) { Write-Host " [dry-run] would set AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion" } else { $tableHeaders = @{ @@ -525,7 +567,7 @@ if ($DryRun) { Write-Host "AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion" } -# ------------------------------------------------------------------ 8. verify via alias +# ------------------------------------------------------------------ 9. verify via alias if (-not $DryRun) { $pending = [System.Collections.ArrayList]::new() $publishSet | ForEach-Object { [void]$pending.Add($_) } @@ -555,7 +597,7 @@ if (-not $DryRun) { if ($env:GITHUB_STEP_SUMMARY) { $mode = if ($DryRun) { 'DRY RUN -- nothing uploaded' } else { 'published + verified via the download alias' } $summary = @( - "### Bootstrap scripts: $mode", + "### Bootstrap scripts: $mode (channel $Channel)", '', "Loader script version: **$loaderVersion** | Bootstrap script version: **$scriptVersion**", "Signed: **$(if ($SigningEnabled) { "yes, certificate '$CertificateName'" } else { 'NO (unsigned dry run)' })**", diff --git a/.github/scripts/Test-BootstrapAttacker.ps1 b/.github/scripts/Test-BootstrapAttacker.ps1 new file mode 100644 index 00000000..2188e4ab --- /dev/null +++ b/.github/scripts/Test-BootstrapAttacker.ps1 @@ -0,0 +1,176 @@ +<# +.SYNOPSIS + Checks the bootstrap's folders against a real standard user. + +.DESCRIPTION + Creates a temporary local standard user and, with that user's token, tries what any local user + on an enrolling device could try against the folders the loader and the installer create: + write, change, rename and delete files, add entries, move or delete the folder. Every attempt + must be denied. Folders the user creates first, or entries the user plants in an unprotected + agent folder of an older installer, must make the installer stop instead of installing into + them. A control attempt proves the user can write where users may. + + The folders come from the installer's own functions, in a scratch copy of ProgramData that + carries the real ProgramData permissions. + + CI only (bootstrap-script-gates.yml): it needs an elevated runner, and it creates a local + account and removes it again. +#> +[CmdletBinding()] +param( + [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path +) + +$ErrorActionPreference = 'Stop' + +# The user's token, for impersonation in this process (a CI helper; customer scripts never +# compile code). +Add-Type -Namespace ApmCi -Name Native -MemberDefinition @' +[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] +public static extern bool LogonUser(string user, string domain, string password, int logonType, int logonProvider, out Microsoft.Win32.SafeHandles.SafeAccessTokenHandle token); +'@ + +. (Join-Path $RepoRoot 'scripts\Bootstrap\Install-AutopilotMonitor.ps1') + +$results = New-Object System.Collections.Generic.List[object] + +function Invoke-AsUser { + param([scriptblock]$Action) + [System.Security.Principal.WindowsIdentity]::RunImpersonated($script:token, [Action]$Action) +} + +# An attempt the user must not get through: it has to fail with "access denied", not with +# any other error that would only hide a broken test. +function Assert-Denied { + param([string]$Name, [scriptblock]$Attempt) + $outcome = 'allowed' + try { + Invoke-AsUser $Attempt + } catch { + $base = $_.Exception.GetBaseException() + $outcome = if ($base -is [System.UnauthorizedAccessException] -or $base.HResult -eq -2147024891) { 'denied' } else { "error: $($base.Message)" } + } + $results.Add([pscustomobject]@{ Check = $Name; Expected = 'denied'; Actual = $outcome }) +} + +function Assert-That { + param([string]$Name, [bool]$Condition, [string]$Detail = '') + $results.Add([pscustomobject]@{ Check = $Name; Expected = 'true'; Actual = $(if ($Condition) { 'true' } else { "false $Detail".Trim() }) }) +} + +# What a bootstrap running as SYSTEM leaves as owner; the default for other admin-created +# entries differs between Windows editions. +function Set-AdministratorsOwner { + param([string]$Path) + $security = (Get-Item -LiteralPath $Path -Force).GetAccessControl('Owner') + $security.SetOwner((New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544'))) + (Get-Item -LiteralPath $Path -Force).SetAccessControl($security) +} + +$name = 'apm-ci-' + [guid]::NewGuid().ToString('N').Substring(0, 8) +$password = [guid]::NewGuid().ToString('N') + 'Aa1!' +$root = Join-Path $env:SystemDrive ('apm-ci-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) +$pd = Join-Path $root 'ProgramData' + +New-LocalUser -Name $name -Password (ConvertTo-SecureString $password -AsPlainText -Force) -AccountNeverExpires -PasswordNeverExpires | Out-Null +try { + try { Add-LocalGroupMember -SID 'S-1-5-32-545' -Member $name } catch { if ($_.FullyQualifiedErrorId -notlike 'MemberExists*') { throw } } + $token = $null + # Interactive logon first (what a local user has); network logon where a server allows no + # local logon for Users. + if (-not [ApmCi.Native]::LogonUser($name, '.', $password, 2, 0, [ref]$token) -and + -not [ApmCi.Native]::LogonUser($name, '.', $password, 3, 0, [ref]$token)) { + throw "LogonUser failed with Win32 error $([System.Runtime.InteropServices.Marshal]::GetLastWin32Error())." + } + $script:token = $token + $userSid = (New-Object System.Security.Principal.NTAccount($name)).Translate([System.Security.Principal.SecurityIdentifier]) + $script:probe = $null + Invoke-AsUser { $script:probe = [System.Security.Principal.WindowsIdentity]::GetCurrent() } + Assert-That 'impersonation runs as the test user' ($script:probe.User -eq $userSid) "$($script:probe.User)" + Assert-That 'the test user is no administrator' (-not (New-Object System.Security.Principal.WindowsPrincipal($script:probe)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)) + + # A scratch ProgramData with the real one's permissions. + New-Item -ItemType Directory -Path $pd -Force | Out-Null + $security = New-Object System.Security.AccessControl.DirectorySecurity + $security.SetSecurityDescriptorSddlForm((Get-Acl -LiteralPath $env:ProgramData).GetSecurityDescriptorSddlForm('Access'), 'Access') + # Nothing from the drive root, whose default lets authenticated users change subfolders. + $security.SetAccessRuleProtection($true, $false) + (Get-Item -LiteralPath $pd).SetAccessControl($security) + + # Control: the user may create in ProgramData, so every denial below is the folders' doing. + $control = Join-Path $pd 'user-may-create-here' + Invoke-AsUser { [System.IO.Directory]::CreateDirectory($control) | Out-Null; [System.IO.File]::WriteAllText((Join-Path $control 'x.txt'), 'x') } + Assert-That 'control: the user can create in ProgramData' (Test-Path -LiteralPath (Join-Path $control 'x.txt')) + + # The loader's run folder with a downloaded installer in it. + $run = New-RunFolder -Parent $pd -Prefix 'AutopilotMonitor-Loader-' + $download = Join-Path $run 'Install-AutopilotMonitor.ps1' + [System.IO.File]::WriteAllText($download, 'verified installer') + Assert-Denied 'run folder: add a file' { [System.IO.File]::WriteAllText((Join-Path $run 'planted.ps1'), 'x') } + Assert-Denied 'run folder: add a folder or link' { [System.IO.Directory]::CreateDirectory((Join-Path $run 'planted')) | Out-Null } + Assert-Denied 'run folder: change the download' { [System.IO.File]::AppendAllText($download, 'tampered') } + Assert-Denied 'run folder: replace the download' { [System.IO.File]::WriteAllText($download, 'replaced') } + Assert-Denied 'run folder: rename the download' { [System.IO.File]::Move($download, $download + '.old') } + Assert-Denied 'run folder: delete the download' { [System.IO.File]::Delete($download) } + Assert-Denied 'run folder: rename the folder' { [System.IO.Directory]::Move($run, $run + '-moved') } + Assert-Denied 'run folder: delete the folder' { [System.IO.Directory]::Delete($run, $true) } + Assert-That 'run folder: the download is unchanged' ([System.IO.File]::ReadAllText($download) -eq 'verified installer') + + # The agent folder an install prepares. + $base = Join-Path $pd 'AutopilotMonitor' + Initialize-AgentBase -Path $base + $logs = [System.IO.Directory]::CreateDirectory((Join-Path $base 'Logs')).FullName + [System.IO.File]::WriteAllText((Join-Path $logs 'bootstrap-script.log'), 'log') + Assert-Denied 'agent folder: add a file' { [System.IO.File]::WriteAllText((Join-Path $base 'planted.dll'), 'x') } + Assert-Denied 'agent folder: add a folder or link' { [System.IO.Directory]::CreateDirectory((Join-Path $base 'Agent')) | Out-Null } + Assert-Denied 'agent folder: change a log' { [System.IO.File]::AppendAllText((Join-Path $logs 'bootstrap-script.log'), 'x') } + Assert-Denied 'agent folder: delete a log' { [System.IO.File]::Delete((Join-Path $logs 'bootstrap-script.log')) } + Assert-Denied 'agent folder: rename the folder' { [System.IO.Directory]::Move($base, $base + '-moved') } + $owner = (Get-Item -LiteralPath $base).GetAccessControl('Owner').GetOwner([System.Security.Principal.SecurityIdentifier]).Value + Assert-That 'agent folder: owned by Administrators' ($owner -eq 'S-1-5-32-544') $owner + [System.IO.Directory]::Delete($base, $true) + + # The user creates the agent folder first: the installer must neither install into it nor + # remove it. + Invoke-AsUser { [System.IO.Directory]::CreateDirectory($base) | Out-Null; [System.IO.File]::WriteAllText((Join-Path $base 'planted.dll'), 'x') } + Assert-That 'squatted agent folder: seen as another account''s' ((Get-FolderState $base) -eq 'Foreign') + $refused = $false + try { Initialize-AgentBase -Path $base } catch { $refused = $_.Exception.Message -like '*(Foreign); not installing into it.' } + Assert-That 'squatted agent folder: the install refuses it' $refused + Assert-That 'squatted agent folder: a skip leaves it' ((Remove-AgentBaseOnSkip -Path $base) -like '*left as it is (Foreign).') + Assert-That 'squatted agent folder: the planted file stays' (Test-Path -LiteralPath (Join-Path $base 'planted.dll')) + [System.IO.Directory]::Delete($base, $true) + + # An agent folder an older installer created without protection: the user plants a file, + # the install protects the folder and stops at the planted file. + [System.IO.Directory]::CreateDirectory($base) | Out-Null + [System.IO.File]::WriteAllText((Join-Path $base 'old-installer.log'), 'old') + Set-AdministratorsOwner $base + Set-AdministratorsOwner (Join-Path $base 'old-installer.log') + Invoke-AsUser { [System.IO.File]::WriteAllText((Join-Path $base 'planted.dll'), 'x') } + $refused = $false + try { Initialize-AgentBase -Path $base } catch { $refused = $_.Exception.Message -like '*holds entries another account created*' } + Assert-That 'planted entry: the install stops' $refused + Assert-That 'planted entry: own leftovers are gone, the planted file stays' ((-not (Test-Path -LiteralPath (Join-Path $base 'old-installer.log'))) -and (Test-Path -LiteralPath (Join-Path $base 'planted.dll'))) + Assert-Denied 'planted entry: the protected folder takes no more' { [System.IO.File]::WriteAllText((Join-Path $base 'second.dll'), 'x') } + [System.IO.Directory]::Delete($base, $true) + + # The user creates a run folder name first: it is never used. + $squatted = Join-Path $pd 'AutopilotMonitor-Loader-00000000' + Invoke-AsUser { [System.IO.Directory]::CreateDirectory($squatted) | Out-Null } + $script:names = [System.Collections.Generic.Queue[string]]::new([string[]]@('00000000', '11111111')) + function New-RunFolderName { return $script:names.Dequeue() } + $fresh = New-RunFolder -Parent $pd -Prefix 'AutopilotMonitor-Loader-' + Assert-That 'squatted run folder: a new name is drawn' ($fresh -eq (Join-Path $pd 'AutopilotMonitor-Loader-11111111')) $fresh + $squattedOwner = (Get-Item -LiteralPath $squatted).GetAccessControl('Owner').GetOwner([System.Security.Principal.SecurityIdentifier]) + Assert-That 'squatted run folder: still the user''s, untouched' ($squattedOwner -eq $userSid) +} finally { + if ($script:token) { $script:token.Dispose() } + Remove-LocalUser -Name $name -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } +} + +$results | Format-Table -AutoSize | Out-String -Width 200 | Write-Host +$failed = @($results | Where-Object { $_.Actual -ne $_.Expected }) +if ($failed.Count -gt 0) { throw "Attacker test: $($failed.Count) of $($results.Count) checks failed." } +Write-Host "Attacker test: all $($results.Count) checks passed." diff --git a/.github/workflows/bootstrap-script-gates.yml b/.github/workflows/bootstrap-script-gates.yml index 6a9c9b07..45847c1d 100644 --- a/.github/workflows/bootstrap-script-gates.yml +++ b/.github/workflows/bootstrap-script-gates.yml @@ -15,21 +15,28 @@ permissions: jobs: gates: - name: Bootstrap guard tests (PS 5.1) - runs-on: windows-latest + name: Bootstrap guard tests (PS 5.1, ${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + # Folder permissions and Windows PowerShell 5.1 come with the OS, so both server images. + os: [windows-2022, windows-2025] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + # For all users: the SYSTEM pass below runs Pester as SYSTEM. - name: Install Pester 5+ shell: powershell run: | - Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null - Install-Module Pester -Scope CurrentUser -Force -SkipPublisherCheck -MinimumVersion 5.5.0 + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope AllUsers | Out-Null + Install-Module Pester -Scope AllUsers -Force -SkipPublisherCheck -MinimumVersion 5.5.0 # Windows PowerShell 5.1 on purpose: that is the runtime IME uses to execute # platform scripts, so guard/relax logic is verified without resetting a device. + # The runner is elevated, so the integration tests use the real folder permissions. - name: Run Pester shell: powershell run: | @@ -40,11 +47,20 @@ jobs: $c.Output.Verbosity = 'Detailed' Invoke-Pester -Configuration $c + # On a device the bootstrap runs as SYSTEM: the integration tests once more as SYSTEM. + - name: Integration tests as SYSTEM + shell: powershell + run: ./.github/scripts/Invoke-BootstrapSystemPass.ps1 + + - name: Folder permissions against a real standard user + shell: powershell + run: ./.github/scripts/Test-BootstrapAttacker.ps1 + - name: Guard scripts stay pure ASCII shell: powershell run: | $failed = $false - Get-ChildItem 'scripts/Bootstrap' -Filter '*.ps1' | ForEach-Object { + Get-ChildItem 'scripts/Bootstrap' -Filter '*.ps1' -Recurse | ForEach-Object { $bytes = [System.IO.File]::ReadAllBytes($_.FullName) for ($i = 0; $i -lt $bytes.Length; $i++) { if ($bytes[$i] -gt 127) { @@ -57,6 +73,15 @@ jobs: if ($failed) { exit 1 } Write-Host 'All bootstrap scripts are pure ASCII.' + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: '10.0.x' + + # The names and log lines the scripts share with the agent, checked against the agent's + # own code. + - name: Script contracts with the agent + run: dotnet test src/Agent/AutopilotMonitor.Agent.V2.Core.Tests --configuration Release --filter "Category=BootstrapContract" --logger "console;verbosity=normal" + # The publish chain renders the -Dev variant by literal substitution and refuses to # ship when an anchor moved. Running it dry here surfaces that (and any drift against # the alias) on the PR instead of on the push that would have published. diff --git a/.github/workflows/pester.yml b/.github/workflows/pester.yml index 034243ad..b79d76e1 100644 --- a/.github/workflows/pester.yml +++ b/.github/workflows/pester.yml @@ -14,7 +14,12 @@ on: - 'scripts/Bootstrap/**' - 'scripts/CustomerSetup/**' - '.github/scripts/Publish-BootstrapScripts.ps1' + - '.github/scripts/Invoke-BootstrapSystemPass.ps1' + - '.github/scripts/Test-BootstrapAttacker.ps1' - '.github/workflows/bootstrap-script-gates.yml' + # The integration tests run the generated OOBE script and the MSI's command line. + - 'src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1' + - 'src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs' workflow_dispatch: permissions: diff --git a/.github/workflows/publish-scripts.yml b/.github/workflows/publish-scripts.yml index e99861fb..8eb39efc 100644 --- a/.github/workflows/publish-scripts.yml +++ b/.github/workflows/publish-scripts.yml @@ -23,6 +23,14 @@ on: required: true type: boolean default: false + channel: + description: 'release: every script. dev: only the two -Dev scripts, to try a change on dev devices first' + required: true + type: choice + options: + - release + - dev + default: release permissions: contents: read @@ -113,13 +121,16 @@ jobs: DOTNET_ROLL_FORWARD: LatestMajor run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1 + # A push publishes the release channel; only a dispatch can choose dev. - name: Publish bootstrap + customer scripts shell: pwsh env: DOTNET_ROLL_FORWARD: LatestMajor + PUBLISH_CHANNEL: ${{ inputs.channel || 'release' }} run: | $dryRun = '${{ inputs.dry_run }}' -eq 'true' ./.github/scripts/Publish-BootstrapScripts.ps1 ` + -Channel $env:PUBLISH_CHANNEL ` -LegacySasToken '${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}' ` -SigningToken $env:CODE_SIGN_AKV_ACCESS_TOKEN ` -KeyVaultUrl $env:CODE_SIGN_KEY_VAULT_URL ` diff --git a/scripts/Bootstrap/Bootstrap.Integration.Tests.ps1 b/scripts/Bootstrap/Bootstrap.Integration.Tests.ps1 new file mode 100644 index 00000000..f018d823 --- /dev/null +++ b/scripts/Bootstrap/Bootstrap.Integration.Tests.ps1 @@ -0,0 +1,733 @@ +# Integration tests (Pester 5+) for the bootstrap chain: the real loader and installer, each +# started the way Intune and the bootstrap MSI start them -- powershell.exe -File, in a process +# of its own -- against a scratch ProgramData and TEMP. Loader 1.2 and the MSI 1.0.x runner run +# from TestData, frozen from git: devices keep running them until customers replace them. +# +# What these tests do not cover is swapped out by global aliases, which win over cmdlets and +# over functions a script defines: downloads (served from a scratch folder), signatures, the +# catalog check, the guard decision, the deployment marker, the agent task, the agent's +# --install and sleeps. Every swap writes a trace line, and the tests check the lines they rely +# on, so a swap that stops working turns a test red instead of reaching the real machine. +# +# Run elevated (CI also runs it as SYSTEM), the folder permissions are the real ones. Without +# elevation the test account may not make Administrators the owner, so the protected DACL is +# swapped for one without that owner change, and folders the account owns count as ours. +# Entries whose name starts with "foreign" count as another account's. +# +# This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI). + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + $script:loaderPath = Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1' + $script:installerPath = Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1' + $script:loader12Path = Join-Path $PSScriptRoot 'TestData\Start-AutopilotMonitor-1.2.ps1' + $script:msiRunnerPath = Join-Path $PSScriptRoot 'TestData\Invoke-BootstrapDownload-1.0.ps1' + # The backend's pin of the generated OOBE script, test values filled in. + $script:oobeScriptPath = Join-Path $script:repoRoot 'src\Backend\AutopilotMonitor.Functions.Tests\TestData\oobe-bootstrap-golden.ps1' + $script:powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $script:isElevated = (New-Object System.Security.Principal.WindowsPrincipal([System.Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) + $script:payloadFiles = @('AutopilotMonitor.Agent.Core.dll', 'AutopilotMonitor.Agent.cat', 'AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.exe.config') + + # The agent package: text files under the agent's names. --install is swapped out, so + # nothing in it ever runs. + $payload = Join-Path $TestDrive 'payload' + New-Item -ItemType Directory -Path $payload | Out-Null + foreach ($name in $script:payloadFiles) { Set-Content -LiteralPath (Join-Path $payload $name) -Value "stub $name" } + $script:agentZip = Join-Path $TestDrive 'AutopilotMonitor-Agent.zip' + Compress-Archive -Path (Join-Path $payload '*') -DestinationPath $script:agentZip + $script:agentZipSha256 = (Get-FileHash -LiteralPath $script:agentZip -Algorithm SHA256).Hash.ToLowerInvariant() + + $script:wrapperText = @' +$global:HarnessDir = $PSScriptRoot +$global:HarnessOptions = [System.IO.File]::ReadAllText((Join-Path $PSScriptRoot 'options.json')) | ConvertFrom-Json + +function global:Write-HarnessTrace { + param([string]$Name, [string]$Detail) + [System.IO.File]::AppendAllText((Join-Path $global:HarnessDir 'trace.txt'), "$Name|$Detail`r`n") +} + +function global:Get-HarnessServerFile { + param([string]$Uri) + $source = Join-Path $global:HarnessOptions.Server ($Uri.Substring($Uri.LastIndexOf('/') + 1)) + if (-not [System.IO.File]::Exists($source)) { throw "The remote server returned an error: (404) Not Found. ($Uri)" } + return $source +} + +function global:HarnessInvokeWebRequest { + [CmdletBinding()] + param([string]$Uri, [string]$OutFile, [switch]$UseBasicParsing, [int]$TimeoutSec, [string]$Method) + Write-HarnessTrace 'Invoke-WebRequest' $Uri + [System.IO.File]::Copy((Get-HarnessServerFile $Uri), $OutFile, $true) +} + +function global:HarnessInvokeRestMethod { + [CmdletBinding()] + param([string]$Uri, [switch]$UseBasicParsing, [int]$TimeoutSec) + Write-HarnessTrace 'Invoke-RestMethod' $Uri + return ([System.IO.File]::ReadAllText((Get-HarnessServerFile $Uri)) | ConvertFrom-Json) +} + +function global:HarnessGetAuthenticodeSignature { + [CmdletBinding()] + param([string]$FilePath) + Write-HarnessTrace 'Get-AuthenticodeSignature' $FilePath + return [pscustomobject]@{ + Status = $global:HarnessOptions.SignatureStatus + SignerCertificate = [pscustomobject]@{ Subject = $global:HarnessOptions.SignerSubject; Thumbprint = 'HARNESS' } + } +} + +function global:HarnessTestFileCatalog { + [CmdletBinding()] + param([string]$Path, [string]$CatalogFilePath, [string[]]$FilesToSkip, [switch]$Detailed) + Write-HarnessTrace 'Test-FileCatalog' $Path + return [pscustomobject]@{ + Status = $(if ($global:HarnessOptions.CatalogValid) { 'Valid' } else { 'ValidationFailed' }) + Signature = [pscustomobject]@{ Status = 'Valid'; SignerCertificate = [pscustomobject]@{ Subject = 'CN=glueckkanja AG, O=glueckkanja AG, C=DE' } } + CatalogItems = @{} + PathItems = @{} + } +} + +function global:HarnessGetBootstrapDecision { + param([int]$MaxBootstrapWindowHours, [int]$OobeProfileMaxAgeMinutes) + Write-HarnessTrace 'Get-BootstrapDecision' "MaxBootstrapWindowHours=$MaxBootstrapWindowHours" + $install = [bool]$global:HarnessOptions.Install + return [pscustomobject]@{ Install = $install; ReasonCode = $(if ($install) { $null } else { 'ProfilesFound' }); RelaxActive = $false } +} + +function global:HarnessGetRegistryValue64 { + param([string]$SubKey, [string]$Name) + Write-HarnessTrace 'Get-RegistryValue64' "$SubKey\$Name" + if ($SubKey -eq 'SOFTWARE\AutopilotMonitor' -and $Name -eq 'Deployed') { return $global:HarnessOptions.Marker } + return $null +} + +function global:HarnessGetScheduledTask { + [CmdletBinding()] + param([string]$TaskName) + Write-HarnessTrace 'Get-ScheduledTask' $TaskName + if ($global:HarnessOptions.TaskPresent) { return [pscustomobject]@{ TaskName = $TaskName } } + throw (New-Object System.Management.Automation.ErrorRecord((New-Object System.Exception "No MSFT_ScheduledTask objects found with property 'TaskName' equal to '$TaskName'."), 'CmdletizationQuery_NotFound_TaskName,Get-ScheduledTask', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $TaskName)) +} + +function global:HarnessInvokeAgentInstallMode { + param([string]$ExePath, [string[]]$Arguments = @('--install')) + Write-HarnessTrace 'Invoke-AgentInstallMode' ('{0} {1} exists={2}' -f $ExePath, ($Arguments -join ' '), [System.IO.File]::Exists($ExePath)) + return [int]$global:HarnessOptions.InstallExitCode +} + +function global:HarnessGetProcess { + [CmdletBinding()] + param([string[]]$Name) + if ($Name -contains 'AutopilotMonitor.Agent') { + Write-HarnessTrace 'Get-Process' 'AutopilotMonitor.Agent' + return [pscustomobject]@{ Id = 4242; StartTime = Get-Date } + } + return Microsoft.PowerShell.Management\Get-Process @PSBoundParameters +} + +function global:HarnessStartSleep { + [CmdletBinding()] + param([int]$Seconds, [int]$Milliseconds) + Write-HarnessTrace 'Start-Sleep' "$Seconds s $Milliseconds ms" +} + +function global:HarnessTestIsElevated { + Write-HarnessTrace 'Test-IsElevated' "$($global:HarnessOptions.Elevated)" + return [bool]$global:HarnessOptions.Elevated +} + +# The protected DACL without the owner change a standard account may not make. +function global:HarnessNewProtectedDirectorySecurity { + $security = New-Object System.Security.AccessControl.DirectorySecurity + $security.SetAccessRuleProtection($true, $false) + $inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' + $propagation = [System.Security.AccessControl.PropagationFlags]::None + foreach ($sid in @([System.Security.Principal.WindowsIdentity]::GetCurrent().User, (New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18')))) { + $security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', $inherit, $propagation, 'Allow'))) + } + $security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule((New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-545')), 'ReadAndExecute', $inherit, $propagation, 'Allow'))) + return $security +} + +function global:HarnessTestIsOwnedByUs { + param([string]$Path) + if ([System.IO.Path]::GetFileName($Path) -like 'foreign*') { return $false } + try { + $owner = (Get-Item -LiteralPath $Path -Force -ErrorAction Stop).GetAccessControl([System.Security.AccessControl.AccessControlSections]::Owner).GetOwner([System.Security.Principal.SecurityIdentifier]) + return ($owner.Value -eq 'S-1-5-18' -or $owner.Value -eq 'S-1-5-32-544' -or $owner -eq [System.Security.Principal.WindowsIdentity]::GetCurrent().User) + } catch { + return $false + } +} + +$swaps = @{ + 'Invoke-WebRequest' = 'HarnessInvokeWebRequest' + 'Invoke-RestMethod' = 'HarnessInvokeRestMethod' + 'Get-AuthenticodeSignature' = 'HarnessGetAuthenticodeSignature' + 'Test-FileCatalog' = 'HarnessTestFileCatalog' + 'Get-BootstrapDecision' = 'HarnessGetBootstrapDecision' + 'Get-RegistryValue64' = 'HarnessGetRegistryValue64' + 'Get-ScheduledTask' = 'HarnessGetScheduledTask' + 'Invoke-AgentInstallMode' = 'HarnessInvokeAgentInstallMode' + 'Get-Process' = 'HarnessGetProcess' + 'Start-Sleep' = 'HarnessStartSleep' + 'Test-IsOwnedByUs' = 'HarnessTestIsOwnedByUs' +} +if ($global:HarnessOptions.SwapElevation) { $swaps['Test-IsElevated'] = 'HarnessTestIsElevated' } +if ($global:HarnessOptions.SwapDacl) { $swaps['New-ProtectedDirectorySecurity'] = 'HarnessNewProtectedDirectorySecurity' } +foreach ($name in $swaps.Keys) { Set-Alias -Name $name -Value $swaps[$name] -Scope Global -Force } + +# The OOBE script arrives as irm | iex, which ignores #Requires. +if ($global:HarnessOptions.Iex) { + Invoke-Expression ([System.IO.File]::ReadAllText($global:HarnessOptions.Script)) +} else { + & $global:HarnessOptions.Script @args +} +exit $LASTEXITCODE +'@ + + function New-Scenario { + $root = Join-Path $TestDrive ('s' + [guid]::NewGuid().ToString('N').Substring(0, 7)) + $scenario = [pscustomobject]@{ + Root = $root + ProgramData = Join-Path $root 'ProgramData' + Base = Join-Path $root 'ProgramData\AutopilotMonitor' + Temp = Join-Path $root 'Temp' + Server = Join-Path $root 'Server' + Outside = Join-Path $root 'Outside' + Ime = Join-Path $root 'Ime' + } + foreach ($dir in @($scenario.ProgramData, $scenario.Temp, $scenario.Server, $scenario.Outside, $scenario.Ime)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } + Copy-Item -LiteralPath $script:installerPath -Destination (Join-Path $scenario.Server 'Install-AutopilotMonitor.ps1') + Copy-Item -LiteralPath $script:agentZip -Destination (Join-Path $scenario.Server 'AutopilotMonitor-Agent.zip') + Set-Content -LiteralPath (Join-Path $scenario.Server 'version.json') -Value ('{"version":"2.0.9999","sha256":"' + $script:agentZipSha256 + '"}') + return $scenario + } + + function Invoke-Scenario { + param( + $Scenario, + [string]$Script, + [string[]]$Arguments = @(), + [string]$WorkingDirectory, + [hashtable]$Options = @{} + ) + $settings = @{ + Script = $Script + Server = $Scenario.Server + Install = $true + Marker = $null + TaskPresent = $false + InstallExitCode = 0 + SignatureStatus = 'Valid' + SignerSubject = 'CN=glueckkanja AG, O=glueckkanja AG, C=DE' + CatalogValid = $true + Elevated = $true + Iex = $false + } + foreach ($key in $Options.Keys) { $settings[$key] = $Options[$key] } + $settings.SwapElevation = ([bool]$settings.Elevated -ne $script:isElevated) + $settings.SwapDacl = -not $script:isElevated + + $harness = Join-Path $Scenario.Root 'Harness' + New-Item -ItemType Directory -Path $harness -Force | Out-Null + $tracePath = Join-Path $harness 'trace.txt' + if (Test-Path -LiteralPath $tracePath) { Remove-Item -LiteralPath $tracePath } + [System.IO.File]::WriteAllText((Join-Path $harness 'options.json'), ($settings | ConvertTo-Json)) + [System.IO.File]::WriteAllText((Join-Path $harness 'run.ps1'), $script:wrapperText) + + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $script:powershell + $quoted = @($Arguments | ForEach-Object { if ($_ -match '\s') { '"' + $_ + '"' } else { $_ } }) + $psi.Arguments = ('-NoProfile -ExecutionPolicy Bypass -File "{0}" {1}' -f (Join-Path $harness 'run.ps1'), ($quoted -join ' ')).Trim() + $psi.WorkingDirectory = $(if ($WorkingDirectory) { $WorkingDirectory } else { $Scenario.Root }) + $psi.UseShellExecute = $false + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.EnvironmentVariables['ProgramData'] = $Scenario.ProgramData + $psi.EnvironmentVariables['ALLUSERSPROFILE'] = $Scenario.ProgramData + $psi.EnvironmentVariables['TEMP'] = $Scenario.Temp + $psi.EnvironmentVariables['TMP'] = $Scenario.Temp + $process = [System.Diagnostics.Process]::Start($psi) + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(300000)) { $process.Kill(); throw 'The harness run did not end within five minutes.' } + $process.WaitForExit() + + return [pscustomobject]@{ + ExitCode = $process.ExitCode + StdOut = $stdout.Result + StdErr = $stderr.Result + Trace = @(if (Test-Path -LiteralPath $tracePath) { Get-Content -LiteralPath $tracePath }) + } + } + + # Every entry below $Root, links as links and never followed; run-folder ids as . + # -Content adds a hash per file, for "exactly as it was". + function Get-Tree { + param([string]$Root, [switch]$Content) + $entries = New-Object System.Collections.Generic.List[string] + $walk = { + param([string]$Folder) + foreach ($entry in [System.IO.Directory]::GetFileSystemEntries($Folder)) { + $relative = $entry.Substring($Root.Length + 1) -replace '-[0-9a-f]{8}(?=\\|$)', '-' + $attributes = [System.IO.File]::GetAttributes($entry) + if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { $entries.Add("$relative -> link"); continue } + if (($attributes -band [System.IO.FileAttributes]::Directory) -ne 0) { $entries.Add("$relative\"); & $walk $entry; continue } + if ($Content) { $relative += ' ' + (Get-FileHash -LiteralPath $entry -Algorithm SHA256).Hash.Substring(0, 16) } + $entries.Add($relative) + } + } + if ([System.IO.Directory]::Exists($Root)) { & $walk $Root } + return (Join-Tree $entries) + } + + function Join-Tree { + param([string[]]$Entries) + $sorted = [string[]]@($Entries | Where-Object { $_ }) + [System.Array]::Sort($sorted, [System.StringComparer]::Ordinal) + return ($sorted -join "`n") + } + + function Get-StdErrLines { + param($Run) + return @($Run.StdErr -split "`r?`n" | Where-Object { $_ }) + } + + function New-Junction { + param([string]$Link, [string]$Target) + cmd.exe /c mklink /J "$Link" "$Target" | Out-Null + } + + function Add-File { + param([string]$Path, [string]$Value = 'x') + New-Item -ItemType Directory -Path (Split-Path $Path) -Force | Out-Null + Set-Content -LiteralPath $Path -Value $Value + } + + # A base with what earlier bootstrap versions and agents could leave behind, plus a file + # no version knows yet and a junction out of it. + function Add-Leftovers { + param($Scenario) + Add-File (Join-Path $Scenario.Base 'Install-AutopilotMonitor.ps1') + Add-File (Join-Path $Scenario.Base 'Logs\bootstrap-loader.log') + Add-File (Join-Path $Scenario.Base 'Logs\bootstrap-script.log') + Add-File (Join-Path $Scenario.Base 'Agent-Install\Agent\AutopilotMonitor.Agent.Core.dll') + Add-File (Join-Path $Scenario.Base 'future-version-file.bin') + Add-File (Join-Path $Scenario.Outside 'target\sentinel.txt') 'must survive' + New-Junction -Link (Join-Path $Scenario.Base 'linked') -Target (Join-Path $Scenario.Outside 'target') + } + + $script:installedTree = @( + 'AutopilotMonitor\' + 'AutopilotMonitor\Agent\' + 'AutopilotMonitor\Agent\AutopilotMonitor.Agent.Core.dll' + 'AutopilotMonitor\Agent\AutopilotMonitor.Agent.cat' + 'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe' + 'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe.config' + 'AutopilotMonitor\Logs\' + ) +} + +Describe 'loader 1.3' { + It 'installs and leaves only the agent folder, with its log carried over' { + $s = New-Scenario + $run = Invoke-Scenario $s -Script $script:loaderPath -Arguments @('-MaxBootstrapWindowHours', '24') + + $run.ExitCode | Should -Be 0 + $run.StdErr | Should -BeNullOrEmpty + Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-loader.log' + 'AutopilotMonitor\Logs\bootstrap-script.log')) + Get-Tree $s.Temp | Should -BeNullOrEmpty + $run.Trace | Should -Contain 'Get-BootstrapDecision|MaxBootstrapWindowHours=24' + $run.Trace | Should -Contain 'Invoke-WebRequest|https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1' + $run.Trace | Should -Contain 'Invoke-WebRequest|https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip' + $run.Trace | Should -Contain 'Invoke-RestMethod|https://download.autopilotmonitor.com/agent/version.json' + @($run.Trace | Where-Object { $_ -like 'Test-FileCatalog|*' }).Count | Should -Be 1 + @($run.Trace | Where-Object { $_ -like 'Invoke-AgentInstallMode|*\AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe --install exists=True' }).Count | Should -Be 1 + $run.StdOut | Should -Match 'Bootstrap Completed Successfully' + Get-Content -LiteralPath (Join-Path $s.Base 'Logs\bootstrap-loader.log') -Raw | Should -Match '=== Autopilot Monitor bootstrap loader v1\.3 started' + if ($script:isElevated) { + (Get-Item -LiteralPath $s.Base).GetAccessControl('Owner').GetOwner([System.Security.Principal.SecurityIdentifier]).Value | Should -Be 'S-1-5-32-544' + } + } + + It 'skips and leaves nothing behind, also what older versions left and files no version knows' { + $s = New-Scenario + Add-Leftovers $s + $run = Invoke-Scenario $s -Script $script:loaderPath -Options @{ Install = $false } + + $run.ExitCode | Should -Be 0 + $run.StdErr | Should -BeNullOrEmpty + Get-Tree $s.ProgramData | Should -BeNullOrEmpty + Get-Content -LiteralPath (Join-Path $s.Outside 'target\sentinel.txt') | Should -Be 'must survive' + $run.StdOut | Should -Match 'No agent on this device: .* removed\.' + @($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*Agent.zip' }).Count | Should -Be 0 + } + + It 'leaves the folder of an agent exactly as it is, self-update leftovers included' { + $s = New-Scenario + Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') 'running agent' + Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.Core.dll.old') 'previous version' + Add-File (Join-Path $s.Base 'Updates\AutopilotMonitor-Agent-Update.zip') 'update' + Add-File (Join-Path $s.Base 'Agent-Update\AutopilotMonitor.Agent.Core.dll') 'staged' + Add-File (Join-Path $s.Base 'Logs\agent.log') 'agent log' + Add-File (Join-Path $s.Base 'State\session.id') 'session' + $before = Get-Tree $s.ProgramData -Content + $run = Invoke-Scenario $s -Script $script:loaderPath + + $run.ExitCode | Should -Be 0 + $run.StdErr | Should -BeNullOrEmpty + Get-Tree $s.ProgramData -Content | Should -Be $before + $run.StdOut | Should -Match 'Agent present \(agent executable\)' + @($run.Trace | Where-Object { $_ -like 'Invoke-AgentInstallMode|*' }).Count | Should -Be 0 + } + + It 'leaves the folder alone when only the deployment marker ' -TestCases @( + @{ Case = 'is there (logs kept after enrollment)'; Options = @{ Marker = '2026-10-01T08:00:00Z' } } + @{ Case = 'is missing but the agent task exists'; Options = @{ TaskPresent = $true } } + ) { + $s = New-Scenario + Add-File (Join-Path $s.Base 'Logs\agent.log') 'kept log' + $before = Get-Tree $s.ProgramData -Content + $run = Invoke-Scenario $s -Script $script:loaderPath -Options $Options + + $run.ExitCode | Should -Be 0 + $run.StdErr | Should -BeNullOrEmpty + Get-Tree $s.ProgramData -Content | Should -Be $before + } + + It 'keeps its log when the installer fails, and the next run removes it' { + $s = New-Scenario + Remove-Item -LiteralPath (Join-Path $s.Server 'version.json') + $failed = Invoke-Scenario $s -Script $script:loaderPath + + $failed.ExitCode | Should -Be 0 + @(Get-StdErrLines $failed).Count | Should -Be 1 + $failed.StdErr | Should -Match '^AutopilotMonitor bootstrap failed: No SHA-256 hash available' + Get-Tree $s.ProgramData | Should -Be (Join-Tree @( + 'AutopilotMonitor-Loader-\' + 'AutopilotMonitor-Loader-\bootstrap-loader.log' + 'AutopilotMonitor\' + 'AutopilotMonitor\Logs\' + 'AutopilotMonitor\Logs\bootstrap-loader.log' + 'AutopilotMonitor\Logs\bootstrap-script.log' + )) + + Set-Content -LiteralPath (Join-Path $s.Server 'version.json') -Value ('{"version":"2.0.9999","sha256":"' + $script:agentZipSha256 + '"}') + $next = Invoke-Scenario $s -Script $script:loaderPath + + $next.ExitCode | Should -Be 0 + $next.StdErr | Should -BeNullOrEmpty + Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-loader.log' + 'AutopilotMonitor\Logs\bootstrap-script.log')) + } + + It 'runs nothing it cannot verify: ' -TestCases @( + @{ Case = 'unsigned'; Options = @{ SignatureStatus = 'NotSigned' }; Edit = $null } + @{ Case = 'another publisher'; Options = @{ SignerSubject = 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }; Edit = $null } + @{ Case = 'an installer below 2.7'; Options = @{}; Edit = @('$ScriptVersion = "2.7"', '$ScriptVersion = "2.6"') } + @{ Case = 'not the installer'; Options = @{}; Edit = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptRole = "AutopilotMonitor-Other"') } + ) { + $s = New-Scenario + if ($Edit) { + $served = Join-Path $s.Server 'Install-AutopilotMonitor.ps1' + $text = [System.IO.File]::ReadAllText($served) + $text.Contains($Edit[0]) | Should -BeTrue + [System.IO.File]::WriteAllText($served, $text.Replace($Edit[0], $Edit[1])) + } + $run = Invoke-Scenario $s -Script $script:loaderPath -Options $Options + + $run.ExitCode | Should -Be 0 + $run.StdErr | Should -BeNullOrEmpty + @($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*Install-AutopilotMonitor.ps1' }).Count | Should -Be 3 + @($run.Trace | Where-Object { $_ -like 'Get-BootstrapDecision|*' }).Count | Should -Be 0 + Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor-Loader-\', 'AutopilotMonitor-Loader-\bootstrap-loader.log')) + } + + It 'refuses to run without SYSTEM or administrator rights and creates nothing' { + $s = New-Scenario + $run = Invoke-Scenario $s -Script $script:loaderPath -Options @{ Elevated = $false } + + $run.ExitCode | Should -Be 0 + @(Get-StdErrLines $run) | Should -Be 'AutopilotMonitor loader: not running as SYSTEM or administrator; nothing was downloaded or changed.' + Get-Tree $s.ProgramData | Should -BeNullOrEmpty + @($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*' }).Count | Should -Be 0 + } +} + +Describe 'installer assigned directly' { + BeforeEach { + $s = New-Scenario + $imeCopy = Join-Path $s.Ime '0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.ps1' + Copy-Item -LiteralPath $script:installerPath -Destination $imeCopy + } + + It 'installs, and the copy IME runs stays where IME put it' { + $run = Invoke-Scenario $s -Script $imeCopy + + $run.ExitCode | Should -Be 0 + $run.StdErr | Should -BeNullOrEmpty + Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-script.log')) + Test-Path -LiteralPath $imeCopy | Should -BeTrue + Get-Tree $s.Temp | Should -BeNullOrEmpty + } + + It 'skips and leaves nothing behind' { + Add-Leftovers $s + $run = Invoke-Scenario $s -Script $imeCopy -Options @{ Install = $false } + + $run.ExitCode | Should -Be 0 + $run.StdErr | Should -BeNullOrEmpty + Get-Tree $s.ProgramData | Should -BeNullOrEmpty + Get-Content -LiteralPath (Join-Path $s.Outside 'target\sentinel.txt') | Should -Be 'must survive' + } + + It 'fails with exit code 1 and exactly one line on stderr: ' -TestCases @( + @{ Case = 'the download fails'; Missing = 'AutopilotMonitor-Agent.zip'; Options = @{}; Message = 'AutopilotMonitor bootstrap failed: The remote server returned an error: (404) Not Found.*' } + @{ Case = 'the payload fails its catalog'; Missing = $null; Options = @{ CatalogValid = $false }; Message = 'AutopilotMonitor bootstrap failed: Payload verification FAILED*' } + @{ Case = 'the agent install fails'; Missing = $null; Options = @{ InstallExitCode = 7 }; Message = 'AutopilotMonitor bootstrap failed: Agent install failed with exit code 7' } + ) { + if ($Missing) { Remove-Item -LiteralPath (Join-Path $s.Server $Missing) } + $run = Invoke-Scenario $s -Script $imeCopy -Options $Options + + $run.ExitCode | Should -Be 1 + @(Get-StdErrLines $run).Count | Should -Be 1 + @(Get-StdErrLines $run)[0] | Should -BeLike $Message + Get-Tree $s.ProgramData | Should -Not -Match 'Agent-Install' + Test-Path -LiteralPath (Join-Path $s.Base 'Logs\bootstrap-script.log') | Should -BeTrue + } + + It 'refuses to run without SYSTEM or administrator rights and changes nothing' { + Add-File (Join-Path $s.Base 'Logs\bootstrap-script.log') 'earlier run' + $before = Get-Tree $s.ProgramData -Content + $run = Invoke-Scenario $s -Script $imeCopy -Options @{ Elevated = $false } + + $run.ExitCode | Should -Be 1 + @(Get-StdErrLines $run) | Should -Be 'AutopilotMonitor bootstrap: not running as SYSTEM or administrator; nothing was changed.' + Get-Tree $s.ProgramData -Content | Should -Be $before + } + + It 'does not install into a folder that holds another account''s entries, and leaves them' { + Add-File (Join-Path $s.Base 'foreign-planted.dll') 'planted' + Add-File (Join-Path $s.Base 'Logs\bootstrap-script.log') 'earlier run' + $run = Invoke-Scenario $s -Script $imeCopy + + $run.ExitCode | Should -Be 1 + @(Get-StdErrLines $run).Count | Should -Be 1 + $run.StdErr | Should -Match 'holds entries another account created' + Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\foreign-planted.dll')) + @($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*' }).Count | Should -Be 0 + } + + It 'leaves another account''s entries and their folder when it skips' { + Add-File (Join-Path $s.Base 'foreign-planted.dll') 'planted' + Add-File (Join-Path $s.Base 'Logs\bootstrap-script.log') 'earlier run' + $run = Invoke-Scenario $s -Script $imeCopy -Options @{ Install = $false } + + $run.ExitCode | Should -Be 0 + $run.StdErr | Should -BeNullOrEmpty + Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\foreign-planted.dll')) + } + + It 'never follows an agent folder that is a link: ' -TestCases @( + @{ Case = 'install'; Install = $true; ExitCode = 1 } + @{ Case = 'skip'; Install = $false; ExitCode = 0 } + ) { + Add-File (Join-Path $s.Outside 'target\sentinel.txt') 'must survive' + New-Junction -Link $s.Base -Target (Join-Path $s.Outside 'target') + $run = Invoke-Scenario $s -Script $imeCopy -Options @{ Install = $Install } + + $run.ExitCode | Should -Be $ExitCode + Get-Tree $s.ProgramData | Should -Be 'AutopilotMonitor -> link' + Get-Tree (Join-Path $s.Outside 'target') | Should -Be 'sentinel.txt' + } +} + +Describe 'loader 1.2, frozen' { + It 'installs with the current installer, which removes its copy and keeps the loader start line' { + $s = New-Scenario + $run = Invoke-Scenario $s -Script $script:loader12Path + + $run.ExitCode | Should -Be 0 + $run.StdErr | Should -BeNullOrEmpty + Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-loader.log' + 'AutopilotMonitor\Logs\bootstrap-script.log')) + $log = @(Get-Content -LiteralPath (Join-Path $s.Base 'Logs\bootstrap-loader.log')) + $log[0] | Should -Match '^\[\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\] === Autopilot Monitor bootstrap loader v1\.2 started \(carried over by the bootstrapper\) ===$' + } + + It 'leaves the folder of an agent as it is, but not the copy the old loader saved there' { + $s = New-Scenario + Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') 'running agent' + Add-File (Join-Path $s.Base 'Logs\agent.log') 'agent log' + $run = Invoke-Scenario $s -Script $script:loader12Path + + $run.ExitCode | Should -Be 0 + $run.StdErr | Should -BeNullOrEmpty + Get-Tree $s.ProgramData | Should -Be (Join-Tree @( + 'AutopilotMonitor\', 'AutopilotMonitor\Agent\', 'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe' + 'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\agent.log', 'AutopilotMonitor\Logs\bootstrap-loader.log' + )) + Get-Content -LiteralPath (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') | Should -Be 'running agent' + } + + It 'skips with the current installer and leaves nothing behind' { + $s = New-Scenario + Add-Leftovers $s + $run = Invoke-Scenario $s -Script $script:loader12Path -Options @{ Install = $false } + + $run.ExitCode | Should -Be 0 + $run.StdErr | Should -BeNullOrEmpty + Get-Tree $s.ProgramData | Should -BeNullOrEmpty + Get-Content -LiteralPath (Join-Path $s.Outside 'target\sentinel.txt') | Should -Be 'must survive' + } +} + +Describe 'bootstrap MSI layouts, started in the folder the MSI installed' { + It ' skips and leaves ' -TestCases @( + @{ Name = 'MSI 1.2 (loader 1.3)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Arguments = @('-LogFileName', 'bootstrap-msi.log'); Left = 'nothing'; Tree = @() } + @{ Name = 'MSI 1.1 (loader 1.2)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader12'; Arguments = @('-LogFileName', 'bootstrap-msi.log'); Left = 'nothing'; Tree = @() } + @{ Name = 'MSI 1.0 (own runner)'; File = 'Invoke-BootstrapDownload.ps1'; Source = 'msiRunner'; Arguments = @(); Left = 'only the log the runner holds open'; Tree = @('AutopilotMonitor\', 'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\bootstrap-msi.log') } + ) { + $s = New-Scenario + $msi = Join-Path $s.Base 'Msi' + New-Item -ItemType Directory -Path $msi -Force | Out-Null + $sourcePath = @{ loader = $script:loaderPath; loader12 = $script:loader12Path; msiRunner = $script:msiRunnerPath }[$Source] + Copy-Item -LiteralPath $sourcePath -Destination (Join-Path $msi $File) + $run = Invoke-Scenario $s -Script (Join-Path $msi $File) -Arguments $Arguments -WorkingDirectory $msi -Options @{ Install = $false } + + $run.ExitCode | Should -Be 0 + $run.StdErr | Should -BeNullOrEmpty + Get-Tree $s.ProgramData | Should -Be (Join-Tree $Tree) + } + + It ' installs' -TestCases @( + @{ Name = 'MSI 1.2 (loader 1.3)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Arguments = @('-LogFileName', 'bootstrap-msi.log') } + @{ Name = 'MSI 1.1 (loader 1.2)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader12'; Arguments = @('-LogFileName', 'bootstrap-msi.log') } + @{ Name = 'MSI 1.0 (own runner)'; File = 'Invoke-BootstrapDownload.ps1'; Source = 'msiRunner'; Arguments = @() } + ) { + $s = New-Scenario + $msi = Join-Path $s.Base 'Msi' + New-Item -ItemType Directory -Path $msi -Force | Out-Null + $sourcePath = @{ loader = $script:loaderPath; loader12 = $script:loader12Path; msiRunner = $script:msiRunnerPath }[$Source] + Copy-Item -LiteralPath $sourcePath -Destination (Join-Path $msi $File) + $run = Invoke-Scenario $s -Script (Join-Path $msi $File) -Arguments $Arguments -WorkingDirectory $msi + + $run.ExitCode | Should -Be 0 + $run.StdErr | Should -BeNullOrEmpty + Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-msi.log' + 'AutopilotMonitor\Logs\bootstrap-script.log')) + } +} + +Describe 'OOBE script, as the backend generates it' { + It 'installs the agent with the bootstrap token and leaves only the agent folder' { + $s = New-Scenario + Add-Leftovers $s + $run = Invoke-Scenario $s -Script $script:oobeScriptPath -Options @{ Iex = $true } + + $run.ExitCode | Should -Be 0 + $run.StdErr | Should -BeNullOrEmpty + Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\oobe_bootstrap.log')) + Get-Tree $s.Temp | Should -BeNullOrEmpty + Get-Content -LiteralPath (Join-Path $s.Outside 'target\sentinel.txt') | Should -Be 'must survive' + $run.Trace | Should -Contain ('Invoke-AgentInstallMode|' + $s.Base + '\Agent\AutopilotMonitor.Agent.exe --install --no-auth --bootstrap-token 22222222-2222-2222-2222-222222222222 --tenant-id 11111111-1111-1111-1111-111111111111 exists=True') + @($run.Trace | Where-Object { $_ -like 'Test-FileCatalog|*' }).Count | Should -Be 1 + } + + It 'only runs --install again when the agent is there' { + $s = New-Scenario + Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') 'running agent' + Add-File (Join-Path $s.Base 'State\session.id') 'session' + $run = Invoke-Scenario $s -Script $script:oobeScriptPath -Options @{ Iex = $true } + + $run.ExitCode | Should -Be 0 + Get-Tree $s.ProgramData | Should -Be (Join-Tree @( + 'AutopilotMonitor\', 'AutopilotMonitor\Agent\', 'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe' + 'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\oobe_bootstrap.log', 'AutopilotMonitor\State\', 'AutopilotMonitor\State\session.id' + )) + @($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*' }).Count | Should -Be 0 + @($run.Trace | Where-Object { $_ -like 'Invoke-AgentInstallMode|*' }).Count | Should -Be 1 + } + + It 'refuses to run without SYSTEM or administrator rights and creates nothing' { + $s = New-Scenario + $run = Invoke-Scenario $s -Script $script:oobeScriptPath -Options @{ Iex = $true; Elevated = $false } + + $run.ExitCode | Should -Be 1 + $run.StdOut | Should -Match 'Run this script from PowerShell as SYSTEM or administrator\. Nothing was changed\.' + Get-Tree $s.ProgramData | Should -BeNullOrEmpty + } +} + +Describe 'publish pipeline and dev channel' { + BeforeAll { + $script:rendered = Join-Path $TestDrive 'rendered' + $script:pwsh = (Get-Command pwsh -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source + $script:publish = Join-Path $script:repoRoot '.github\scripts\Publish-BootstrapScripts.ps1' + $script:publishOutput = @(& $script:pwsh -NoProfile -File $script:publish -DryRun -SignedOutputDir $script:rendered 2>&1 | ForEach-Object { "$_" }) + if ($LASTEXITCODE -ne 0) { throw "Publish dry run failed: $($script:publishOutput -join [Environment]::NewLine)" } + } + + # A loader that went live first would refuse the installer still being served. + It 'puts both installers before both loaders' { + $order = @($script:publishOutput | Where-Object { $_ -match '\[dry-run\] (\S+\.ps1) ' } | ForEach-Object { $Matches[1] }) + $order[0..3] | Should -Be @('Install-AutopilotMonitor.ps1', 'Install-AutopilotMonitor-Dev.ps1', 'Start-AutopilotMonitor.ps1', 'Start-AutopilotMonitor-Dev.ps1') + @($script:publishOutput | Where-Object { $_ -like '*Installer accepted: AutopilotMonitor-Installer v*' }).Count | Should -Be 2 + } + + It 'refuses to publish an installer the loader would refuse: ' -TestCases @( + @{ Case = 'below the minimum version'; Old = '$ScriptVersion = "2.7"'; New = '$ScriptVersion = "2.6"' } + @{ Case = 'without its role line'; Old = '$ScriptRole = "AutopilotMonitor-Installer"'; New = '# no role' } + @{ Case = 'with a second version line'; Old = '$ScriptVersion = "2.7"'; New = "`$ScriptVersion = `"2.7`"`n`$ScriptVersion = `"2.8`"" } + ) { + $root = Join-Path $TestDrive ('repo' + [guid]::NewGuid().ToString('N').Substring(0, 6)) + New-Item -ItemType Directory -Path (Join-Path $root 'scripts\Bootstrap') -Force | Out-Null + Copy-Item -LiteralPath $script:loaderPath -Destination (Join-Path $root 'scripts\Bootstrap') + $text = [System.IO.File]::ReadAllText($script:installerPath) + $text.Contains($Old) | Should -BeTrue + [System.IO.File]::WriteAllText((Join-Path $root 'scripts\Bootstrap\Install-AutopilotMonitor.ps1'), $text.Replace($Old, $New)) + + $output = @(& $script:pwsh -NoProfile -File $script:publish -DryRun -Channel dev -RepoRoot $root 2>&1 | ForEach-Object { "$_" }) + + $LASTEXITCODE | Should -Not -Be 0 + ($output -join "`n") | Should -Match 'The loader would refuse Install-AutopilotMonitor-Dev\.ps1' + ($output -join "`n") | Should -Not -Match '\[dry-run\]' + } + + It 'the dev loader fetches the dev installer, which installs the dev agent' { + $s = New-Scenario + Copy-Item -LiteralPath (Join-Path $script:rendered 'Install-AutopilotMonitor-Dev.ps1') -Destination $s.Server + Copy-Item -LiteralPath $script:agentZip -Destination (Join-Path $s.Server 'AutopilotMonitor-Agent-dev.zip') + Set-Content -LiteralPath (Join-Path $s.Server 'version-dev.json') -Value ('{"version":"2.0.9999","sha256":"' + $script:agentZipSha256 + '"}') + Remove-Item -LiteralPath (Join-Path $s.Server 'Install-AutopilotMonitor.ps1'), (Join-Path $s.Server 'AutopilotMonitor-Agent.zip'), (Join-Path $s.Server 'version.json') + $devLoader = Join-Path $s.Ime 'Start-AutopilotMonitor-Dev.ps1' + Copy-Item -LiteralPath (Join-Path $script:rendered 'Start-AutopilotMonitor-Dev.ps1') -Destination $devLoader + $run = Invoke-Scenario $s -Script $devLoader + + $run.ExitCode | Should -Be 0 + $run.StdErr | Should -BeNullOrEmpty + @($run.Trace | Where-Object { $_ -like 'Invoke-*' }) | Should -Be @( + 'Invoke-WebRequest|https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor-Dev.ps1' + 'Invoke-RestMethod|https://download.autopilotmonitor.com/agent/version-dev.json' + 'Invoke-WebRequest|https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip' + ('Invoke-AgentInstallMode|' + $s.Base + '\Agent\AutopilotMonitor.Agent.exe --install exists=True') + ) + Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-loader.log' + 'AutopilotMonitor\Logs\bootstrap-script.log')) + } + + It 'the stable loader in its published form runs the stable installer in its published form' { + $s = New-Scenario + Copy-Item -LiteralPath (Join-Path $script:rendered 'Install-AutopilotMonitor.ps1') -Destination $s.Server -Force + $loader = Join-Path $s.Ime 'Start-AutopilotMonitor.ps1' + Copy-Item -LiteralPath (Join-Path $script:rendered 'Start-AutopilotMonitor.ps1') -Destination $loader + [System.IO.File]::ReadAllText($loader).Contains("`r`n") | Should -BeTrue + $run = Invoke-Scenario $s -Script $loader + + $run.ExitCode | Should -Be 0 + $run.StdErr | Should -BeNullOrEmpty + $run.StdOut | Should -Match 'Installer accepted: AutopilotMonitor-Installer v2\.7' + $run.StdOut | Should -Match 'Bootstrap Completed Successfully' + } +} diff --git a/scripts/Bootstrap/Install-AutopilotMonitor.Tests.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor.Tests.ps1 index 881afc1a..421136c8 100644 --- a/scripts/Bootstrap/Install-AutopilotMonitor.Tests.ps1 +++ b/scripts/Bootstrap/Install-AutopilotMonitor.Tests.ps1 @@ -1,10 +1,13 @@ -# Pester tests (Pester 5+) for the bootstrap guard/relax logic in -# Install-AutopilotMonitor.ps1. +# Pester tests (Pester 5+) for Install-AutopilotMonitor.ps1. # # The script under test is dot-sourced; its entry guard prevents the bootstrap # from executing, so loading it here is side-effect free. External state (WMI, -# registry, filesystem, WinRT) is mocked; profile directories are real folders -# under Pester's $TestDrive with a manipulated CreationTimeUtc. +# registry, scheduled tasks, WinRT) is mocked; folders, files and junctions are real, +# under Pester's $TestDrive. +# +# Folders created here belong to the test account. Run locally without elevation, that +# account may not make Administrators the owner, and its folders are never "ours". Tests +# that need folders we own mock Test-IsOwnedByUs and New-ProtectedDirectorySecurity. # # Run under Windows PowerShell 5.1 to match the IME runtime: # powershell.exe -NoProfile -Command "& { $c = New-PesterConfiguration; $c.Run.Path = 'scripts\Bootstrap'; $c.Output.Verbosity = 'Detailed'; Invoke-Pester -Configuration $c }" @@ -12,7 +15,8 @@ # This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI). BeforeAll { - . (Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1') + $script:InstallerPath = Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1' + . $script:InstallerPath function New-FakeSignature { param([string]$Status, [string]$Subject) @@ -48,6 +52,43 @@ BeforeAll { (Get-Item $p).CreationTimeUtc = (Get-Date).ToUniversalTime().AddMinutes(-$AgeMinutes) return $p } + + function New-CategoryError { + param([System.Management.Automation.ErrorCategory]$Category) + return New-Object System.Management.Automation.ErrorRecord((New-Object System.Exception 'simulated'), 'Simulated', $Category, $null) + } + + # Stand-in for the protected DACL: protected like the real one, but full control for the + # test account and no owner change, so a non-elevated run can create and clean it up. + function New-TestDirectorySecurity { + $security = New-Object System.Security.AccessControl.DirectorySecurity + $security.SetAccessRuleProtection($true, $false) + $me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + $security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($me, 'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow'))) + return $security + } + + function New-TestFolder { + param([string]$Name) + $path = Join-Path $TestDrive ("$Name-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) + New-Item -ItemType Directory -Path $path -Force | Out-Null + return $path + } + + # A directory junction pointing at a folder with a sentinel file: whatever the code under + # test does, the sentinel must survive. + function New-SentinelJunction { + param([string]$Link) + $target = New-TestFolder 'junction-target' + Set-Content -Path (Join-Path $target 'sentinel.txt') -Value 'must survive' + cmd.exe /c mklink /J "$Link" "$target" | Out-Null + return $target + } + + function Get-ErrorRecords { + param([scriptblock]$Script) + return @(& $Script 2>&1 | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }) + } } Describe 'Test-IsCloudPc' { @@ -60,32 +101,32 @@ Describe 'Test-IsCloudPc' { } It 'detects a Cloud PC when both markers are present' { - Mock Test-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' } + Mock Test-RegistryKey64 { $true } -ParameterFilter { $SubKey -eq 'SOFTWARE\Microsoft\Windows365' } Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } } Test-IsCloudPc | Should -BeTrue } It 'does not detect with the registry key alone (e.g. W365-Boot physical client)' { - Mock Test-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' } + Mock Test-RegistryKey64 { $true } -ParameterFilter { $SubKey -eq 'SOFTWARE\Microsoft\Windows365' } Mock Get-Service { $null } Test-IsCloudPc | Should -BeFalse } It 'does not detect with the service alone' { - Mock Test-Path { $false } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' } + Mock Test-RegistryKey64 { $false } Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } } Test-IsCloudPc | Should -BeFalse } It 'does not detect a plain device (no markers)' { - Mock Test-Path { $false } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' } + Mock Test-RegistryKey64 { $false } Mock Get-Service { $null } Test-IsCloudPc | Should -BeFalse } It 'still evaluates markers when the WMI identity query fails' { Mock Get-CimInstance { throw 'RPC server unavailable' } - Mock Test-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' } + Mock Test-RegistryKey64 { $true } -ParameterFilter { $SubKey -eq 'SOFTWARE\Microsoft\Windows365' } Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } } Test-IsCloudPc | Should -BeTrue } @@ -97,6 +138,34 @@ Describe 'Get-OobeState' { } } +Describe 'Get-RegistryValue64 and Test-RegistryKey64' { + # The guards must read what the 64-bit agent wrote, also from a 32-bit host. These run + # against keys every Windows has. + It 'reads a value from the 64-bit view' { + Get-RegistryValue64 -SubKey 'SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name 'CurrentBuild' | Should -Not -BeNullOrEmpty + } + + # ProgramFilesDir is "Program Files" in the 64-bit view and "Program Files (x86)" in the + # 32-bit one; Intune starts scripts in a 32-bit host by default. + It 'reads the 64-bit view from a 32-bit host' { + $host32 = Join-Path $env:SystemRoot 'SysWOW64\WindowsPowerShell\v1.0\powershell.exe' + $command = ". '{0}'; [Environment]::Is64BitProcess; Get-RegistryValue64 -SubKey 'SOFTWARE\Microsoft\Windows\CurrentVersion' -Name 'ProgramFilesDir'" -f $script:InstallerPath + $out = @(& $host32 -NoProfile -ExecutionPolicy Bypass -Command $command) + $out[0] | Should -Be 'False' + $out[1] | Should -Be $env:ProgramW6432 + } + + It 'returns null for a missing key or value' { + Get-RegistryValue64 -SubKey 'SOFTWARE\AutopilotMonitor-NoSuchKey' -Name 'Deployed' | Should -BeNullOrEmpty + Get-RegistryValue64 -SubKey 'SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name 'NoSuchValue' | Should -BeNullOrEmpty + } + + It 'tells an existing key from a missing one' { + Test-RegistryKey64 -SubKey 'SOFTWARE\Microsoft\Windows NT\CurrentVersion' | Should -BeTrue + Test-RegistryKey64 -SubKey 'SOFTWARE\AutopilotMonitor-NoSuchKey' | Should -BeFalse + } +} + Describe 'Get-RealUserProfilePaths' { BeforeEach { Mock Write-Log { } @@ -214,25 +283,24 @@ Describe 'Get-BootstrapDecision' { BeforeEach { Mock Write-Log { } # Baseline: clean device during Device ESP -- no marker, no profiles, no - # logged-on user, 1h uptime, agent not installed. Tests override selectively. - Mock Get-ItemProperty { $null } + # logged-on user, 1h uptime. Tests override selectively. + Mock Get-RegistryValue64 { $null } Mock Get-RealUserProfilePaths { @() } Mock Get-OobeState { 'Completed' } Mock Test-IsCloudPc { $false } Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-1) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' } - $script:cleanAgentBin = Join-Path $TestDrive 'AgentBin' } It 'installs on a clean ESP device' { - $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin + $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 $d.Install | Should -BeTrue $d.ReasonCode | Should -BeNullOrEmpty $d.RelaxActive | Should -BeFalse } - It 'skips when the Deployed marker exists (guard 1)' { - Mock Get-ItemProperty { [pscustomobject]@{ Deployed = '2026-08-01T10:00:00Z' } } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\AutopilotMonitor' } - $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin + It 'skips when the Deployed marker exists, read through the 64-bit view (guard 1)' { + Mock Get-RegistryValue64 { '2026-08-01T10:00:00Z' } -ParameterFilter { $SubKey -eq 'SOFTWARE\AutopilotMonitor' -and $Name -eq 'Deployed' } + $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 $d.Install | Should -BeFalse $d.ReasonCode | Should -Be 'AlreadyDeployed' } @@ -240,7 +308,7 @@ Describe 'Get-BootstrapDecision' { It 'skips a productive device with an old profile (guard 2)' { $script:fakeProfile = New-FakeProfile 'OldUser' 5000 Mock Get-RealUserProfilePaths { @($script:fakeProfile) } - $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin + $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 $d.Install | Should -BeFalse $d.ReasonCode | Should -Be 'ProfilesFound' } @@ -250,27 +318,32 @@ Describe 'Get-BootstrapDecision' { Mock Get-RealUserProfilePaths { @($script:fakeProfile) } Mock Test-IsCloudPc { $true } # First-connect user is already visible in LogonUI on W365 -- must not block either. - Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'AzureAD\CloudPcUser' } } -ParameterFilter { $Path -like '*LogonUI*' } - $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin + Mock Get-RegistryValue64 { 'AzureAD\CloudPcUser' } -ParameterFilter { $SubKey -like '*LogonUI*' } + $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 $d.Install | Should -BeTrue $d.RelaxActive | Should -BeTrue } - It 'skips when a real user has logged on and no relax applies (guard 3)' { - Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'AzureAD\SomeUser' } } -ParameterFilter { $Path -like '*LogonUI*' } - $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin + It 'skips when a real user has logged on and no relax applies, read through the 64-bit view (guard 3)' { + Mock Get-RegistryValue64 { 'AzureAD\SomeUser' } -ParameterFilter { $SubKey -eq 'SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -and $Name -eq 'LastLoggedOnUser' } + $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 $d.Install | Should -BeFalse $d.ReasonCode | Should -Be 'LastLoggedOnUser' } It 'does not skip for the defaultuser0 OOBE account (guard 3)' { - Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'defaultuser0' } } -ParameterFilter { $Path -like '*LogonUI*' } - (Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin).Install | Should -BeTrue + Mock Get-RegistryValue64 { 'defaultuser0' } -ParameterFilter { $SubKey -like '*LogonUI*' } + (Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15).Install | Should -BeTrue + } + + It 'treats an unreadable marker as absent for the decision (the agent check decides fail-safe)' { + Mock Get-RegistryValue64 { throw 'access denied' } + (Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15).Install | Should -BeTrue } It 'skips when uptime exceeds the bootstrap window (guard 4)' { Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-20) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' } - $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin + $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 $d.Install | Should -BeFalse $d.ReasonCode | Should -Be 'UptimeExceeded' } @@ -282,7 +355,7 @@ Describe 'Get-BootstrapDecision' { Mock Get-RealUserProfilePaths { @($script:fakeProfile) } Mock Test-IsCloudPc { $true } Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-90) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' } - $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin + $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 $d.Install | Should -BeTrue $d.RelaxActive | Should -BeTrue } @@ -290,7 +363,7 @@ Describe 'Get-BootstrapDecision' { It 'still skips an over-uptime Cloud PC when the relax is NOT active (no fresh profile)' { Mock Test-IsCloudPc { $true } Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-90) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' } - $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin + $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 $d.Install | Should -BeFalse $d.ReasonCode | Should -Be 'UptimeExceeded' } @@ -300,36 +373,416 @@ Describe 'Get-BootstrapDecision' { Mock Get-RealUserProfilePaths { @($script:fakeProfile) } Mock Get-OobeState { 'InProgress' } Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-20) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' } - $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin + $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 $d.Install | Should -BeFalse $d.ReasonCode | Should -Be 'UptimeExceeded' } +} - It 'skips when the agent binary is already present (guard 5)' { - $binPath = Join-Path $TestDrive 'AgentBinExisting' - New-Item -ItemType Directory -Path $binPath -Force | Out-Null - Set-Content -Path (Join-Path $binPath 'AutopilotMonitor.Agent.exe') -Value 'stub' - $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $binPath - $d.Install | Should -BeFalse - $d.ReasonCode | Should -Be 'AgentPresent' +Describe 'logging' { + # Regression guard: Write-Log runs inside functions that RETURN decision + # objects. If it ever emits to the output stream again (Write-Output), the + # log lines leak into the return values and turn them into arrays. These + # tests deliberately do NOT mock Write-Log. + BeforeEach { + # Plain local assignments: PowerShell's dynamic scoping makes Write-Log resolve these + # instead of the ProgramData path and the state defined by the dot-sourced script. + $LogFile = Join-Path $TestDrive "bootstrap-$([guid]::NewGuid().ToString('N')).log" + $LogState = [pscustomobject]@{ Ready = $false; Pending = (New-Object System.Collections.Generic.List[string]) } + } + + It 'Get-BootstrapDecision returns exactly one object with real logging' { + Mock Get-RegistryValue64 { '2026-08-01T10:00:00Z' } -ParameterFilter { $SubKey -eq 'SOFTWARE\AutopilotMonitor' } + $d = @(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15) + $d.Count | Should -Be 1 + $d[0].ReasonCode | Should -Be 'AlreadyDeployed' + } + + It 'keeps lines in memory until the log file is opened, then writes them first' { + Write-Log 'before the folder exists' + Test-Path $LogFile | Should -BeFalse + $LogState.Pending.Count | Should -Be 1 + + Open-LogFile + Write-Log 'after' + + $lines = @(Get-Content $LogFile) + $lines.Count | Should -Be 2 + $lines[0] | Should -Match '^\[\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\] before the folder exists$' + $lines[1] | Should -Match 'after$' + $LogState.Pending.Count | Should -Be 0 + } + + It 'writes the version line in the format the agent reads' { + Write-Log "Bootstrap script version: v$ScriptVersion" + Open-LogFile + Get-Content $LogFile -Raw | Should -Match 'Bootstrap script version: v\d+(\.\d+){0,3}\b' } } -Describe 'logging purity' { - # Regression guard: Write-Log runs inside functions that RETURN decision - # objects. If it ever emits to the output stream again (Write-Output), the - # log lines leak into the return values and turn them into arrays. This - # test deliberately does NOT mock Write-Log. - It 'Get-BootstrapDecision returns exactly one object with real logging' { - Mock Get-ItemProperty { [pscustomobject]@{ Deployed = '2026-08-01T10:00:00Z' } } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\AutopilotMonitor' } - # Plain local assignment: PowerShell's dynamic scoping makes Write-Log - # (called from Get-BootstrapDecision) resolve this $LogFile instead of - # the ProgramData path defined by the dot-sourced script. - $LogFile = Join-Path $TestDrive 'bootstrap_test.log' - $d = @(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath (Join-Path $TestDrive 'NoAgent')) - $d.Count | Should -Be 1 - $d[0].ReasonCode | Should -Be 'AlreadyDeployed' - Get-Content $LogFile -Raw | Should -Match 'SKIP: Agent was previously deployed' +Describe 'Get-AgentPresence' { + BeforeAll { + # Get-ScheduledTask is a CDXML function: a module-qualified call inside its mock still + # reaches the mock, the command object does not. + $script:realGetScheduledTask = Get-Command -Name Get-ScheduledTask -Module ScheduledTasks + } + + BeforeEach { + Mock Start-Sleep { } + Mock Get-RegistryValue64 { $null } + Mock Get-ScheduledTask { throw (New-CategoryError -Category ObjectNotFound) } + $AgentBinPath = Join-Path $TestDrive "AgentBin-$([guid]::NewGuid().ToString('N'))" + } + + It 'finds no agent when marker, executable and task are all absent' { + Get-AgentPresence | Should -BeNullOrEmpty + } + + It 'names the deployment marker' { + Mock Get-RegistryValue64 { '2026-08-01T10:00:00Z' } -ParameterFilter { $SubKey -eq 'SOFTWARE\AutopilotMonitor' -and $Name -eq 'Deployed' } + Get-AgentPresence | Should -Be 'deployment marker' + } + + It 'names the agent executable' { + New-Item -ItemType Directory -Path $AgentBinPath -Force | Out-Null + Set-Content -Path (Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe') -Value 'stub' + Get-AgentPresence | Should -Be 'agent executable' + } + + It 'names the agent task' { + Mock Get-ScheduledTask { [pscustomobject]@{ TaskName = 'AutopilotMonitor-Agent' } } + Get-AgentPresence | Should -Be 'agent task' + } + + It 'counts an unreadable marker as present' { + Mock Get-RegistryValue64 { throw 'Requested registry access is not allowed.' } + Get-AgentPresence | Should -BeLike 'deployment marker unreadable*' + } + + It 'counts an agent executable it cannot check as present' { + Mock Get-Item { throw (New-CategoryError -Category PermissionDenied) } -ParameterFilter { $LiteralPath -like '*AutopilotMonitor.Agent.exe' } + Get-AgentPresence | Should -BeLike 'agent executable unreadable*' + } + + It 'counts a task query that fails for another reason as present, after one retry' { + Mock Get-ScheduledTask { throw (New-CategoryError -Category ResourceUnavailable) } + Get-AgentPresence | Should -BeLike 'agent task unreadable*' + Should -Invoke Get-ScheduledTask -Times 2 -Exactly + } + + It 'decides "no task" by the error category, not the message' { + # The real cmdlet on a name that cannot exist: its message is localized, its category is not. + Mock Get-ScheduledTask { & $script:realGetScheduledTask -TaskName "AutopilotMonitor-NoSuchTask-$([guid]::NewGuid().ToString('N'))" -ErrorAction Stop } + Get-AgentPresence | Should -BeNullOrEmpty + } +} + +Describe 'Test-IsOwnedByUs' { + It 'accepts SYSTEM as owner' { + Test-IsOwnedByUs $env:ProgramData | Should -BeTrue + } + + It 'refuses another owner (TrustedInstaller owns the Windows folder)' { + Test-IsOwnedByUs $env:SystemRoot | Should -BeFalse + } + + It 'refuses what it cannot read' { + Test-IsOwnedByUs (Join-Path $TestDrive 'does-not-exist') | Should -BeFalse + } +} + +Describe 'Test-IsReparsePoint' { + It 'tells a junction from a folder' { + $folder = New-TestFolder 'plain' + $link = Join-Path $TestDrive "link-$([guid]::NewGuid().ToString('N'))" + $null = New-SentinelJunction -Link $link + Test-IsReparsePoint $folder | Should -BeFalse + Test-IsReparsePoint $link | Should -BeTrue + } +} + +Describe 'Get-FolderState' { + It 'reports a missing folder, also below a missing parent' { + Get-FolderState (Join-Path $TestDrive 'nope') | Should -Be 'Missing' + Get-FolderState (Join-Path $TestDrive 'nope\deeper') | Should -Be 'Missing' + } + + It 'reports a file where the folder should be' { + $file = Join-Path $TestDrive "file-$([guid]::NewGuid().ToString('N'))" + Set-Content -Path $file -Value 'x' + Get-FolderState $file | Should -Be 'File' + } + + It 'reports a link without judging its target' { + Mock Test-IsOwnedByUs { $true } + $link = Join-Path $TestDrive "link-$([guid]::NewGuid().ToString('N'))" + $null = New-SentinelJunction -Link $link + Get-FolderState $link | Should -Be 'Link' + } + + It 'reports a folder by its owner' { + $folder = New-TestFolder 'owned' + Mock Test-IsOwnedByUs { $true } + Get-FolderState $folder | Should -Be 'Ours' + Mock Test-IsOwnedByUs { $false } + Get-FolderState $folder | Should -Be 'Foreign' + } +} + +Describe 'New-RunFolderName' { + It 'is 8 lowercase hex characters and changes on every call' { + $a = New-RunFolderName + $b = New-RunFolderName + $a | Should -Match '^[0-9a-f]{8}$' + $b | Should -Match '^[0-9a-f]{8}$' + $a | Should -Not -Be $b + } +} + +Describe 'New-RunFolder' { + BeforeEach { + Mock New-ProtectedDirectorySecurity { New-TestDirectorySecurity } + Mock Test-IsOwnedByUs { $true } + $script:parent = New-TestFolder 'runs' + } + + It 'creates prefix plus 8 hex characters with the protected DACL' { + $path = New-RunFolder -Parent $script:parent -Prefix 'Agent-Install-' + Split-Path $path -Leaf | Should -Match '^Agent-Install-[0-9a-f]{8}$' + (Get-Acl -LiteralPath $path).AreAccessRulesProtected | Should -BeTrue + Should -Invoke New-ProtectedDirectorySecurity -Times 1 -Exactly + } + + It 'never uses an existing name' { + $script:names = [System.Collections.Generic.Queue[string]]::new([string[]]@('aaaaaaaa', 'bbbbbbbb')) + Mock New-RunFolderName { $script:names.Dequeue() } + $taken = Join-Path $script:parent 'Loader-aaaaaaaa' + New-Item -ItemType Directory -Path $taken | Out-Null + Set-Content -Path (Join-Path $taken 'planted.txt') -Value 'x' + + $path = New-RunFolder -Parent $script:parent -Prefix 'Loader-' + + Split-Path $path -Leaf | Should -Be 'Loader-bbbbbbbb' + @(Get-ChildItem -LiteralPath $path -Force).Count | Should -Be 0 + } + + It 'never uses a junction planted at the name' { + $script:names = [System.Collections.Generic.Queue[string]]::new([string[]]@('cccccccc', 'dddddddd')) + Mock New-RunFolderName { $script:names.Dequeue() } + $target = New-SentinelJunction -Link (Join-Path $script:parent 'Loader-cccccccc') + + $path = New-RunFolder -Parent $script:parent -Prefix 'Loader-' + + Split-Path $path -Leaf | Should -Be 'Loader-dddddddd' + Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue + } + + It 'gives up after three names that are not ours' { + Mock Test-IsOwnedByUs { $false } + { New-RunFolder -Parent $script:parent -Prefix 'Loader-' } | Should -Throw '*Could not create a run folder*' + } +} + +Describe 'Remove-OwnTree' { + BeforeEach { + Mock Start-Sleep { } + Mock Test-IsOwnedByUs { $true } + $script:root = New-TestFolder 'tree' + } + + It 'removes an own tree with whatever is in it, also files no version knows yet' { + New-Item -ItemType Directory -Path (Join-Path $script:root 'Logs\deeper') -Force | Out-Null + Set-Content -Path (Join-Path $script:root 'Logs\bootstrap-script.log') -Value 'x' + Set-Content -Path (Join-Path $script:root 'Logs\deeper\future-file.dat') -Value 'x' + Set-Content -Path (Join-Path $script:root 'Install-AutopilotMonitor.ps1') -Value 'x' + + $left = Remove-OwnTree -Path $script:root + + Test-Path $script:root | Should -BeFalse + $left.Foreign.Count | Should -Be 0 + $left.Locked.Count | Should -Be 0 + } + + It 'removes a junction as a link and never follows it, at the top and deep in the tree' { + New-Item -ItemType Directory -Path (Join-Path $script:root 'Logs') -Force | Out-Null + $top = New-SentinelJunction -Link (Join-Path $script:root 'Agent') + $deep = New-SentinelJunction -Link (Join-Path $script:root 'Logs\loop') + + $left = Remove-OwnTree -Path $script:root + + Test-Path $script:root | Should -BeFalse + Test-Path (Join-Path $top 'sentinel.txt') | Should -BeTrue + Test-Path (Join-Path $deep 'sentinel.txt') | Should -BeTrue + $left.Locked.Count | Should -Be 0 + } + + It 'survives a junction that points back at the tree itself' { + New-Item -ItemType Directory -Path (Join-Path $script:root 'Logs') -Force | Out-Null + cmd.exe /c mklink /J "$(Join-Path $script:root 'Logs\cycle')" "$script:root" | Out-Null + + $left = Remove-OwnTree -Path $script:root + + Test-Path $script:root | Should -BeFalse + $left.Locked.Count | Should -Be 0 + } + + It 'leaves another account''s entries alone, and with them their folder' { + New-Item -ItemType Directory -Path (Join-Path $script:root 'Logs') -Force | Out-Null + Set-Content -Path (Join-Path $script:root 'Logs\own.log') -Value 'x' + Set-Content -Path (Join-Path $script:root 'Logs\foreign-planted.dll') -Value 'x' + Mock Test-IsOwnedByUs { $Path -notlike '*foreign-*' } + + $left = Remove-OwnTree -Path $script:root + + Test-Path (Join-Path $script:root 'Logs\foreign-planted.dll') | Should -BeTrue + Test-Path (Join-Path $script:root 'Logs\own.log') | Should -BeFalse + $left.Foreign | Should -Be @((Join-Path $script:root 'Logs\foreign-planted.dll')) + } + + It 'removes a read-only file' { + $file = Join-Path $script:root 'readonly.txt' + Set-Content -Path $file -Value 'x' + Set-ItemProperty -Path $file -Name IsReadOnly -Value $true + + $null = Remove-OwnTree -Path $script:root + + Test-Path $script:root | Should -BeFalse + } + + It 'reports a file in use, keeps going, and writes nothing to the error stream' { + $locked = Join-Path $script:root 'in-use.log' + Set-Content -Path $locked -Value 'x' + Set-Content -Path (Join-Path $script:root 'other.txt') -Value 'x' + $handle = [System.IO.File]::Open($locked, 'Open', 'Read', 'None') + try { + $errors = Get-ErrorRecords { $script:left = Remove-OwnTree -Path $script:root } + } finally { + $handle.Dispose() + } + + $errors.Count | Should -Be 0 + $script:left.Locked | Should -Contain $locked + Test-Path (Join-Path $script:root 'other.txt') | Should -BeFalse + Should -Invoke Start-Sleep -Times 2 -Exactly + } + + It 'keeps the root folder when asked to' { + Set-Content -Path (Join-Path $script:root 'x.txt') -Value 'x' + $null = Remove-OwnTree -Path $script:root -KeepRoot + Test-Path $script:root | Should -BeTrue + @(Get-ChildItem -LiteralPath $script:root -Force).Count | Should -Be 0 + } +} + +Describe 'Initialize-AgentBase' { + BeforeEach { + Mock Start-Sleep { } + Mock New-ProtectedDirectorySecurity { New-TestDirectorySecurity } + Mock Test-IsOwnedByUs { $true } + $script:base = Join-Path $TestDrive "AutopilotMonitor-$([guid]::NewGuid().ToString('N').Substring(0, 8))" + } + + It 'creates a missing folder with the protected DACL' { + Initialize-AgentBase -Path $script:base + Test-Path $script:base | Should -BeTrue + (Get-Acl -LiteralPath $script:base).AreAccessRulesProtected | Should -BeTrue + } + + It 'protects and empties an existing folder of ours, keeping the folder' { + New-Item -ItemType Directory -Path (Join-Path $script:base 'Agent') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $script:base 'Logs') -Force | Out-Null + Set-Content -Path (Join-Path $script:base 'Logs\bootstrap-script.log') -Value 'old' + Set-Content -Path (Join-Path $script:base 'Install-AutopilotMonitor.ps1') -Value 'old' + + Initialize-AgentBase -Path $script:base + + Test-Path $script:base | Should -BeTrue + @(Get-ChildItem -LiteralPath $script:base -Force).Count | Should -Be 0 + (Get-Acl -LiteralPath $script:base).AreAccessRulesProtected | Should -BeTrue + } + + It 'refuses when another account''s entries remain' { + New-Item -ItemType Directory -Path $script:base -Force | Out-Null + Set-Content -Path (Join-Path $script:base 'foreign-planted.dll') -Value 'x' + Mock Test-IsOwnedByUs { $Path -notlike '*foreign-*' } + { Initialize-AgentBase -Path $script:base } | Should -Throw '*holds entries another account created*' + Test-Path (Join-Path $script:base 'foreign-planted.dll') | Should -BeTrue + } + + It 'refuses a folder another account owns, without touching it' { + New-Item -ItemType Directory -Path $script:base -Force | Out-Null + Set-Content -Path (Join-Path $script:base 'theirs.txt') -Value 'x' + Mock Test-IsOwnedByUs { $false } + { Initialize-AgentBase -Path $script:base } | Should -Throw '*not a folder that SYSTEM or administrators created (Foreign)*' + Test-Path (Join-Path $script:base 'theirs.txt') | Should -BeTrue + } + + It 'refuses a link and leaves its target alone' { + $target = New-SentinelJunction -Link $script:base + { Initialize-AgentBase -Path $script:base } | Should -Throw '*(Link)*' + Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue + } + + It 'refuses a file' { + Set-Content -Path $script:base -Value 'x' + { Initialize-AgentBase -Path $script:base } | Should -Throw '*(File)*' + } +} + +Describe 'Remove-AgentBaseOnSkip' { + BeforeEach { + Mock Start-Sleep { } + Mock New-ProtectedDirectorySecurity { New-TestDirectorySecurity } + Mock Test-IsOwnedByUs { $true } + $script:base = Join-Path $TestDrive "AutopilotMonitor-$([guid]::NewGuid().ToString('N').Substring(0, 8))" + } + + It 'has nothing to do when the folder does not exist' { + Remove-AgentBaseOnSkip -Path $script:base | Should -BeLike '*does not exist*' + } + + It 'removes a folder of ours with everything a bootstrap left in it' { + New-Item -ItemType Directory -Path (Join-Path $script:base 'Agent') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $script:base 'Msi') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $script:base 'Logs') -Force | Out-Null + Set-Content -Path (Join-Path $script:base 'Logs\bootstrap-loader.log') -Value 'x' + Set-Content -Path (Join-Path $script:base 'Msi\Start-AutopilotMonitor.ps1') -Value 'x' + Set-Content -Path (Join-Path $script:base 'Install-AutopilotMonitor.ps1') -Value 'x' + + Remove-AgentBaseOnSkip -Path $script:base | Should -BeLike '*removed.' + + Test-Path $script:base | Should -BeFalse + } + + It 'keeps the folder when another account''s entry is in it, and says so' { + New-Item -ItemType Directory -Path $script:base -Force | Out-Null + Set-Content -Path (Join-Path $script:base 'foreign-planted.txt') -Value 'x' + Set-Content -Path (Join-Path $script:base 'own.txt') -Value 'x' + Mock Test-IsOwnedByUs { $Path -notlike '*foreign-*' } + + Remove-AgentBaseOnSkip -Path $script:base | Should -BeLike '*kept: 1 entries of another account*' + + Test-Path (Join-Path $script:base 'foreign-planted.txt') | Should -BeTrue + Test-Path (Join-Path $script:base 'own.txt') | Should -BeFalse + } + + It 'leaves a folder another account owns as it is' { + New-Item -ItemType Directory -Path $script:base -Force | Out-Null + Set-Content -Path (Join-Path $script:base 'theirs.txt') -Value 'x' + Mock Test-IsOwnedByUs { $false } + Remove-AgentBaseOnSkip -Path $script:base | Should -BeLike '*left as it is (Foreign)*' + Test-Path (Join-Path $script:base 'theirs.txt') | Should -BeTrue + } + + It 'leaves a link and its target as they are' { + $target = New-SentinelJunction -Link $script:base + Remove-AgentBaseOnSkip -Path $script:base | Should -BeLike '*left as it is (Link)*' + Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue + } + + It 'never throws' { + Mock Get-FolderState { throw 'unexpected' } + { Remove-AgentBaseOnSkip -Path $script:base } | Should -Not -Throw } } @@ -445,51 +898,184 @@ Describe 'Get-AgentPayloadRejections' { Describe 'Install-VerifiedAgentPayload' { BeforeEach { - Mock Write-Log { } + Mock Start-Sleep { } $src = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.cat', 'Newtonsoft.Json.dll') $script:zip = Join-Path $TestDrive "payload-$([guid]::NewGuid()).zip" Compress-Archive -Path (Join-Path $src '*') -DestinationPath $script:zip - $script:staging = Join-Path $TestDrive "staging-$([guid]::NewGuid())" + $script:staging = New-TestFolder 'Agent-Install' $script:target = Join-Path $TestDrive "agent-$([guid]::NewGuid())" } - It 'moves an accepted payload into the agent folder and removes the staging folder' { + It 'moves an accepted payload in as the agent folder' { Mock Get-AgentPayloadRejections { , @() } Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher @(Get-ChildItem $script:target | ForEach-Object Name | Sort-Object) | Should -Be @('AutopilotMonitor.Agent.cat', 'AutopilotMonitor.Agent.exe', 'Newtonsoft.Json.dll') - Test-Path $script:staging | Should -BeFalse + Test-Path (Join-Path $script:staging 'Agent') | Should -BeFalse } - It 'verifies the staging folder, never the agent folder' { + It 'checks the extracted payload in the run folder, never the agent folder' { Mock Get-AgentPayloadRejections { $script:checked = $Path; , @() } Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher - $script:checked | Should -Be $script:staging + $script:checked | Should -Be (Join-Path $script:staging 'Agent') } - It 'leaves the agent folder untouched when the payload is refused, and cleans up' { + It 'refuses a payload that fails the check and creates no agent folder' { Mock Get-AgentPayloadRejections { , @('payload differs from catalog (missing: none; extra: evil.dll; changed: none)') } { Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher } | Should -Throw '*Payload verification FAILED*evil.dll*' Test-Path $script:target | Should -BeFalse - Test-Path $script:staging | Should -BeFalse } - It 'removes leftovers from the agent folder before the payload moves in' { + It 'never merges into an agent folder that is already there' { New-Item -ItemType Directory -Path $script:target | Out-Null Set-Content -Path (Join-Path $script:target 'stale.dll') -Value 'x' Mock Get-AgentPayloadRejections { , @() } - Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher - Test-Path (Join-Path $script:target 'stale.dll') | Should -BeFalse - Test-Path (Join-Path $script:target 'AutopilotMonitor.Agent.exe') | Should -BeTrue + { Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher } | Should -Throw + @(Get-ChildItem $script:target | ForEach-Object Name) | Should -Be @('stale.dll') + } +} + +Describe 'Invoke-AgentInstallMode' { + It 'returns only the exit code; the program''s output goes to the host' { + $r = @(Invoke-AgentInstallMode -ExePath "$env:SystemRoot\System32\cmd.exe" -Arguments @('/c', 'echo from-agent & exit 3')) + $r.Count | Should -Be 1 + $r[0] | Should -Be 3 + } +} + +Describe 'Read-LoaderLogs' { + BeforeEach { + $script:realProgramData = $env:ProgramData + $env:ProgramData = New-TestFolder 'ProgramData' + $AgentLogPath = Join-Path $env:ProgramData 'AutopilotMonitor\Logs' + $LegacyInstallerPath = Join-Path $env:ProgramData 'AutopilotMonitor\Install-AutopilotMonitor.ps1' + } + AfterEach { + $env:ProgramData = $script:realProgramData } - It 'starts from an empty staging folder even when a previous run left one behind' { - New-Item -ItemType Directory -Path $script:staging | Out-Null - Set-Content -Path (Join-Path $script:staging 'leftover.dll') -Value 'x' - Mock Get-AgentPayloadRejections { $script:seen = @(Get-ChildItem $Path | ForEach-Object Name); , @() } - Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher - $script:seen | Should -Not -Contain 'leftover.dll' + It 'takes the whole log of loader 1.3 from the run folder it started this script from' { + $run = Join-Path $env:ProgramData 'AutopilotMonitor-Loader-0a1b2c3d' + New-Item -ItemType Directory -Path $run | Out-Null + Set-Content -Path (Join-Path $run 'bootstrap-msi.log') -Value '[2026-10-22 10:00:00] === Autopilot Monitor bootstrap loader v1.3 started (user=SYSTEM, pid=1) ===' + + $logs = @(Read-LoaderLogs -ScriptPath (Join-Path $run 'Install-AutopilotMonitor.ps1') -ScriptRoot $run) + + $logs.Count | Should -Be 1 + $logs[0].Name | Should -Be 'bootstrap-msi.log' + $logs[0].Text | Should -BeLike '*bootstrap loader v1.3 started*' + } + + It 'takes only the last start line of an older loader, rebuilt from the pattern' { + New-Item -ItemType Directory -Path $AgentLogPath -Force | Out-Null + Set-Content -Path (Join-Path $AgentLogPath 'bootstrap-loader.log') -Value @( + '[2026-10-01 09:00:00] === Autopilot Monitor bootstrap loader v1.1 started (user=SYSTEM, pid=1) ===' + 'anything else a user may have planted here' + '[2026-10-22 10:00:00] === Autopilot Monitor bootstrap loader v1.2 started (user=SYSTEM, pid=2) ===' + '[2026-10-22 10:00:01] Signature accepted' + ) + + $logs = @(Read-LoaderLogs -ScriptPath $LegacyInstallerPath -ScriptRoot (Split-Path $LegacyInstallerPath)) + + $logs.Count | Should -Be 1 + $logs[0].Name | Should -Be 'bootstrap-loader.log' + $logs[0].Text | Should -Be "[2026-10-22 10:00:00] === Autopilot Monitor bootstrap loader v1.2 started (carried over by the bootstrapper) ===`r`n" + } + + It 'takes nothing when the script was assigned directly' { + $ime = New-TestFolder 'IMEScripts' + @(Read-LoaderLogs -ScriptPath (Join-Path $ime 'x.ps1') -ScriptRoot $ime).Count | Should -Be 0 + } +} + +Describe 'Remove-LegacyInstallerCopy' { + BeforeEach { + $LegacyInstallerPath = Join-Path (New-TestFolder 'Base') 'Install-AutopilotMonitor.ps1' + Set-Content -Path $LegacyInstallerPath -Value 'x' + } + + It 'deletes this script when it runs from the path older loaders use, ignoring case' { + Remove-LegacyInstallerCopy -ScriptPath $LegacyInstallerPath.ToUpperInvariant() + Test-Path $LegacyInstallerPath | Should -BeFalse + } + + It 'leaves every other path alone (IME copy, manual run)' { + $other = Join-Path $TestDrive 'Install-AutopilotMonitor.ps1' + Set-Content -Path $other -Value 'x' + Remove-LegacyInstallerCopy -ScriptPath $other + Test-Path $other | Should -BeTrue + Test-Path $LegacyInstallerPath | Should -BeTrue + } + + It 'never throws' { + { Remove-LegacyInstallerCopy -ScriptPath $null } | Should -Not -Throw + { Remove-LegacyInstallerCopy -ScriptPath 'C:\does\not\exist.ps1' } | Should -Not -Throw + } +} + +Describe 'Remove-StaleLoaderRunFolders' { + BeforeEach { + Mock Start-Sleep { } + $script:realProgramData = $env:ProgramData + $env:ProgramData = New-TestFolder 'ProgramData' + } + AfterEach { + $env:ProgramData = $script:realProgramData + } + + It 'removes the run folders of earlier loader runs and keeps the current one' { + Mock Test-IsOwnedByUs { $true } + $current = Join-Path $env:ProgramData 'AutopilotMonitor-Loader-11111111' + $stale = Join-Path $env:ProgramData 'AutopilotMonitor-Loader-22222222' + New-Item -ItemType Directory -Path $current, $stale | Out-Null + Set-Content -Path (Join-Path $stale 'bootstrap-loader.log') -Value 'x' + + Remove-StaleLoaderRunFolders -ScriptRoot $current + + Test-Path $current | Should -BeTrue + Test-Path $stale | Should -BeFalse + } + + It 'leaves alone what is not ours or is a link' { + Mock Test-IsOwnedByUs { $false } + $foreign = Join-Path $env:ProgramData 'AutopilotMonitor-Loader-33333333' + New-Item -ItemType Directory -Path $foreign | Out-Null + $target = New-SentinelJunction -Link (Join-Path $env:ProgramData 'AutopilotMonitor-Loader-44444444') + + Remove-StaleLoaderRunFolders -ScriptRoot $null + + Test-Path $foreign | Should -BeTrue + Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue + } +} + +Describe 'script text' { + BeforeAll { + $script:text = Get-Content -LiteralPath $script:InstallerPath -Raw + $tokens = $null + $parseErrors = $null + $script:ast = [System.Management.Automation.Language.Parser]::ParseFile($script:InstallerPath, [ref]$tokens, [ref]$parseErrors) + } + + It 'declares the role and version the loader checks, one line each' { + ([regex]::Matches($script:text, '(?m)^\$ScriptRole = "AutopilotMonitor-Installer"\s*$')).Count | Should -Be 1 + ([regex]::Matches($script:text, '(?m)^\$ScriptVersion = "2\.7"\s*$')).Count | Should -Be 1 + } + + It 'never stages anything in the shared temp folder' { + $script:text | Should -Not -Match 'env:TEMP' + } + + It 'never deletes recursively except through Remove-OwnTree' { + $removeItems = $script:ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -in @('Remove-Item', 'ri', 'rm', 'del', 'rd', 'rmdir', 'erase') }, $true) + @($removeItems).Count | Should -Be 0 + $script:text | Should -Not -Match 'Delete\([^)]*\$true\)' + } + + It 'carries each shared-block marker exactly once' { + ([regex]::Matches($script:text, '### BEGIN shared install helpers')).Count | Should -Be 1 + ([regex]::Matches($script:text, '### END shared install helpers')).Count | Should -Be 1 } } diff --git a/scripts/Bootstrap/Install-AutopilotMonitor.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor.ps1 index caa2123f..02ca1d1c 100644 --- a/scripts/Bootstrap/Install-AutopilotMonitor.ps1 +++ b/scripts/Bootstrap/Install-AutopilotMonitor.ps1 @@ -1,131 +1,40 @@ <# .SYNOPSIS - Bootstrap script to deploy and start the Autopilot Monitor agent. + Installs the Autopilot Monitor agent during Windows Autopilot enrollment. .DESCRIPTION - Designed to be deployed via Intune as a PowerShell Script during Autopilot. - Runs very early in the enrollment process (first Intune action) and: - 1. Runs pre-flight guards to skip productive devices and ghost re-installs. - A relax exemption allows install when a single freshly-created profile - appears DURING OOBE (Windows Backup for Organizations restore) or on a - freshly provisioned Windows 365 Cloud PC. - 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 a fresh staging folder - 5. Verifies the payload against its signed catalog (AutopilotMonitor.Agent.cat): - signed by the expected publisher, every file present and unchanged, nothing extra - 6. Moves the verified payload into %ProgramData%\AutopilotMonitor\Agent - 7. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime) - 8. Verifies the runtime process actually launched - Agent self-destructs when enrollment completes. + 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. - Relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser) - normally means "productive -> skip". Two legitimate flows break that assumption: + 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. - (1) Windows Backup for Organizations: one user signs in + restores DURING OOBE. - Trigger: the OS positively reports OOBE InProgress. - - (2) Windows 365 Cloud PC: W365 provisions headless -- OOBE and Intune enrollment - complete before any user ever connects, so OOBE is never InProgress when - this script runs. The assigned user's profile is created at first connect, - typically seconds before Intune pulls this script. Trigger: the device - positively identifies as a Cloud PC via TWO local markers (both required): - the HKLM\SOFTWARE\Microsoft\Windows365 registry key AND the - CloudManagedDesktopExtension service. - - Either trigger relaxes guards 2+3 ONLY when additionally: - (a) there is exactly ONE real user profile, AND - (b) that profile was created within $OobeProfileMaxAgeMinutes minutes. - The Cloud PC trigger additionally exempts guard 4 (uptime window) -- see - .PARAMETER MaxBootstrapWindowHours. Any miss keeps the original skip - (SKIP-safe: we never install on uncertainty). On productive devices the - profile is old, so (b) keeps skipping correctly. + 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 - URL to download the agent ZIP from. Defaults to the production download endpoint; - override only for parallel lab/dev assignments that point at a separate pre-release ZIP. + Agent ZIP to install. Default: the published agent. .PARAMETER VersionJsonName - Filename of the integrity manifest in the same blob container as the agent ZIP. - Defaults to "version.json"; override only for parallel lab/dev manifests. + Manifest next to the agent ZIP that holds its SHA-256. Default: version.json. .PARAMETER MaxBootstrapWindowHours - Maximum device uptime (hours) within which the bootstrap is still considered - valid. Devices booted more than this many hours ago are skipped because we no - longer trust their OOBE state. Default: 12. - Not applied while the Cloud PC relax is active: a Cloud PC runs headless for - days between provisioning and the user's first connect, so the fresh-profile - window (OobeProfileMaxAgeMinutes) is the time anchor there, not boot uptime. + 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 (minutes) of the single profile that may appear for the relax - exemption to apply. The restore/first-connect -> bootstrap gap is always - minutes; an older profile is treated as a productive device. Default: 15. + Maximum age of the one new profile accepted during OOBE or on a Cloud PC. Default: 15. .NOTES - - Agent is temporary and auto-removes after enrollment. - - All files live under C:\ProgramData\AutopilotMonitor (easy cleanup). - - One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed - (prevents ghost re-installs on re-Autopilot of the same device). - - Scheduled Task survives reboots during enrollment. - - OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState. - Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack - the class -> state reads as 'Unavailable' -> original (skip) behaviour. Docs: - https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo - - Guard/relax logic lives in functions; the script only executes when run - directly. Dot-sourcing it (as the Pester suite in - Install-AutopilotMonitor.Tests.ps1 does) loads the functions without side effects. - - This script MUST remain pure ASCII (no Unicode/UTF-8 special chars). - PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars. - -.CHANGELOG - 2026-10-13 v2.6 Payload catalog: the agent ZIP carries AutopilotMonitor.Agent.cat, - a catalog of every payload file signed by the expected publisher. - The ZIP is extracted into a staging folder and moved into the - agent folder only when Test-FileCatalog finds the catalog signed - by the expected publisher and every file present, unchanged and - nothing extra. A rejected payload is deleted, so the next run - starts clean. Replaces the v2.5 gate, which covered only the - AutopilotMonitor binaries. - 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 - InProgress; the single-fresh-profile conditions are unchanged - and guard 4 (uptime window) is exempted for Cloud PCs. - Raw OOBE state and Cloud PC marker states are now logged. - Guard/relax logic refactored into functions with a dot-source - entry guard so the Pester suite can test it without executing - the bootstrap; behaviour when run directly is unchanged. - 2026-07-20 v2.2 Switched default download endpoint from the legacy blob - (autopilotmonitor.blob.core.windows.net) to the Front Door alias - https://download.autopilotmonitor.com/agent/ (route /agent/*). - version.json is still derived from the same container. - 2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT - SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a - single freshly-created profile appears in OOBE (Windows Backup for - Organizations). SKIP-safe when the API is absent (< 1809). - Removed legacy Content-MD5 fallback; SHA-256 manifest verification is - now mandatory (no hash -> bootstrap aborts, no unverified install). - 2026-08-18 v2.4 Renamed log file bootstrap_agent.log -> bootstrap-script.log - (unified kebab-case log naming across the agent log directory). - 2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s - TenantId-wait), so the script only calls `--install` plain. - Hardened post-install with a 10 s runtime-process verify. - 2026-04-09 v1.1 Introduced explicit script version, logged on startup. - 2026-03-31 Replaced OS age + MDM pre-flight checks with multi-signal guard - (registry deployment marker, WMI/filesystem user profile, last - logged-on user, 12 h bootstrap window). - 2026-03-30 Fixed non-ASCII characters that broke parsing under PowerShell 5.1. - 2026-03-29 Hardened integrity check: SHA-256 verification via version.json. - 2026-02-13 Simplified bootstrapper, introduced --install parameter for agent. - 2026-02-12 Robust download with integrity check (Content-MD5), boot time support, - pre-flight check to skip if agent already installed. - 2026-02-05 Initial version. + Requires Windows PowerShell 5.1. + Changes: https://docs.autopilotmonitor.com/changelog/platform-changelog #> [CmdletBinding()] @@ -143,39 +52,81 @@ param( [int]$OobeProfileMaxAgeMinutes = 15 ) -# Script version (bump on meaningful changes; see .CHANGELOG above) -$ScriptVersion = "2.6" +# The loader checks these two lines before it runs this script. +$ScriptVersion = "2.7" +$ScriptRole = "AutopilotMonitor-Installer" -# The publisher the payload catalog 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. +# Signer of the agent's file catalog; a subject pattern, so certificate renewals keep working. $ExpectedPublisher = "*O=glueckkanja AG*" - -# Signed catalog at the root of every agent ZIP: the SHA-256 of every other payload file. $AgentCatalogName = "AutopilotMonitor.Agent.cat" -# Configuration - Everything in ProgramData for easy cleanup $AgentBasePath = "$env:ProgramData\AutopilotMonitor" $AgentBinPath = "$AgentBasePath\Agent" -$AgentStagingPath = "$AgentBasePath\Agent-Install" $AgentLogPath = "$AgentBasePath\Logs" $LogFile = "$AgentLogPath\bootstrap-script.log" -# Write-Host (NOT Write-Output): log calls happen inside functions that return -# decision objects; Write-Output would leak every log line into those return values. -function Write-Log { - param([string]$Message) - $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" - $logMessage = "[$timestamp] $Message" - Write-Host $logMessage - Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue +# 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]) } -# Raw OOBE state via the documented WinRT API -# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState. -# Returns the state string (InProgress/Completed/NotStarted). An older OS without the -# class (< Win10 1809 / 10.0.17763.0) or any error returns 'Unavailable' -# -> SKIP-safe (we never relax a guard on uncertainty). +# 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] @@ -186,12 +137,7 @@ function Get-OobeState { } } -# Positive Windows 365 Cloud PC detection. Local WMI/SMBIOS on a Cloud PC only -# reports the generic Manufacturer='Microsoft Corporation' / Model='Virtual Machine', -# so identification uses two W365-specific markers instead. BOTH are required (AND): -# each alone has look-alikes (e.g. W365 Boot physical clients, other managed-desktop -# agents). Identity and marker states are logged for diagnosis; any error returns -# $false (SKIP-safe). +# 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 @@ -200,7 +146,7 @@ function Test-IsCloudPc { catch { Write-Log "INFO: computer identity query failed: $($_.Exception.Message)" } try { - $regMarker = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows365' + $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) @@ -211,10 +157,8 @@ function Test-IsCloudPc { } } -# Collects real (non-special) user profiles as full paths (WMI Special flag + filesystem). -# NOTE: callers MUST wrap the result in @(...). PowerShell unrolls function output, so an -# empty result returns $null and a single path returns a scalar string, whose [0] indexer -# would yield its first char ('C') instead of the full path. +# 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)$' @( @@ -229,12 +173,8 @@ function Get-RealUserProfilePaths { Select-Object -Unique } -# Relax: the ONLY exemption to guards 2+3. Two triggers (see .DESCRIPTION): -# (1) OOBE InProgress -> Windows Backup for Organizations restore -# (2) Cloud PC identity -> Windows 365 (headless OOBE, profile at first connect) -# Either trigger additionally requires: exactly one real profile AND that profile -# created < $OobeProfileMaxAgeMinutes minutes ago. Any miss keeps the original skip. -# Returns an object with: Active (bool), OobeState (string), IsCloudPc (bool). +# 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, @@ -281,30 +221,25 @@ function Get-RelaxDecision { } } -# Runs pre-flight guards 1-5 and returns the install decision WITHOUT side effects -# beyond logging. Returns an object with: -# Install (bool), ReasonCode (null | AlreadyDeployed | ProfilesFound | -# LastLoggedOnUser | UptimeExceeded | AgentPresent), RelaxActive (bool). +# Guards 1-4; reads only. Returns Install, ReasonCode and RelaxActive. function Get-BootstrapDecision { param( [int]$MaxBootstrapWindowHours, - [int]$OobeProfileMaxAgeMinutes, - [string]$AgentBinPath + [int]$OobeProfileMaxAgeMinutes ) - # Guard 1: Agent was already deployed on this device (registry marker survives self-destruct) - $deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed + # 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 } } - # The @() wrap is required, see NOTE on Get-RealUserProfilePaths. $profilePaths = @(Get-RealUserProfilePaths) $relax = Get-RelaxDecision -ProfilePaths $profilePaths -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes - # Guard 2: No real user profile should exist yet (primary productive-device guard), - # unless the relax signature holds. + # Guard 2: real user profiles. if ($profilePaths.Count -gt 0 -and -not $relax.Active) { $details = (($profilePaths | ForEach-Object { $leaf = Split-Path $_ -Leaf @@ -317,19 +252,16 @@ function Get-BootstrapDecision { return [pscustomobject]@{ Install = $false; ReasonCode = 'ProfilesFound'; RelaxActive = $false } } - # Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet - # (same relax exemption: the restoring/first-connect user signs in legitimately). - $lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser + # 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: Bootstrap window check - device uptime must be within accepted OOBE window. - # Sleep/standby does not reset uptime, only real boot/restart does. - # Exempted while the Cloud PC relax is active: a Cloud PC runs headless from - # provisioning until the user's first connect (often days), so boot uptime says - # nothing there -- the fresh-profile window of the relax is the time anchor. + # 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)" @@ -342,27 +274,197 @@ function Get-BootstrapDecision { } } - # Guard 5: Is the agent already installed? (leftover from previous run) - if (Test-Path $AgentBinPath) { - $existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue - if ($existingAgent) { - Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)." - return [pscustomobject]@{ Install = $false; ReasonCode = 'AgentPresent'; RelaxActive = $relax.Active } - } - } - return [pscustomobject]@{ Install = $true; ReasonCode = $null; RelaxActive = $relax.Active } } -### BEGIN shared payload verification -- embedded verbatim into the OOBE go-script by -### OobeBootstrapScriptGenerator (D-354). Keep it self-contained: parameters only, no -### script-scope variables, no Write-Log. -# Returns the reasons why the extracted payload in $Path must not run; an empty result means -# it matches its signed catalog exactly. Test-FileCatalog's Status covers the hashes only (an -# unsigned catalog over matching files reports Valid), so the signature and its publisher are -# judged on their own. Any error is a rejection: a payload that cannot be verified is an -# unverified payload. The comma keeps a single rejection an ARRAY: PowerShell unrolls a -# one-element result to a bare string, and the caller reads .Count. +# 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, @@ -415,11 +517,8 @@ function Get-AgentPayloadRejections { return , $rejected } -# Extracts the agent ZIP into a fresh staging folder, verifies it there and moves it into the -# agent folder only when it matches its signed catalog. A rejected payload is deleted with the -# staging folder: nothing unverified lands where SYSTEM runs the agent, and the next run starts -# clean instead of finding a half-installed agent. The caller has made sure no agent is -# installed, so anything already in the agent folder is a leftover and goes first. +# 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, @@ -429,57 +528,174 @@ function Install-VerifiedAgentPayload { [string]$CatalogName = 'AutopilotMonitor.Agent.cat' ) - if (Test-Path -LiteralPath $StagingPath) { - Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction Stop - } - New-Item -Path $StagingPath -ItemType Directory -Force | Out-Null - try { - Expand-Archive -Path $ZipPath -DestinationPath $StagingPath -Force -ErrorAction Stop + $payload = Join-Path $StagingPath 'Agent' + Expand-Archive -Path $ZipPath -DestinationPath $payload -Force -ErrorAction Stop - $rejections = Get-AgentPayloadRejections -Path $StagingPath -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." - } - - if (Test-Path -LiteralPath $TargetPath) { - Get-ChildItem -LiteralPath $TargetPath -Force | Remove-Item -Recurse -Force -ErrorAction Stop - } - else { - New-Item -Path $TargetPath -ItemType Directory -Force | Out-Null - } - Get-ChildItem -LiteralPath $StagingPath -Force | Move-Item -Destination $TargetPath -Force -ErrorAction Stop + $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." } - finally { - Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction SilentlyContinue + + 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 + } } } -### END shared payload verification -# Main bootstrap flow. Only invoked when the script is run directly (see entry guard -# at the bottom); never on dot-source. -function Invoke-Bootstrap { +# 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 { - New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null - New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null - New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null + $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 v1.3 run folder; 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) { + 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. Never throws. +function Remove-StaleLoaderRunFolders { + param([string]$ScriptRoot) + try { $folders = [System.IO.Directory]::GetDirectories($env:ProgramData, $LoaderRunFolderPrefix + '*') } catch { return } + 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') { $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, and a working directory + # cannot be deleted. + [System.IO.Directory]::SetCurrentDirectory("$env:SystemRoot\System32") Write-Log "===== Autopilot Monitor Bootstrap Started =====" Write-Log "Bootstrap script version: v$ScriptVersion" - $decision = Get-BootstrapDecision -MaxBootstrapWindowHours $MaxBootstrapWindowHours -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes -AgentBinPath $AgentBinPath + $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))" - # Download, verify and install the agent binaries. Guard 5 has already returned when an - # agent exe exists, so this always starts without an installed agent. - $agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe" - Write-Log "Downloading agent from $AgentDownloadUrl..." + $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 { - # Derive manifest URL from the agent download URL (same blob container) + # The manifest sits next to the agent ZIP. $versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName $expectedSha256 = $null $manifestAttempts = 3 @@ -505,7 +721,7 @@ function Invoke-Bootstrap { } } - $zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip" + $zipPath = Join-Path $staging "AutopilotMonitor-Agent.zip" $maxDownloadAttempts = 3 $downloadAttempt = 0 @@ -537,8 +753,7 @@ function Invoke-Bootstrap { } } while ($downloadAttempt -lt $maxDownloadAttempts) - # SHA-256 from the version manifest is the only accepted integrity proof. - # No hash -> refuse to install an unverified ZIP (SKIP-safe security posture). + # No hash, no install. if (-not $expectedSha256) { throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP." } @@ -549,20 +764,12 @@ function Invoke-Bootstrap { } Write-Log "SHA-256 integrity check passed" - # Payload catalog. The SHA-256 above proves the ZIP is the one the manifest names, but - # that manifest lives in the same container as the ZIP and cannot outlive a compromise - # of the download host. The catalog is signed by the expected publisher, whose pin - # travels inside THIS script -- verified by the loader before it ran -- so the chain - # from the file an administrator assigns to every file the agent runs from stays - # publisher-anchored end to end. Fail-closed like the hash check: running unverified - # code as SYSTEM is the risk being removed. + # 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 $AgentStagingPath -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName + Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $staging -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName Write-Log "Payload verified against its signed catalog and installed to $AgentBinPath" - Remove-Item $zipPath -Force -ErrorAction SilentlyContinue - Write-Log "Cleaned up temporary files" - if (-not (Test-Path $agentExePath)) { throw "Agent executable not found after extraction at $agentExePath" } @@ -573,10 +780,15 @@ function Invoke-Bootstrap { 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)..." - & $agentExePath --install - $installExitCode = $LASTEXITCODE + $installExitCode = Invoke-AgentInstallMode -ExePath $agentExePath if ($installExitCode -ne 0) { throw "Agent install failed with exit code $installExitCode" } @@ -607,19 +819,22 @@ function Invoke-Bootstrap { Write-Log "===== Bootstrap FAILED =====" Write-Log "ERROR: $($_.Exception.Message)" Write-Log "Stack trace: $($_.ScriptStackTrace)" - Write-Log "Please check log file: $LogFile" + if ($LogState.Ready) { Write-Log "Please check log file: $LogFile" } $errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)" - if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' } + if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 2045) + '...' } [Console]::Error.WriteLine($errMsg) exit 1 } } -# Entry guard: execute only when run as a script (IME, console). Dot-sourcing -# (". .\Install-AutopilotMonitor.ps1", as the Pester suite does) only loads -# the functions above and MUST stay side-effect free. +# Dot-sourcing (tests, release pipeline) loads the functions without running the bootstrap. if ($MyInvocation.InvocationName -ne '.') { - Invoke-Bootstrap + try { + Invoke-Bootstrap + } + finally { + Remove-LegacyInstallerCopy -ScriptPath $PSCommandPath + } } diff --git a/scripts/Bootstrap/Start-AutopilotMonitor.Tests.ps1 b/scripts/Bootstrap/Start-AutopilotMonitor.Tests.ps1 index 1dbfc0c5..299bed87 100644 --- a/scripts/Bootstrap/Start-AutopilotMonitor.Tests.ps1 +++ b/scripts/Bootstrap/Start-AutopilotMonitor.Tests.ps1 @@ -3,7 +3,7 @@ # The script under test is dot-sourced; its entry guard prevents the loader from # executing, so loading it here is side-effect free. Network and signature checks # are mocked -- what is verified is the DECISION: what does the loader accept, what -# does it refuse, and how often does it retry. +# does it refuse, how often does it retry, and what does it leave behind. # # Run under Windows PowerShell 5.1 to match the IME runtime: # powershell.exe -NoProfile -Command "& { $c = New-PesterConfiguration; $c.Run.Path = 'scripts\Bootstrap'; $c.Output.Verbosity = 'Detailed'; Invoke-Pester -Configuration $c }" @@ -20,6 +20,20 @@ BeforeAll { } else { $null } return [pscustomobject]@{ Status = $Status; SignerCertificate = $cert } } + + # Arguments as PowerShell hands them to a script, names marked as names; an array of + # plain strings would bind by position instead. + function ConvertTo-ScriptArguments { return , $args } + + function Get-ScriptAst { + param([string]$Path) + $tokens = $null; $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors) + return [pscustomobject]@{ Ast = $ast; Tokens = $tokens; Errors = $errors } + } + + $script:loaderPath = Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1' + $script:installerPath = Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1' } Describe 'Test-BootstrapSignature' { @@ -69,11 +83,90 @@ Describe 'Test-BootstrapSignature' { } } +Describe 'Get-InstallerIdentity' { + BeforeAll { + $script:roleLine = '$ScriptRole = "AutopilotMonitor-Installer"' + } + + It 'reads the role and the version of LF text' { + $id = Get-InstallerIdentity -Text ("param()`n`$ScriptVersion = `"2.7`"`n" + $script:roleLine + "`n") + $id.Role | Should -BeExactly 'AutopilotMonitor-Installer' + $id.Version | Should -Be ([version]'2.7') + } + + It 'reads CRLF text that ends in an Authenticode signature block' { + $text = (@('$ScriptVersion = "2.7"', $script:roleLine, '# SIG # Begin signature block', '# MIIFxwYJKoZIhvcNAQcCoIIFuDCCBbQCAQExCzAJBgUrDgMCGgUAMGkGCisGAQQB', '# SIG # End signature block') -join "`r`n") + "`r`n" + (Get-InstallerIdentity -Text $text).Version | Should -Be ([version]'2.7') + } + + It 'compares versions as numbers, so 2.10 is above 2.7' { + $id = Get-InstallerIdentity -Text ("`$ScriptVersion = `"2.10`"`n" + $script:roleLine + "`n") + $id.Version -gt [version]'2.7' | Should -BeTrue + } + + It 'returns nothing for ' -TestCases @( + @{ Case = 'a missing role line'; Lines = @('$ScriptVersion = "2.7"') } + @{ Case = 'a missing version line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"') } + @{ Case = 'a role line in a comment'; Lines = @('# $ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7"') } + @{ Case = 'an indented version line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', ' $ScriptVersion = "2.7"') } + @{ Case = 'a second version line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7"', '$ScriptVersion = "9.9"') } + @{ Case = 'a second role line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptRole = "Other"', '$ScriptVersion = "2.7"') } + @{ Case = 'a pre-release version'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7-beta"') } + @{ Case = 'a version line with more code'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7"; $x = 1') } + ) { + Get-InstallerIdentity -Text (($Lines -join "`n") + "`n") | Should -BeNullOrEmpty + } + + # The pair the loader and the installer in this repository must keep: the published + # installer has to pass the loader that ships with it. + It 'accepts the installer in this repository' { + $id = Get-InstallerIdentity -Text ([System.IO.File]::ReadAllText($script:installerPath)) + $id.Role | Should -BeExactly $ExpectedInstallerRole + $id.Version -ge $MinimumInstallerVersion | Should -BeTrue + } +} + +Describe 'Test-InstallerIdentity' { + BeforeEach { + Mock Write-Log { } + $script:candidate = Join-Path $TestDrive ("installer-" + [guid]::NewGuid().ToString('N') + '.ps1') + } + + It 'accepts version ' -TestCases @(@{ Version = '2.7' }, @{ Version = '2.10' }, @{ Version = '3.0.1' }) { + [System.IO.File]::WriteAllText($script:candidate, "`$ScriptVersion = `"$Version`"`n`$ScriptRole = `"AutopilotMonitor-Installer`"`n") + Test-InstallerIdentity -Path $script:candidate | Should -BeTrue + } + + # Every installer the publisher ever signed keeps a valid signature; the minimum keeps the + # older ones from running. + It 'refuses an installer below the minimum version' { + [System.IO.File]::WriteAllText($script:candidate, "`$ScriptVersion = `"2.6`"`n`$ScriptRole = `"AutopilotMonitor-Installer`"`n") + Test-InstallerIdentity -Path $script:candidate | Should -BeFalse + Should -Invoke Write-Log -ParameterFilter { $Message -like '*below the minimum 2.7*' } + } + + # The publisher signs other scripts too. + It 'refuses another role' { + [System.IO.File]::WriteAllText($script:candidate, "`$ScriptVersion = `"2.7`"`n`$ScriptRole = `"AutopilotMonitor-Uninstaller`"`n") + Test-InstallerIdentity -Path $script:candidate | Should -BeFalse + } + + It 'refuses a script without the identity lines' { + [System.IO.File]::WriteAllText($script:candidate, "Write-Host 'signed, but not the installer'`n") + Test-InstallerIdentity -Path $script:candidate | Should -BeFalse + } + + It 'refuses a file it cannot read' { + Test-InstallerIdentity -Path (Join-Path $TestDrive 'missing.ps1') | Should -BeFalse + } +} + Describe 'Get-VerifiedBootstrapScript' { BeforeEach { Mock Write-Log { } Mock Start-Sleep { } Mock Invoke-WebRequest { } + Mock Test-InstallerIdentity { $true } } It 'returns after the first attempt when the download verifies' { @@ -105,15 +198,204 @@ Describe 'Get-VerifiedBootstrapScript' { Should -Invoke Invoke-WebRequest -Times 2 -Exactly } - It 'never executes anything when no attempt verifies' { + It 'refuses a validly signed script that is not the current installer' { + Mock Test-BootstrapSignature { $true } + Mock Test-InstallerIdentity { $false } + Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeFalse + Should -Invoke Invoke-WebRequest -Times 3 -Exactly + } + + # The identity lines count only once the signature proves they are the publisher's. + It 'reads the identity only after the signature passed' { Mock Test-BootstrapSignature { $false } Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeFalse + Should -Invoke Test-InstallerIdentity -Times 0 -Exactly + } +} + +Describe 'Get-DownloadSecurityProtocol' { + It 'keeps the system default, which includes TLS 1.3' { + Get-DownloadSecurityProtocol -Current ([Net.SecurityProtocolType]::SystemDefault) | Should -Be ([Net.SecurityProtocolType]::SystemDefault) + } + + It 'adds TLS 1.2 to an explicit list' { + Get-DownloadSecurityProtocol -Current ([Net.SecurityProtocolType]'Ssl3, Tls') | Should -Be ([Net.SecurityProtocolType]'Ssl3, Tls, Tls12') + } +} + +Describe 'Remove-LoaderRunFiles' { + BeforeEach { + Mock Write-Host { } + $run = Join-Path $TestDrive ("run-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $run | Out-Null + $installer = Join-Path $run 'Install-AutopilotMonitor.ps1' + $log = Join-Path $run 'bootstrap-loader.log' + Set-Content -Path $installer -Value 'downloaded' + Set-Content -Path $log -Value 'log' + $LogState = [pscustomobject]@{ File = $log } + } + + It 'removes the download, the log and the folder' { + Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer + Test-Path -LiteralPath $run | Should -BeFalse + $LogState.File | Should -BeNullOrEmpty + } + + It 'keeps the log and the folder with -KeepLog, but never the download' { + Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer -KeepLog + @(Get-ChildItem -LiteralPath $run -Force | ForEach-Object Name) | Should -Be @('bootstrap-loader.log') + } + + It 'leaves a folder that holds anything else, without throwing' { + Set-Content -Path (Join-Path $run 'other.txt') -Value 'other' + { Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer } | Should -Not -Throw + @(Get-ChildItem -LiteralPath $run -Force | ForEach-Object Name) | Should -Be @('other.txt') + Should -Invoke Write-Host -ParameterFilter { "$Object" -like 'INFO: run folder*' } + } +} + +Describe 'Start-BootstrapLoader' { + BeforeEach { + $script:savedProgramData = $env:ProgramData + $env:ProgramData = Join-Path $TestDrive ("pd-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $env:ProgramData | Out-Null + $script:runFolder = Join-Path $env:ProgramData 'AutopilotMonitor-Loader-0a1b2c3d' + $script:marker = Join-Path $TestDrive ("ran-" + [guid]::NewGuid().ToString('N') + '.txt') + + $LogState = [pscustomobject]@{ File = $null } + $InstallerArguments = @() + + Mock Write-Host { } + Mock Start-Sleep { } + Mock Test-IsElevated { $true } + # A standard user cannot make Administrators the owner, so the run folder is a plain + # one here; New-RunFolder itself is tested with the installer, which shares it. + Mock New-RunFolder { [System.IO.Directory]::CreateDirectory($script:runFolder) | Out-Null; return $script:runFolder } + } + + AfterEach { + $env:ProgramData = $script:savedProgramData + } + + It 'refuses to run without SYSTEM or administrator rights and creates nothing' { + Mock Test-IsElevated { $false } + $stderr = New-Object System.IO.StringWriter + $previous = [Console]::Error + [Console]::SetError($stderr) + try { Start-BootstrapLoader } finally { [Console]::SetError($previous) } + $stderr.ToString() | Should -Match 'not running as SYSTEM or administrator' + Should -Invoke New-RunFolder -Times 0 -Exactly + @(Get-ChildItem -LiteralPath $env:ProgramData -Force).Count | Should -Be 0 + } + + It 'runs nothing and keeps only its log when no download verifies' { + Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "Set-Content -LiteralPath '$($script:marker)' -Value ran"); $false } + Start-BootstrapLoader + Test-Path -LiteralPath $script:marker | Should -BeFalse + @(Get-ChildItem -LiteralPath $script:runFolder -Force | ForEach-Object Name) | Should -Be @('bootstrap-loader.log') + Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') -Raw | Should -Match 'Nothing was executed' + } + + It 'runs the verified installer with the passed arguments by name and removes its folder after exit 0' { + $agentLog = Join-Path $env:ProgramData 'AutopilotMonitor\Logs\agent.log' + New-Item -ItemType Directory -Path (Split-Path $agentLog) -Force | Out-Null + Set-Content -Path $agentLog -Value 'agent' + Mock Get-VerifiedBootstrapScript { + [System.IO.File]::WriteAllText($Destination, "param([string]`$AgentDownloadUrl = 'default', [int]`$MaxBootstrapWindowHours = 12)`nSet-Content -LiteralPath '$($script:marker)' -Value `"`$AgentDownloadUrl `$MaxBootstrapWindowHours`"`nexit 0`n") + $true + } + $InstallerArguments = ConvertTo-ScriptArguments -MaxBootstrapWindowHours 24 + + Start-BootstrapLoader + + Get-Content -LiteralPath $script:marker | Should -Be 'default 24' + Test-Path -LiteralPath $script:runFolder | Should -BeFalse + Get-Content -LiteralPath $agentLog | Should -Be 'agent' + } + + It 'keeps its log but not the installer when the installer fails' { + Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "exit 1`n"); $true } + Start-BootstrapLoader + @(Get-ChildItem -LiteralPath $script:runFolder -Force | ForEach-Object Name) | Should -Be @('bootstrap-loader.log') + Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') -Raw | Should -Match 'Installer finished \(exit code 1\)' + } + + It 'keeps its log when the installer throws' { + Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "throw 'installer broke'`n"); $true } + Start-BootstrapLoader + Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') -Raw | Should -Match 'Installer threw: installer broke' + } + + # An installer that ends without an exit code must not inherit a 0 from earlier work. + It 'does not take an earlier exit code for the installer''s' { + Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "Write-Output 'no exit code'`n"); $true } + $global:LASTEXITCODE = 0 + Start-BootstrapLoader + Test-Path -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') | Should -BeTrue + } + + It 'names its version first in its log, in the form the installer carries over' { + Mock Get-VerifiedBootstrapScript { $false } + Start-BootstrapLoader + $first = (Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log'))[0] + $first | Should -Match '^\[\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\] === Autopilot Monitor bootstrap loader v\d+(\.\d+){0,3} started' + } +} + +Describe 'loader contract' { + BeforeAll { + $script:loader = Get-ScriptAst $script:loaderPath + } + + It 'parses without errors' { + $script:loader.Errors.Count | Should -Be 0 + } + + # Unbound arguments reach the installer only while the script is not an advanced one. + It 'stays a plain script, so unbound arguments are kept for the installer' { + $script:loader.Ast.ParamBlock.Attributes.Count | Should -Be 0 + @($script:loader.Ast.ParamBlock.Parameters.Attributes | Where-Object { $_.TypeName.Name -eq 'Parameter' }) | Should -BeNullOrEmpty + } + + # The six functions also run in the installer and, embedded, in the OOBE script. + It 'carries the shared functions word for word as the installer has them' { + $installer = Get-ScriptAst $script:installerPath + foreach ($name in @('Test-IsElevated', 'New-ProtectedDirectorySecurity', 'Test-IsReparsePoint', 'Test-IsOwnedByUs', 'New-RunFolderName', 'New-RunFolder')) { + $mine = $script:loader.Ast.Find({ param($a) $a -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $a.Name -eq $name }, $true) + $theirs = $installer.Ast.Find({ param($a) $a -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $a.Name -eq $name }, $true) + $mine | Should -Not -BeNullOrEmpty -Because $name + $mine.Extent.Text | Should -BeExactly $theirs.Extent.Text -Because $name + } + } + + It 'calls no command beyond the few it needs' { + $own = @($script:loader.Ast.FindAll({ param($a) $a -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) | ForEach-Object { $_.Name }) + $allowed = $own + @('Add-Content', 'ForEach-Object', 'Get-AuthenticodeSignature', 'Get-Date', 'Get-Item', 'Invoke-WebRequest', 'Join-Path', 'New-Object', 'Out-Null', 'Start-Sleep', 'Test-Path', 'Write-Host') + $used = @($script:loader.Ast.FindAll({ param($a) $a -is [System.Management.Automation.Language.CommandAst] }, $true) | ForEach-Object { $_.GetCommandName() } | Where-Object { $_ } | Sort-Object -Unique) + @($used | Where-Object { $allowed -notcontains $_ }) | Should -BeNullOrEmpty + } + + It 'deletes only the download, its log and its own empty folder' { + $deletes = @($script:loader.Ast.FindAll({ param($a) $a -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and $a.Member.Extent.Text -eq 'Delete' }, $true)) + $deletes.Count | Should -Be 3 + foreach ($delete in $deletes) { + if ($delete.Expression.Extent.Text -eq '[System.IO.Directory]') { + $delete.Arguments[1].Extent.Text | Should -Be '$false' + } else { + $delete.Expression.Extent.Text | Should -Be '[System.IO.File]' + } + } + } + + It 'never names the agent folder' { + $strings = @($script:loader.Tokens | Where-Object { $_ -is [System.Management.Automation.Language.StringToken] } | ForEach-Object { $_.Value }) + @($strings | Where-Object { $_ -match '(^|\\)AutopilotMonitor($|\\)' }) | Should -BeNullOrEmpty } } Describe 'publish contract' { BeforeAll { - $script:loaderText = Get-Content (Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1') -Raw + $script:loaderText = Get-Content $script:loaderPath -Raw } # Publish-BootstrapScripts.ps1 renders the -Dev loader by substituting this exact @@ -127,7 +409,7 @@ Describe 'publish contract' { # The publisher parses this to write version.json.loaderVersion and to run the # version-bump guard. It 'declares a parsable ScriptVersion' { - $script:loaderText -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"' | Should -BeTrue + $script:loaderText -match '(?m)^\$ScriptVersion = "\d+(\.\d+){1,3}"\r?$' | Should -BeTrue } It 'pins the expected publisher by subject, not by thumbprint' { diff --git a/scripts/Bootstrap/Start-AutopilotMonitor.ps1 b/scripts/Bootstrap/Start-AutopilotMonitor.ps1 index 589056da..7be524c0 100644 --- a/scripts/Bootstrap/Start-AutopilotMonitor.ps1 +++ b/scripts/Bootstrap/Start-AutopilotMonitor.ps1 @@ -1,107 +1,171 @@ <# .SYNOPSIS - Stage 1 of the Autopilot Monitor bootstrap: fetches the current installer and runs it. + Downloads the current Autopilot Monitor installer and runs it. .DESCRIPTION - This is the file an administrator assigns in Intune, and the same file the bootstrap MSI - installs and runs -- one implementation for both delivery channels; the MSI is only the - packaging. It carries no enrollment logic at all: it downloads the published - Install-AutopilotMonitor.ps1, proves that it was signed by Autopilot Monitor, and runs it - in this process. + Assign this script in Intune once; the bootstrap MSI runs the same script. It downloads + Install-AutopilotMonitor.ps1 from download.autopilotmonitor.com and runs it only when the + file carries a valid signature from glueckkanja AG and is installer version 2.7 or later. + Because the installer is fetched on every run, this script does not need replacing when + the installer changes. - Why two stages: the installer holds every guard, relax rule and download path, and those - change. Whoever assigns the installer directly has to replace their Intune copy each - time; whoever assigns this loader never does, because the part that changes lives on the - server. Assigning the installer directly stays supported and unchanged. + The download and the log go into %ProgramData%\AutopilotMonitor-Loader-, which only + SYSTEM and administrators can change. After a successful run the folder is removed; after + a failure it keeps the log for diagnosis, and the next installer run removes it. - This file is deliberately boring. It is the one piece that cannot be fixed remotely, so - it does the least it can: download, verify, invoke, log. + The exit code is always 0, so the loader never fails enrollment. Without SYSTEM or + administrator rights it stops with an error and changes nothing. - Verification is by Authenticode publisher, not by hash. A hash oracle would have to be - fetched from the same host as the script itself, so it could not survive a compromise of - that host, while the expected publisher below travels with this file. It pins the - certificate SUBJECT and not a thumbprint, so a certificate renewal does not brick a - loader that is already deployed on devices. - - Fail-soft: the loader always exits 0 and never throws into the caller. A device that - cannot reach the download endpoint is a device without monitoring, never a failed - enrollment step. Verification failures are the same: nothing runs, and the reason is in - the log. Refusing to execute unverified code as SYSTEM is the point of this file. - - This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI). + Any other arguments are passed to the installer, for example -MaxBootstrapWindowHours 24. .PARAMETER BootstrapUrl - Installer URL. Defaults to the published stable installer; the -Dev variant of this - loader is rendered from that default at publish time. + Installer to download. Default: the published installer. .PARAMETER LogFileName - Log file inside the agent log directory. The MSI channel passes bootstrap-msi.log so the - delivery channel stays visible in a diagnostics package; the diag tooling keys on these - names, so do not rename them. - -.PARAMETER BootstrapArguments - Everything else is passed through to the installer unchanged, so any parameter the - installer accepts can be set here without this file knowing about it. - -.EXAMPLE - .\Start-AutopilotMonitor.ps1 - -.EXAMPLE - .\Start-AutopilotMonitor.ps1 -MaxBootstrapWindowHours 24 + Name of the log file. Default: bootstrap-loader.log. .NOTES - Author : Oliver Kieselbach (autopilotmonitor.com) - Runtime : Windows PowerShell 5.1, SYSTEM context, during Autopilot enrollment - -.CHANGELOG - 2026-09-08 v1.2 Log the signer CN instead of the full EV subject; the full subject is - logged only when the publisher is why a script was rejected. - 2026-09-08 v1.1 Also used by the bootstrap MSI, which replaced its own copy of this - logic; -LogFileName keeps the channel visible in diagnostics. - 2026-09-08 v1.0 Initial version: two-stage bootstrap, publisher-pinned verification. + Requires Windows PowerShell 5.1. + Changes: https://docs.autopilotmonitor.com/changelog/platform-changelog #> -[CmdletBinding()] param( - [Parameter(Mandatory = $false)] [string]$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1", - [Parameter(Mandatory = $false)] [ValidatePattern('^[A-Za-z0-9._-]+$')] - [string]$LogFileName = "bootstrap-loader.log", - - [Parameter(Mandatory = $false, ValueFromRemainingArguments = $true)] - [string[]]$BootstrapArguments + [string]$LogFileName = "bootstrap-loader.log" ) -# Script version (bump on meaningful changes; see .CHANGELOG above) -$ScriptVersion = "1.2" +# Unbound arguments reach the installer by name. [CmdletBinding()] or a [Parameter()] +# attribute would reject them instead. +$InstallerArguments = $args -# The publisher every downloaded stage-2 script must carry. A -like pattern on the subject, -# NOT a thumbprint: certificates are renewed, deployed loaders are not. +$ScriptVersion = "1.3" + +# A subject pattern, not a thumbprint, so a certificate renewal keeps deployed loaders working. $ExpectedPublisher = "*O=glueckkanja AG*" -$LoaderBasePath = "$env:ProgramData\AutopilotMonitor" -$LoaderLogPath = "$LoaderBasePath\Logs" -$LogFile = "$LoaderLogPath\$LogFileName" -$BootstrapPath = "$LoaderBasePath\Install-AutopilotMonitor.ps1" +$ExpectedInstallerRole = "AutopilotMonitor-Installer" +$MinimumInstallerVersion = [version]"2.7" + +$RunFolderPrefix = "AutopilotMonitor-Loader-" $DownloadAttempts = 3 $RetryDelaySeconds = 10 -# Write-Host as well as the file: the Intune script output is what an administrator sees in -# the portal, the file is what a support case reads. +$LogState = [pscustomobject]@{ File = $null } + function Write-Log { param([string]$Message) $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $logMessage = "[$timestamp] $Message" Write-Host $logMessage - Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue + if ($LogState.File) { + Add-Content -Path $LogState.File -Value $logMessage -ErrorAction SilentlyContinue + } } -# True only when the file is signed, the signature validates against the machine's trust -# chain, and the signer is us. Any error is a false -- an unreadable signature is an -# unverified script. +### Shared with Install-AutopilotMonitor.ps1, word for word. + +function Test-IsElevated { + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() + return (New-Object System.Security.Principal.WindowsPrincipal($identity)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) +} + +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) +} + +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 + } +} + +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') }) +} + +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." +} + +### End of the shared functions. + +# Keeps the system default (0, which includes TLS 1.3); otherwise adds TLS 1.2. +function Get-DownloadSecurityProtocol { + param([Net.SecurityProtocolType]$Current) + if ([int]$Current -eq 0) { return $Current } + return ($Current -bor [Net.SecurityProtocolType]::Tls12) +} + +# Exactly one role line and one version line, each at the start of a line; $null otherwise. +function Get-InstallerIdentity { + param([string]$Text) + $roles = [regex]::Matches($Text, '(?m)^\$ScriptRole[ \t]*=[ \t]*"([A-Za-z0-9.-]+)"[ \t]*\r?$') + $versions = [regex]::Matches($Text, '(?m)^\$ScriptVersion[ \t]*=[ \t]*"(\d+(?:\.\d+){1,3})"[ \t]*\r?$') + if ($roles.Count -ne 1 -or $versions.Count -ne 1) { return $null } + return [pscustomobject]@{ Role = $roles[0].Groups[1].Value; Version = [version]$versions[0].Groups[1].Value } +} + +# Runs after the signature check, so the lines it reads are signed. +function Test-InstallerIdentity { + param([string]$Path) + try { + $identity = Get-InstallerIdentity -Text ([System.IO.File]::ReadAllText($Path)) + } + catch { + Write-Log "Identity check failed to run: $($_.Exception.Message)" + return $false + } + if ($null -eq $identity) { + Write-Log "REJECTED: the script does not declare exactly one role line and one version line." + return $false + } + if ($identity.Role -ne $ExpectedInstallerRole) { + Write-Log "REJECTED: role '$($identity.Role)' is not '$ExpectedInstallerRole'." + return $false + } + if ($identity.Version -lt $MinimumInstallerVersion) { + Write-Log "REJECTED: installer version $($identity.Version) is below the minimum $MinimumInstallerVersion." + return $false + } + Write-Log "Installer accepted: $($identity.Role) v$($identity.Version)." + return $true +} + +# A valid signature from the expected publisher; any error counts as unverified. function Test-BootstrapSignature { param([string]$Path) @@ -118,9 +182,7 @@ function Test-BootstrapSignature { return $false } - # The full subject of an EV certificate is a line of street address and registration - # numbers. The CN answers the only question the log has to answer; the full subject is - # logged when it is the reason for a rejection. + # The CN is enough for the log; the full subject is logged when it causes a rejection. $subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { "" } $signer = if ($subject -match 'CN=([^,]+)') { $Matches[1] } else { $subject } Write-Log "Signature status: $($sig.Status). Signer: $signer" @@ -138,9 +200,7 @@ function Test-BootstrapSignature { return $true } -# Downloads the installer and returns $true once a downloaded copy passes verification. -# Verification lives inside the retry loop on purpose: a truncated or proxy-mangled body -# fails the signature check, and that is worth another attempt. +# A download that fails verification, such as a truncated one, is retried too. function Get-VerifiedBootstrapScript { param([string]$Url, [string]$Destination) @@ -149,7 +209,7 @@ function Get-VerifiedBootstrapScript { Write-Log "Download attempt $attempt/$DownloadAttempts from $Url" Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop - if (Test-BootstrapSignature -Path $Destination) { + if ((Test-BootstrapSignature -Path $Destination) -and (Test-InstallerIdentity -Path $Destination)) { return $true } } @@ -165,51 +225,83 @@ function Get-VerifiedBootstrapScript { return $false } -# Main flow. Only invoked when the script is run directly (see entry guard at the bottom), -# so the Pester suite can dot-source this file without bootstrapping anything. -function Start-BootstrapLoader { - New-Item -ItemType Directory -Force -Path $LoaderLogPath -ErrorAction SilentlyContinue | Out-Null - - Write-Log "=== Autopilot Monitor bootstrap loader v$ScriptVersion started (user=$env:USERNAME, pid=$PID) ===" - - # PS 5.1 on an OOBE device still defaults to TLS 1.0 for this process. +# The download always goes. After an installer exit 0 the log and the folder go too; +# otherwise they stay for diagnosis. +function Remove-LoaderRunFiles { + param([string]$RunFolder, [string]$InstallerPath, [switch]$KeepLog) try { - [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + if ([System.IO.File]::Exists($InstallerPath)) { [System.IO.File]::Delete($InstallerPath) } + if ($KeepLog) { return } + $logFile = $LogState.File + $LogState.File = $null + if ($logFile -and [System.IO.File]::Exists($logFile)) { [System.IO.File]::Delete($logFile) } + [System.IO.Directory]::Delete($RunFolder, $false) } catch { - Write-Log "INFO: could not raise the TLS protocol: $($_.Exception.Message)" - } - - if (-not (Get-VerifiedBootstrapScript -Url $BootstrapUrl -Destination $BootstrapPath)) { - Write-Log "No verified installer available after $DownloadAttempts attempt(s). Nothing was executed." - return - } - - Write-Log "Executing $BootstrapPath" - - # Same process, so the execution policy this loader runs under applies to stage 2 as - # well and no second policy decision can strand the installer. Splatting an array of - # remaining arguments keeps this file free of any knowledge about the installer's - # parameters. - $arguments = @() - if ($BootstrapArguments) { $arguments = $BootstrapArguments } - - try { - & $BootstrapPath @arguments - Write-Log "Installer finished (exit code $LASTEXITCODE)." - } - catch { - Write-Log "Installer threw: $($_.Exception.Message)" + Write-Host "INFO: run folder $RunFolder was not fully removed: $($_.Exception.Message)" } } -# Entry guard: dot-sourcing (Pester) loads the functions without running the loader. +function Start-BootstrapLoader { + if (-not (Test-IsElevated)) { + $message = "AutopilotMonitor loader: not running as SYSTEM or administrator; nothing was downloaded or changed." + Write-Host $message + [Console]::Error.WriteLine($message) + return + } + + try { + [Net.ServicePointManager]::SecurityProtocol = Get-DownloadSecurityProtocol -Current ([Net.ServicePointManager]::SecurityProtocol) + } + catch { + Write-Host "INFO: could not adjust the TLS protocols: $($_.Exception.Message)" + } + + try { + $runFolder = New-RunFolder -Parent $env:ProgramData -Prefix $RunFolderPrefix + } + catch { + Write-Host "AutopilotMonitor loader: no run folder ($($_.Exception.Message)). Nothing was downloaded or executed." + return + } + + $LogState.File = Join-Path $runFolder $LogFileName + Write-Log "=== Autopilot Monitor bootstrap loader v$ScriptVersion started (user=$env:USERNAME, pid=$PID) ===" + Write-Log ("PowerShell {0}, {1}, language mode {2}, run folder {3}" -f $PSVersionTable.PSVersion, $(if ([Environment]::Is64BitProcess) { '64-bit' } else { '32-bit' }), $ExecutionContext.SessionState.LanguageMode, $runFolder) + + $installerPath = Join-Path $runFolder 'Install-AutopilotMonitor.ps1' + $succeeded = $false + try { + if (-not (Get-VerifiedBootstrapScript -Url $BootstrapUrl -Destination $installerPath)) { + Write-Log "No verified installer available after $DownloadAttempts attempt(s). Nothing was executed." + return + } + + Write-Log "Executing $installerPath" + + # Same process, so the execution policy that admitted this script covers the installer. + # The exit code is reset so that an installer that throws cannot report an older one. + $global:LASTEXITCODE = $null + try { + & $installerPath @InstallerArguments + Write-Log "Installer finished (exit code $LASTEXITCODE)." + $succeeded = ($LASTEXITCODE -eq 0) + } + catch { + Write-Log "Installer threw: $($_.Exception.Message)" + } + } + finally { + Remove-LoaderRunFiles -RunFolder $runFolder -InstallerPath $installerPath -KeepLog:(-not $succeeded) + } +} + +# Dot-sourcing (tests) loads the functions without running the loader. if ($MyInvocation.InvocationName -ne '.') { try { Start-BootstrapLoader } catch { - # Nothing above should throw; if it does, it must not become a failed Intune script. Write-Log "Loader failed: $($_.Exception.Message)" } exit 0 diff --git a/scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1 b/scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1 index 1387f018..e989979f 100644 --- a/scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1 +++ b/scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1 @@ -1,9 +1,9 @@ # Autopilot Monitor Bootstrap Guard Test # Dry-run only: does not exit, does not install, only reports what WOULD happen. -# Mirrors Install-AutopilotMonitor.ps1 (v2.3) incl. the relax exemption for +# Mirrors Install-AutopilotMonitor.ps1 (v2.7) incl. the relax exemption for # guards 2+3, triggered by "Windows Backup for Organizations" (OOBE InProgress) -# or a positively identified Windows 365 Cloud PC, and the guard 4 (uptime) -# exemption while the Cloud PC relax is active. +# or a positively identified Windows 365 Cloud PC, the guard 4 (uptime) +# exemption while the Cloud PC relax is active, and the agent check. param( [string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent', @@ -29,20 +29,47 @@ function Write-Step { Write-Host "$prefix $Message" } +# HKLM through the 64-bit view, as the installer reads it: a 32-bit host would read WOW6432Node. function Get-RegistryValueSafe { param( - [string]$Path, + [string]$SubKey, [string]$Name ) try { - return (Get-ItemProperty -Path $Path -Name $Name -ErrorAction Stop).$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() + } } catch { return $null } } +function Test-RegistryKeySafe { + param([string]$SubKey) + + try { + $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() + } + } + catch { + return $false + } +} + # Positive OOBE check via WinRT Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState. # Requires Windows 10, version 1809 (10.0.17763.0). Older builds / errors -> $false (SKIP-safe). # Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo @@ -58,7 +85,7 @@ function Test-OobeInProgress { # Positive Windows 365 Cloud PC detection: two local markers, BOTH required (AND), # because each alone has look-alikes. Mirrors Test-IsCloudPc in the bootstrapper. -$cloudPcRegMarker = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows365' +$cloudPcRegMarker = Test-RegistryKeySafe -SubKey 'SOFTWARE\Microsoft\Windows365' $cloudPcSvcMarker = $null -ne (Get-Service -Name 'CloudManagedDesktopExtension' -ErrorAction SilentlyContinue) $isCloudPc = ($cloudPcRegMarker -and $cloudPcSvcMarker) @@ -73,7 +100,7 @@ Write-Host '=== Autopilot Monitor Bootstrap Guard Test (Dry Run) ===' Write-Host '' # Guard 1: Agent was already deployed on this device (survives self-destruct) -$deployed = Get-RegistryValueSafe -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' +$deployed = Get-RegistryValueSafe -SubKey 'SOFTWARE\AutopilotMonitor' -Name 'Deployed' if ($deployed) { Write-Step -Status 'SKIP' -Message "Guard 1: Agent was previously deployed at '$deployed'." $wouldInstall = $false @@ -146,7 +173,7 @@ if ($profilePaths.Count -gt 0 -and -not $oobeRelax) { } # Guard 3: LastLoggedOnUser (unless OOBE-relax holds) -$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser +$lastLoggedOnUser = Get-RegistryValueSafe -SubKey 'SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) { Write-Step -Status 'SKIP' -Message "Guard 3: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive." $wouldInstall = $false @@ -183,13 +210,19 @@ catch { Write-Step -Status 'WARN' -Message 'Guard 4: Could not determine LastBootUpTime / uptime.' } -# Guard 5: Agent binary already present +# Agent check: an installed agent (its executable or its task; the marker is guard 1) is +# left as it is. +$agentTask = Get-ScheduledTask -TaskName 'AutopilotMonitor-Agent' -ErrorAction SilentlyContinue if (Test-Path $AgentExePath) { - Write-Step -Status 'SKIP' -Message "Guard 5: Agent already installed at '$AgentExePath'." + Write-Step -Status 'SKIP' -Message "Agent check: Agent already installed at '$AgentExePath'." $wouldInstall = $false $reasons.Add("Agent binary already present: $AgentExePath") +} elseif ($agentTask) { + Write-Step -Status 'SKIP' -Message "Agent check: Agent task 'AutopilotMonitor-Agent' exists." + $wouldInstall = $false + $reasons.Add("Agent task present: AutopilotMonitor-Agent") } else { - Write-Step -Status 'PASS' -Message "Guard 5: Agent binary not present at '$AgentExePath'." + Write-Step -Status 'PASS' -Message "Agent check: No agent binary at '$AgentExePath' and no agent task." } Write-Host '' diff --git a/scripts/Bootstrap/TestData/Invoke-BootstrapDownload-1.0.ps1 b/scripts/Bootstrap/TestData/Invoke-BootstrapDownload-1.0.ps1 new file mode 100644 index 00000000..3964c88b --- /dev/null +++ b/scripts/Bootstrap/TestData/Invoke-BootstrapDownload-1.0.ps1 @@ -0,0 +1,55 @@ +# Invoke-BootstrapDownload.ps1 - thin runner embedded in the Bootstrap MSI. +# +# The MSI is only a delivery vehicle (MDM LOB channel installs earlier than the IME +# script/app batches during Autopilot Device Preparation). This runner downloads the +# CURRENT Install-AutopilotMonitor.ps1 from the download endpoint and executes it, so +# every piece of real bootstrap logic (guards, relax rules, agent download) stays +# server-updatable without re-shipping the MSI. +# +# Fail-soft by design: the runner always exits 0 so a transient network failure never +# marks the LOB install as failed; diagnostics go to bootstrap-msi.log. +# ASCII only - no special characters in this file. + +$ErrorActionPreference = 'Stop' +$baseDir = Join-Path $env:ProgramData 'AutopilotMonitor' +$logDir = Join-Path $baseDir 'Logs' +New-Item -ItemType Directory -Force -Path $logDir | Out-Null +$log = Join-Path $logDir 'bootstrap-msi.log' + +function Write-Log([string]$message) { + Add-Content -Path $log -Value ("[{0}] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $message) +} + +try { + Write-Log "MSI bootstrap runner started (user=$env:USERNAME, pid=$PID)." + + if (Test-Path 'HKLM:\SOFTWARE\AutopilotMonitor\Deployed') { + Write-Log 'Deployed marker present - skipping, another channel already installed the agent.' + exit 0 + } + + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $url = 'https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1' + $dest = Join-Path $baseDir 'Install-AutopilotMonitor.ps1' + + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $dest + break + } catch { + Write-Log "Download attempt $attempt failed: $_" + if ($attempt -eq 3) { throw } + Start-Sleep -Seconds 10 + } + } + + Write-Log "Bootstrap script downloaded to $dest - executing." + # PS 5.1 file redirection (*>>) writes UTF-16LE, which garbles a log otherwise + # written via Add-Content (ANSI). Route through Add-Content for one encoding. + & $dest *>&1 | ForEach-Object { $_.ToString() } | Add-Content -Path $log + Write-Log "Bootstrap script finished (exit code $LASTEXITCODE)." + exit 0 +} catch { + Write-Log ("MSI bootstrap runner failed: " + ($_ | Out-String)) + exit 0 +} diff --git a/scripts/Bootstrap/TestData/Start-AutopilotMonitor-1.2.ps1 b/scripts/Bootstrap/TestData/Start-AutopilotMonitor-1.2.ps1 new file mode 100644 index 00000000..589056da --- /dev/null +++ b/scripts/Bootstrap/TestData/Start-AutopilotMonitor-1.2.ps1 @@ -0,0 +1,216 @@ +<# +.SYNOPSIS + Stage 1 of the Autopilot Monitor bootstrap: fetches the current installer and runs it. + +.DESCRIPTION + This is the file an administrator assigns in Intune, and the same file the bootstrap MSI + installs and runs -- one implementation for both delivery channels; the MSI is only the + packaging. It carries no enrollment logic at all: it downloads the published + Install-AutopilotMonitor.ps1, proves that it was signed by Autopilot Monitor, and runs it + in this process. + + Why two stages: the installer holds every guard, relax rule and download path, and those + change. Whoever assigns the installer directly has to replace their Intune copy each + time; whoever assigns this loader never does, because the part that changes lives on the + server. Assigning the installer directly stays supported and unchanged. + + This file is deliberately boring. It is the one piece that cannot be fixed remotely, so + it does the least it can: download, verify, invoke, log. + + Verification is by Authenticode publisher, not by hash. A hash oracle would have to be + fetched from the same host as the script itself, so it could not survive a compromise of + that host, while the expected publisher below travels with this file. It pins the + certificate SUBJECT and not a thumbprint, so a certificate renewal does not brick a + loader that is already deployed on devices. + + Fail-soft: the loader always exits 0 and never throws into the caller. A device that + cannot reach the download endpoint is a device without monitoring, never a failed + enrollment step. Verification failures are the same: nothing runs, and the reason is in + the log. Refusing to execute unverified code as SYSTEM is the point of this file. + + This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI). + +.PARAMETER BootstrapUrl + Installer URL. Defaults to the published stable installer; the -Dev variant of this + loader is rendered from that default at publish time. + +.PARAMETER LogFileName + Log file inside the agent log directory. The MSI channel passes bootstrap-msi.log so the + delivery channel stays visible in a diagnostics package; the diag tooling keys on these + names, so do not rename them. + +.PARAMETER BootstrapArguments + Everything else is passed through to the installer unchanged, so any parameter the + installer accepts can be set here without this file knowing about it. + +.EXAMPLE + .\Start-AutopilotMonitor.ps1 + +.EXAMPLE + .\Start-AutopilotMonitor.ps1 -MaxBootstrapWindowHours 24 + +.NOTES + Author : Oliver Kieselbach (autopilotmonitor.com) + Runtime : Windows PowerShell 5.1, SYSTEM context, during Autopilot enrollment + +.CHANGELOG + 2026-09-08 v1.2 Log the signer CN instead of the full EV subject; the full subject is + logged only when the publisher is why a script was rejected. + 2026-09-08 v1.1 Also used by the bootstrap MSI, which replaced its own copy of this + logic; -LogFileName keeps the channel visible in diagnostics. + 2026-09-08 v1.0 Initial version: two-stage bootstrap, publisher-pinned verification. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string]$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1", + + [Parameter(Mandatory = $false)] + [ValidatePattern('^[A-Za-z0-9._-]+$')] + [string]$LogFileName = "bootstrap-loader.log", + + [Parameter(Mandatory = $false, ValueFromRemainingArguments = $true)] + [string[]]$BootstrapArguments +) + +# Script version (bump on meaningful changes; see .CHANGELOG above) +$ScriptVersion = "1.2" + +# The publisher every downloaded stage-2 script must carry. A -like pattern on the subject, +# NOT a thumbprint: certificates are renewed, deployed loaders are not. +$ExpectedPublisher = "*O=glueckkanja AG*" + +$LoaderBasePath = "$env:ProgramData\AutopilotMonitor" +$LoaderLogPath = "$LoaderBasePath\Logs" +$LogFile = "$LoaderLogPath\$LogFileName" +$BootstrapPath = "$LoaderBasePath\Install-AutopilotMonitor.ps1" + +$DownloadAttempts = 3 +$RetryDelaySeconds = 10 + +# Write-Host as well as the file: the Intune script output is what an administrator sees in +# the portal, the file is what a support case reads. +function Write-Log { + param([string]$Message) + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $logMessage = "[$timestamp] $Message" + Write-Host $logMessage + Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue +} + +# True only when the file is signed, the signature validates against the machine's trust +# chain, and the signer is us. Any error is a false -- an unreadable signature is an +# unverified script. +function Test-BootstrapSignature { + param([string]$Path) + + try { + $sig = Get-AuthenticodeSignature -FilePath $Path -ErrorAction Stop + } + catch { + Write-Log "Signature check failed to run: $($_.Exception.Message)" + return $false + } + + if ($null -eq $sig) { + Write-Log "Signature check returned nothing." + return $false + } + + # The full subject of an EV certificate is a line of street address and registration + # numbers. The CN answers the only question the log has to answer; the full subject is + # logged when it is the reason for a rejection. + $subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { "" } + $signer = if ($subject -match 'CN=([^,]+)') { $Matches[1] } else { $subject } + Write-Log "Signature status: $($sig.Status). Signer: $signer" + + if ($sig.Status -ne 'Valid') { + Write-Log "REJECTED: expected signature status 'Valid'." + return $false + } + if ($subject -notlike $ExpectedPublisher) { + Write-Log "REJECTED: signer does not match the expected publisher. Full subject: $subject" + return $false + } + + Write-Log "Signature accepted (thumbprint $($sig.SignerCertificate.Thumbprint))." + return $true +} + +# Downloads the installer and returns $true once a downloaded copy passes verification. +# Verification lives inside the retry loop on purpose: a truncated or proxy-mangled body +# fails the signature check, and that is worth another attempt. +function Get-VerifiedBootstrapScript { + param([string]$Url, [string]$Destination) + + for ($attempt = 1; $attempt -le $DownloadAttempts; $attempt++) { + try { + Write-Log "Download attempt $attempt/$DownloadAttempts from $Url" + Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop + + if (Test-BootstrapSignature -Path $Destination) { + return $true + } + } + catch { + Write-Log "Download attempt $attempt failed: $($_.Exception.Message)" + } + + if ($attempt -lt $DownloadAttempts) { + Start-Sleep -Seconds $RetryDelaySeconds + } + } + + return $false +} + +# Main flow. Only invoked when the script is run directly (see entry guard at the bottom), +# so the Pester suite can dot-source this file without bootstrapping anything. +function Start-BootstrapLoader { + New-Item -ItemType Directory -Force -Path $LoaderLogPath -ErrorAction SilentlyContinue | Out-Null + + Write-Log "=== Autopilot Monitor bootstrap loader v$ScriptVersion started (user=$env:USERNAME, pid=$PID) ===" + + # PS 5.1 on an OOBE device still defaults to TLS 1.0 for this process. + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + } + catch { + Write-Log "INFO: could not raise the TLS protocol: $($_.Exception.Message)" + } + + if (-not (Get-VerifiedBootstrapScript -Url $BootstrapUrl -Destination $BootstrapPath)) { + Write-Log "No verified installer available after $DownloadAttempts attempt(s). Nothing was executed." + return + } + + Write-Log "Executing $BootstrapPath" + + # Same process, so the execution policy this loader runs under applies to stage 2 as + # well and no second policy decision can strand the installer. Splatting an array of + # remaining arguments keeps this file free of any knowledge about the installer's + # parameters. + $arguments = @() + if ($BootstrapArguments) { $arguments = $BootstrapArguments } + + try { + & $BootstrapPath @arguments + Write-Log "Installer finished (exit code $LASTEXITCODE)." + } + catch { + Write-Log "Installer threw: $($_.Exception.Message)" + } +} + +# Entry guard: dot-sourcing (Pester) loads the functions without running the loader. +if ($MyInvocation.InvocationName -ne '.') { + try { + Start-BootstrapLoader + } + catch { + # Nothing above should throw; if it does, it must not become a failed Intune script. + Write-Log "Loader failed: $($_.Exception.Message)" + } + exit 0 +} diff --git a/scripts/Bootstrap/Uninstall-AutopilotMonitor.ps1 b/scripts/Bootstrap/Uninstall-AutopilotMonitor.ps1 index 00ea59d4..edde8097 100644 --- a/scripts/Bootstrap/Uninstall-AutopilotMonitor.ps1 +++ b/scripts/Bootstrap/Uninstall-AutopilotMonitor.ps1 @@ -6,6 +6,7 @@ This script removes all traces of the Autopilot Monitor agent: - Stops and removes the Scheduled Task - Deletes C:\ProgramData\AutopilotMonitor folder + - Deletes C:\ProgramData\AutopilotMonitor-Loader-* folders that a failed loader run kept Normally the agent self-destructs after enrollment completion. Use this script only for manual cleanup or troubleshooting. @@ -16,7 +17,8 @@ .NOTES - Requires Administrator privileges - Safe to run multiple times - - No Registry cleanup needed (agent doesn't use Registry) + - Keeps HKLM\SOFTWARE\AutopilotMonitor\Deployed: the bootstrap reads it and never + installs the agent on this device again #> [CmdletBinding()] @@ -67,6 +69,17 @@ catch { Write-Host " [INFO] You may need to manually delete: $AgentBasePath" -ForegroundColor Yellow } +# Delete loader run folders kept by failed runs +foreach ($runFolder in @(Get-ChildItem -Path $env:ProgramData -Directory -Filter 'AutopilotMonitor-Loader-*' -ErrorAction SilentlyContinue)) { + try { + Remove-Item -LiteralPath $runFolder.FullName -Recurse -Force -ErrorAction Stop + Write-Host " [OK] Loader run folder deleted: $($runFolder.FullName)" -ForegroundColor Green + } + catch { + Write-Host " [FAIL] Error deleting $($runFolder.FullName): $($_.Exception.Message)" -ForegroundColor Red + } +} + Write-Host "" Write-Host "===== Cleanup Completed =====" -ForegroundColor Cyan Write-Host "Autopilot Monitor agent has been removed from this device." -ForegroundColor Green diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Bootstrap/BootstrapContractTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Bootstrap/BootstrapContractTests.cs new file mode 100644 index 00000000..d0374ad3 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Bootstrap/BootstrapContractTests.cs @@ -0,0 +1,121 @@ +using System; +using System.IO; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; +using AutopilotMonitor.Agent.V2.Core.Security; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Shared; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Bootstrap +{ + /// + /// What the bootstrap scripts and the agent must agree on: the names by which the installer + /// recognizes an installed agent, the folders and log files, and the log lines the agent reads + /// back as its provenance. Every script value is read from the script's own text, so a rename + /// on either side fails here. The bootstrap script gates run this class on every change to + /// the scripts. + /// + [Trait("Category", "BootstrapContract")] + public sealed class BootstrapContractTests : IDisposable + { + private static readonly string BootstrapDir = Path.Combine(RepoPaths.Root(), "scripts", "Bootstrap"); + private static readonly string Installer = File.ReadAllText(Path.Combine(BootstrapDir, "Install-AutopilotMonitor.ps1")); + private static readonly string Loader = File.ReadAllText(Path.Combine(BootstrapDir, "Start-AutopilotMonitor.ps1")); + private static readonly string OobeScript = File.ReadAllText(Path.Combine( + RepoPaths.Root(), "src", "Backend", "AutopilotMonitor.Functions.Tests", "TestData", "oobe-bootstrap-golden.ps1")); + + private readonly string _logs = Path.Combine(Path.GetTempPath(), "apm-bootstrap-contract-" + Guid.NewGuid().ToString("N")); + + public BootstrapContractTests() => Directory.CreateDirectory(_logs); + + public void Dispose() + { + try { Directory.Delete(_logs, recursive: true); } catch { /* best effort */ } + } + + /// The value of the one top-level $Name = "value" line of a script. + private static string Assignment(string script, string name) + { + var matches = Regex.Matches(script, @"(?m)^\$" + name + @"[ \t]*=[ \t]*""([^""]*)""[ \t]*\r?$"); + Assert.True(matches.Count == 1, $"expected exactly one top-level ${name} line, found {matches.Count}"); + return matches[0].Groups[1].Value; + } + + private static string ScriptPath(string agentPath) => agentPath.Replace("%ProgramData%", "$env:ProgramData"); + + private static string AgentSubdirectory() => + (string)typeof(AutopilotMonitor.Agent.V2.Program) + .GetField("DefaultAgentSubdirectory", BindingFlags.NonPublic | BindingFlags.Static) + .GetRawConstantValue(); + + private void WriteLog(string name, string line) => + File.WriteAllText(Path.Combine(_logs, name), line + "\r\n", Encoding.ASCII); + + [Fact] + public void The_installer_recognizes_an_agent_by_the_names_the_agent_uses() + { + Assert.Equal(DeploymentMarker.RegistryKeyPath, Assignment(Installer, "DeploymentMarkerKey")); + Assert.Equal(DeploymentMarker.RegistryValueName, Assignment(Installer, "DeploymentMarkerValue")); + Assert.Equal(Constants.ScheduledTaskName, Assignment(Installer, "AgentTaskName")); + Assert.Equal(typeof(AutopilotMonitor.Agent.V2.Program).Assembly.GetName().Name + ".exe", Assignment(Installer, "AgentExeName")); + } + + [Fact] + public void The_installer_works_in_the_folders_the_agent_uses() + { + var basePath = Assignment(Installer, "AgentBasePath"); + + Assert.Equal(ScriptPath(Constants.AgentDataDirectory), basePath); + Assert.Equal(ScriptPath(Constants.LogDirectory), Assignment(Installer, "AgentLogPath").Replace("$AgentBasePath", basePath)); + Assert.Equal(basePath + "\\" + AgentSubdirectory(), Assignment(Installer, "AgentBinPath").Replace("$AgentBasePath", basePath)); + } + + [Fact] + public void Every_bootstrap_logs_under_the_name_the_agent_reads() + { + Assert.Equal("$AgentLogPath\\" + BootstrapProvenanceReader.InstallerLogName, Assignment(Installer, "LogFile")); + Assert.Matches(@"\[string\]\$LogFileName = """ + Regex.Escape(BootstrapProvenanceReader.LoaderLogName) + @"""", Loader); + Assert.Contains("$LogFile = \"$AgentLogPath\\" + BootstrapProvenanceReader.OobeLogName + "\"", OobeScript); + + var wxs = File.ReadAllText(Path.Combine(RepoPaths.Root(), "src", "Agent", "AutopilotMonitor.BootstrapMsi", "Package.wxs")); + Assert.Contains("-LogFileName " + BootstrapProvenanceReader.MsiLoaderLogName, wxs); + } + + [Fact] + public void The_agent_reads_the_versions_the_current_scripts_write() + { + var installerVersion = Assignment(Installer, "ScriptVersion"); + var loaderVersion = Assignment(Loader, "ScriptVersion"); + Assert.Contains("Write-Log \"Bootstrap script version: v$ScriptVersion\"", Installer); + Assert.Contains("Write-Log \"=== Autopilot Monitor bootstrap loader v$ScriptVersion started (user=$env:USERNAME, pid=$PID) ===\"", Loader); + + WriteLog(BootstrapProvenanceReader.InstallerLogName, $"[2026-10-22 10:00:01] Bootstrap script version: v{installerVersion}"); + WriteLog(BootstrapProvenanceReader.LoaderLogName, $"[2026-10-22 10:00:00] === Autopilot Monitor bootstrap loader v{loaderVersion} started (user=SYSTEM, pid=4242) ==="); + + var provenance = BootstrapProvenanceReader.Read(_logs); + + Assert.Equal(installerVersion, provenance.ScriptVersion); + Assert.Equal("loader", provenance.LoaderLog); + Assert.Equal(loaderVersion, provenance.LoaderVersion); + } + + // The installer carries only the start line over from loaders before 1.3, rebuilt from a + // pattern; the agent has to read that line as the loader's version. + [Fact] + public void The_agent_reads_the_start_line_the_installer_carries_over_from_an_older_loader() + { + const string suffix = " (carried over by the bootstrapper) ===`r`n"; + Assert.Contains("$starts[$starts.Count - 1].Value + \"" + suffix + "\"", Installer); + + WriteLog(BootstrapProvenanceReader.MsiLoaderLogName, "[2026-10-22 10:00:00] === Autopilot Monitor bootstrap loader v1.2 started (carried over by the bootstrapper) ==="); + + var provenance = BootstrapProvenanceReader.Read(_logs); + + Assert.Equal("msi", provenance.LoaderLog); + Assert.Equal("1.2", provenance.LoaderVersion); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.InstallMode.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.InstallMode.cs index 09722503..4d4686f4 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Program.InstallMode.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.InstallMode.cs @@ -213,7 +213,7 @@ namespace AutopilotMonitor.Agent.V2 // SKIP via pre-flight (agent already installed), and the device would // sit without a runtime until manual intervention. The bootstrap // script's Get-Process probe is the canonical "no runtime came up" - // signal — see Install-AutopilotMonitor.ps1 ~line 280. + // signal — see "Runtime process verified" in Install-AutopilotMonitor.ps1. RuntimeLaunchResult launch; if (!TokenPrivilegeProbe.IsPrivilegeHeld(TokenPrivilegeProbe.TimeZonePrivilege)) { diff --git a/src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs b/src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs index aa068ff2..2da6ee87 100644 --- a/src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs +++ b/src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs @@ -16,7 +16,7 @@ diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/OobeBootstrapScriptGeneratorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/OobeBootstrapScriptGeneratorTests.cs index 9cd80abf..fce2fca5 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/OobeBootstrapScriptGeneratorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/OobeBootstrapScriptGeneratorTests.cs @@ -8,8 +8,9 @@ namespace AutopilotMonitor.Functions.Tests; /// Pins the OOBE bootstrap script generator, ported from the portal's /// app/go/[code]/route.ts. The golden file TestData/oobe-bootstrap-golden.ps1 is the /// pin of the shipped template (modulo line endings): first captured from the TS -/// template literal, since D-354 extended with the payload catalog check that the -/// generator embeds verbatim from the installer. Ports the behavioral cases of the +/// template literal, since D-354 extended with the installer's shared install helpers +/// (payload catalog check, protected agent folder, run folder), which the generator +/// embeds verbatim. Ports the behavioral cases of the /// vitest suite app/go/[code]/__tests__/route.test.ts that concern script generation. /// public class OobeBootstrapScriptGeneratorTests @@ -50,8 +51,8 @@ public class OobeBootstrapScriptGeneratorTests var script = Generate(); Assert.StartsWith("#Requires -RunAsAdministrator", script); - Assert.Contains($"--tenant-id '{TenantId}'", script); - Assert.Contains($"--bootstrap-token '{Token}'", script); + Assert.Contains($"'--tenant-id', '{TenantId}'", script); + Assert.Contains($"'--bootstrap-token', '{Token}'", script); Assert.Contains($"$AgentDownloadUrl = '{DownloadUrl}'", script); Assert.Contains("Generated: 2026-07-28T12:00:00.000Z", script); Assert.Contains("Expires: 2026-08-01T12:00:00.000Z", script); @@ -73,9 +74,10 @@ public class OobeBootstrapScriptGeneratorTests Assert.Contains("'Bootstrap token expires: 2026-08-01T12:00:00.000Z'", script); } - // ── D-354: the payload is checked against its signed catalog before --install ── + // ── D-354: the payload is checked against its signed catalog before --install, with the + // installer's own code for that and for the agent folder ── [Fact] - public void SuccessScript_embeds_the_installers_payload_verification_verbatim() + public void SuccessScript_embeds_the_installers_shared_install_helpers_verbatim() { var installer = File.ReadAllText(Path.Combine(FindRepoRoot(), "scripts", "Bootstrap", "Install-AutopilotMonitor.ps1")) .Replace("\r\n", "\n"); @@ -85,22 +87,42 @@ public class OobeBootstrapScriptGeneratorTests Assert.Contains("function Get-AgentPayloadRejections", block); Assert.Contains("function Install-VerifiedAgentPayload", block); + Assert.Contains("function Initialize-AgentBase", block); + Assert.Contains("function New-RunFolder", block); + Assert.Contains("function Remove-OwnTree", block); + Assert.Contains("function Invoke-AgentInstallMode", block); Assert.Contains(block, Generate().Replace("\r\n", "\n")); } [Fact] - public void SuccessScript_verifies_the_payload_before_installing_the_agent() + public void SuccessScript_prepares_the_folder_and_verifies_the_payload_before_installing_the_agent() { var script = Generate(); + var prepare = script.IndexOf("Initialize-AgentBase -Path $AgentBasePath", StringComparison.Ordinal); var verify = script.IndexOf( - "Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $AgentStagingPath -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName", + "Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $staging -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName", StringComparison.Ordinal); - var install = script.IndexOf("& $agentExePath --install", StringComparison.Ordinal); + var install = script.IndexOf("Invoke-AgentInstallMode -ExePath $agentExePath -Arguments @('--install'", StringComparison.Ordinal); - Assert.True(verify > 0, "the generated script never verifies the payload"); + Assert.True(prepare > 0, "the generated script never prepares the agent folder"); + Assert.True(verify > prepare, "the payload check must run in the prepared folder"); Assert.True(install > verify, "--install must run after the payload check"); Assert.DoesNotContain("Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath", script); + Assert.DoesNotContain("$env:TEMP", script); + } + + // #Requires is ignored under irm | iex, so the script checks for itself before it changes anything. + [Fact] + public void SuccessScript_refuses_to_run_without_elevation_before_it_changes_anything() + { + var script = Generate(); + + var check = script.IndexOf("if (-not (Test-IsElevated)) {", StringComparison.Ordinal); + var tryStart = script.IndexOf("\ntry {", StringComparison.Ordinal); + + Assert.True(check > tryStart && tryStart > 0, "the elevation check must open the main block"); + Assert.True(check < script.IndexOf("Initialize-AgentBase -Path $AgentBasePath", StringComparison.Ordinal)); } [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1 b/src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1 index 8286c224..74bb9cc7 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1 +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1 @@ -17,16 +17,10 @@ $AgentBasePath = "$env:ProgramData\AutopilotMonitor" $AgentBinPath = "$AgentBasePath\Agent" $AgentLogPath = "$AgentBasePath\Logs" $LogFile = "$AgentLogPath\oobe_bootstrap.log" -$AgentStagingPath = "$AgentBasePath\Agent-Install" $ExpectedPublisher = '*O=glueckkanja AG*' $AgentCatalogName = 'AutopilotMonitor.Agent.cat' -# Create directories -New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null -New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null -New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null - function Write-Log { param([string]$Message) $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" @@ -35,15 +29,164 @@ function Write-Log { Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue } -### BEGIN shared payload verification -- embedded verbatim into the OOBE go-script by -### OobeBootstrapScriptGenerator (D-354). Keep it self-contained: parameters only, no -### script-scope variables, no Write-Log. -# Returns the reasons why the extracted payload in $Path must not run; an empty result means -# it matches its signed catalog exactly. Test-FileCatalog's Status covers the hashes only (an -# unsigned catalog over matching files reports Valid), so the signature and its publisher are -# judged on their own. Any error is a rejection: a payload that cannot be verified is an -# unverified payload. The comma keeps a single rejection an ARRAY: PowerShell unrolls a -# one-element result to a bare string, and the caller reads .Count. +### 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, @@ -96,11 +239,8 @@ function Get-AgentPayloadRejections { return , $rejected } -# Extracts the agent ZIP into a fresh staging folder, verifies it there and moves it into the -# agent folder only when it matches its signed catalog. A rejected payload is deleted with the -# staging folder: nothing unverified lands where SYSTEM runs the agent, and the next run starts -# clean instead of finding a half-installed agent. The caller has made sure no agent is -# installed, so anything already in the agent folder is a leftover and goes first. +# 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, @@ -110,71 +250,89 @@ function Install-VerifiedAgentPayload { [string]$CatalogName = 'AutopilotMonitor.Agent.cat' ) - if (Test-Path -LiteralPath $StagingPath) { - Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction Stop - } - New-Item -Path $StagingPath -ItemType Directory -Force | Out-Null - try { - Expand-Archive -Path $ZipPath -DestinationPath $StagingPath -Force -ErrorAction Stop + $payload = Join-Path $StagingPath 'Agent' + Expand-Archive -Path $ZipPath -DestinationPath $payload -Force -ErrorAction Stop - $rejections = Get-AgentPayloadRejections -Path $StagingPath -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." - } - - if (Test-Path -LiteralPath $TargetPath) { - Get-ChildItem -LiteralPath $TargetPath -Force | Remove-Item -Recurse -Force -ErrorAction Stop - } - else { - New-Item -Path $TargetPath -ItemType Directory -Force | Out-Null - } - Get-ChildItem -LiteralPath $StagingPath -Force | Move-Item -Destination $TargetPath -Force -ErrorAction Stop + $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." } - finally { - Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction SilentlyContinue + + 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 + } } } -### END shared payload verification + +# 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 try { + # #Requires has no effect under irm | iex. + if (-not (Test-IsElevated)) { + throw "Run this script from PowerShell as SYSTEM or administrator. Nothing was changed." + } + + # An installed agent only gets --install again. Otherwise the agent folder is prepared as + # the installer prepares it: protected and empty. + $agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe" + $agentInstalled = Test-Path $agentExePath + if (-not $agentInstalled) { + Initialize-AgentBase -Path $AgentBasePath + } + [System.IO.Directory]::CreateDirectory($AgentLogPath) | Out-Null + Write-Log "===== Autopilot Monitor OOBE Bootstrap =====" Write-Log 'Bootstrap token expires: 2026-08-01T12:00:00.000Z' - # Check if agent is already installed - $agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe" - if (Test-Path $agentExePath) { + if ($agentInstalled) { Write-Log "Agent already installed at $agentExePath -- skipping download" } else { - # Download agent ZIP - $zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip" - Write-Log "Downloading agent from $AgentDownloadUrl..." + # Download, verification and extraction happen in a run folder of their own. + $staging = New-RunFolder -Parent $AgentBasePath -Prefix 'Agent-Install-' + try { + $zipPath = Join-Path $staging "AutopilotMonitor-Agent.zip" + Write-Log "Downloading agent from $AgentDownloadUrl..." - $maxDownloadAttempts = 3 - $downloadAttempt = 0 + $maxDownloadAttempts = 3 + $downloadAttempt = 0 - do { - $downloadAttempt++ - try { - Write-Log "Download attempt $downloadAttempt/$maxDownloadAttempts" - Invoke-WebRequest -Uri $AgentDownloadUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop - Write-Log "Downloaded agent to $zipPath" - break - } - catch { - if ($downloadAttempt -ge $maxDownloadAttempts) { throw } - $retryDelays = @(2, 4, 8) - $delay = $retryDelays[$downloadAttempt - 1] - Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${delay}s..." - Start-Sleep -Seconds $delay - } - } while ($downloadAttempt -lt $maxDownloadAttempts) + do { + $downloadAttempt++ + try { + Write-Log "Download attempt $downloadAttempt/$maxDownloadAttempts" + Invoke-WebRequest -Uri $AgentDownloadUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop + Write-Log "Downloaded agent to $zipPath" + break + } + catch { + if ($downloadAttempt -ge $maxDownloadAttempts) { throw } + $retryDelays = @(2, 4, 8) + $delay = $retryDelays[$downloadAttempt - 1] + Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${delay}s..." + Start-Sleep -Seconds $delay + } + } while ($downloadAttempt -lt $maxDownloadAttempts) - # Verify against the signed payload catalog before anything lands in the agent folder - Write-Log "Verifying the payload against its signed catalog..." - Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $AgentStagingPath -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName - Remove-Item $zipPath -Force -ErrorAction SilentlyContinue - Write-Log "Payload verified and installed to $AgentBinPath" + # Verify against the signed payload catalog before anything lands in the agent folder + Write-Log "Verifying the payload against its signed catalog..." + Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $staging -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName + Write-Log "Payload verified and installed to $AgentBinPath" + } + finally { + $null = Remove-OwnTree -Path $staging + } if (-not (Test-Path $agentExePath)) { throw "Agent executable not found after extraction at $agentExePath" @@ -183,8 +341,7 @@ try { # Install agent with bootstrap token Write-Log "Installing agent with bootstrap token..." - & $agentExePath --install --no-auth --bootstrap-token '22222222-2222-2222-2222-222222222222' --tenant-id '11111111-1111-1111-1111-111111111111' - $installExitCode = $LASTEXITCODE + $installExitCode = Invoke-AgentInstallMode -ExePath $agentExePath -Arguments @('--install', '--no-auth', '--bootstrap-token', '22222222-2222-2222-2222-222222222222', '--tenant-id', '11111111-1111-1111-1111-111111111111') if ($installExitCode -ne 0) { throw "Agent install failed with exit code $installExitCode" } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/OobeBootstrapScriptGenerator.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/OobeBootstrapScriptGenerator.cs index a528b30a..cc7ea940 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/OobeBootstrapScriptGenerator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/OobeBootstrapScriptGenerator.cs @@ -37,7 +37,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap // SECURITY INVARIANT: every template value comes either from // BootstrapScriptValueValidator.TryValidate (the request-derived ones) or from a // compile-time constant (portal URL, publisher pin, catalog name, and the installer's - // payload-verification block embedded at build time). The single-quoted PowerShell + // shared install helpers embedded at build time). The single-quoted PowerShell // string literals below are the second line of defense against injection // (single-quoted PS strings do NOT expand $(...), $var, or backticks). // Do NOT change to double-quoted without re-reviewing the validator's @@ -67,20 +67,20 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap .Replace("__PORTAL_URL__", Constants.PortalBaseUrl) .Replace("__EXPECTED_PUBLISHER__", Constants.CodeSigningPublisherPattern) .Replace("__PAYLOAD_CATALOG__", Constants.AgentPayloadCatalogFileName) - .Replace("__PAYLOAD_VERIFICATION__", SharedPayloadVerification.Value); + .Replace("__SHARED_INSTALL_HELPERS__", SharedInstallHelpers.Value); } internal const string InstallerResourceName = "AutopilotMonitor.Functions.Bootstrap.Install-AutopilotMonitor.ps1"; - internal const string SharedBlockBegin = "### BEGIN shared payload verification"; - internal const string SharedBlockEnd = "### END shared payload verification"; + internal const string SharedBlockBegin = "### BEGIN shared install helpers"; + internal const string SharedBlockEnd = "### END shared install helpers"; - // The payload check is the installer's own code, embedded at build time and cut out - // between its markers: one text for both install paths, so they can never drift - // (D-354). A missing marker fails the first request loudly instead of serving a - // script that installs without the check. - private static readonly Lazy SharedPayloadVerification = new(LoadSharedPayloadVerification); + // The payload check and the folder handling are the installer's own code, embedded at + // build time and cut out between its markers: one text for both install paths, so they + // can never drift (D-354). A missing marker fails the first request loudly instead of + // serving a script that installs without them. + private static readonly Lazy SharedInstallHelpers = new(LoadSharedInstallHelpers); - internal static string LoadSharedPayloadVerification() + internal static string LoadSharedInstallHelpers() { using var stream = typeof(OobeBootstrapScriptGenerator).Assembly.GetManifestResourceStream(InstallerResourceName) ?? throw new InvalidOperationException($"Embedded resource {InstallerResourceName} is missing."); @@ -90,7 +90,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap var begin = installer.IndexOf(SharedBlockBegin, StringComparison.Ordinal); var end = installer.IndexOf(SharedBlockEnd, StringComparison.Ordinal); if (begin < 0 || end < begin || installer.IndexOf(SharedBlockBegin, begin + 1, StringComparison.Ordinal) >= 0) - throw new InvalidOperationException("The installer's shared payload-verification markers are missing or ambiguous."); + throw new InvalidOperationException("The installer's shared install-helper markers are missing or ambiguous."); var lineEnd = installer.IndexOf('\n', end); return installer.Substring(begin, (lineEnd < 0 ? installer.Length : lineEnd) - begin); @@ -116,16 +116,10 @@ $AgentBasePath = "$env:ProgramData\AutopilotMonitor" $AgentBinPath = "$AgentBasePath\Agent" $AgentLogPath = "$AgentBasePath\Logs" $LogFile = "$AgentLogPath\oobe_bootstrap.log" -$AgentStagingPath = "$AgentBasePath\Agent-Install" $ExpectedPublisher = '__EXPECTED_PUBLISHER__' $AgentCatalogName = '__PAYLOAD_CATALOG__' -# Create directories -New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null -New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null -New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null - function Write-Log { param([string]$Message) $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" @@ -134,47 +128,64 @@ function Write-Log { Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue } -__PAYLOAD_VERIFICATION__ +__SHARED_INSTALL_HELPERS__ try { + # #Requires has no effect under irm | iex. + if (-not (Test-IsElevated)) { + throw "Run this script from PowerShell as SYSTEM or administrator. Nothing was changed." + } + + # An installed agent only gets --install again. Otherwise the agent folder is prepared as + # the installer prepares it: protected and empty. + $agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe" + $agentInstalled = Test-Path $agentExePath + if (-not $agentInstalled) { + Initialize-AgentBase -Path $AgentBasePath + } + [System.IO.Directory]::CreateDirectory($AgentLogPath) | Out-Null + Write-Log "===== Autopilot Monitor OOBE Bootstrap =====" Write-Log 'Bootstrap token expires: __EXPIRES_AT__' - # Check if agent is already installed - $agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe" - if (Test-Path $agentExePath) { + if ($agentInstalled) { Write-Log "Agent already installed at $agentExePath -- skipping download" } else { - # Download agent ZIP - $zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip" - Write-Log "Downloading agent from $AgentDownloadUrl..." + # Download, verification and extraction happen in a run folder of their own. + $staging = New-RunFolder -Parent $AgentBasePath -Prefix 'Agent-Install-' + try { + $zipPath = Join-Path $staging "AutopilotMonitor-Agent.zip" + Write-Log "Downloading agent from $AgentDownloadUrl..." - $maxDownloadAttempts = 3 - $downloadAttempt = 0 + $maxDownloadAttempts = 3 + $downloadAttempt = 0 - do { - $downloadAttempt++ - try { - Write-Log "Download attempt $downloadAttempt/$maxDownloadAttempts" - Invoke-WebRequest -Uri $AgentDownloadUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop - Write-Log "Downloaded agent to $zipPath" - break - } - catch { - if ($downloadAttempt -ge $maxDownloadAttempts) { throw } - $retryDelays = @(2, 4, 8) - $delay = $retryDelays[$downloadAttempt - 1] - Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${delay}s..." - Start-Sleep -Seconds $delay - } - } while ($downloadAttempt -lt $maxDownloadAttempts) + do { + $downloadAttempt++ + try { + Write-Log "Download attempt $downloadAttempt/$maxDownloadAttempts" + Invoke-WebRequest -Uri $AgentDownloadUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop + Write-Log "Downloaded agent to $zipPath" + break + } + catch { + if ($downloadAttempt -ge $maxDownloadAttempts) { throw } + $retryDelays = @(2, 4, 8) + $delay = $retryDelays[$downloadAttempt - 1] + Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${delay}s..." + Start-Sleep -Seconds $delay + } + } while ($downloadAttempt -lt $maxDownloadAttempts) - # Verify against the signed payload catalog before anything lands in the agent folder - Write-Log "Verifying the payload against its signed catalog..." - Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $AgentStagingPath -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName - Remove-Item $zipPath -Force -ErrorAction SilentlyContinue - Write-Log "Payload verified and installed to $AgentBinPath" + # Verify against the signed payload catalog before anything lands in the agent folder + Write-Log "Verifying the payload against its signed catalog..." + Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $staging -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName + Write-Log "Payload verified and installed to $AgentBinPath" + } + finally { + $null = Remove-OwnTree -Path $staging + } if (-not (Test-Path $agentExePath)) { throw "Agent executable not found after extraction at $agentExePath" @@ -183,8 +194,7 @@ try { # Install agent with bootstrap token Write-Log "Installing agent with bootstrap token..." - & $agentExePath --install --no-auth --bootstrap-token '__BOOTSTRAP_TOKEN__' --tenant-id '__TENANT_ID__' - $installExitCode = $LASTEXITCODE + $installExitCode = Invoke-AgentInstallMode -ExePath $agentExePath -Arguments @('--install', '--no-auth', '--bootstrap-token', '__BOOTSTRAP_TOKEN__', '--tenant-id', '__TENANT_ID__') if ($installExitCode -ne 0) { throw "Agent install failed with exit code $installExitCode" }