mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
Bootstrap leaves nothing behind: loader 1.3 and installer 2.7 (D-375)
- Loader 1.3 downloads into a protected run folder of its own (%ProgramData%\AutopilotMonitor-Loader-<8 hex>), runs the installer only when it is signed by the pinned publisher and declares the installer role and version 2.7 or later, removes the folder after a successful run and never touches the agent folder. Unbound arguments now reach the installer by name; the array splat bound them by position. - Installer 2.7 refuses without elevation and decides before it writes: guards read HKLM through the 64-bit view (a 32-bit host never saw the deployment marker), guard 5 is covered by the agent check. An agent's folder stays as it is, a skip without agent removes the folder, an install starts from an emptied, protected folder and stages the ZIP in a run folder. It moves the MSI's working directory out, writes stderr only on failure and removes the copy older loaders left at the fixed path. - Help texts of both scripts state what customers need; .CHANGELOG, which made Get-Help ignore the help, gives way to the Platform Changelog. - OOBE go-script: the installer's shared helpers, its own elevation check (#Requires is ignored under irm | iex), a run folder. - Publish: version read line-anchored, the loader's own check on the exact publish bytes, installers before loaders, -Channel dev (dispatch input). - Bootstrap MSI 1.2.0; Test-ShouldBootstrapAgent reads the 64-bit view; Uninstall removes loader run folders and keeps the Deployed marker. - Tests: Pester unit suites, an integration harness running the real scripts (loader 1.2 and the MSI 1.0 runner frozen in TestData), BootstrapContractTests; the gate runs on windows-2022 and windows-2025, once more as SYSTEM, and against a real standard user. [skip actions]: published by a dispatch of publish-scripts.yml, channel dev first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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."
|
||||
@@ -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)' })**",
|
||||
|
||||
@@ -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."
|
||||
Reference in New Issue
Block a user