mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
3a898b5071
- 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>
177 lines
11 KiB
PowerShell
177 lines
11 KiB
PowerShell
<#
|
|
.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."
|