mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
Bootstrap MSI 1.4 keeps its loader copy under ProgramData and runs only what it checked; installer 2.9 removes the copy (D-384)
MSI 1.3 (D-377) installed the loader under Program Files, where nothing removed it: a customer found the file on every device. Everything of ours lives under ProgramData. - Package.wxs 1.4.0: the loader copy goes back to <base>\Msi (new component GUID); the deferred SYSTEM action, started in System32, reads the copy once, checks our signature and the publisher pin on those bytes and runs those bytes from memory, so a file a user planted at that path leads to nothing and no handle keeps the installer from removing the copy. Every bracket and brace is escaped for the Windows Installer formatter. - Install-AutopilotMonitor.ps1 2.9: Remove-MsiTransportFolder removes the copy in the agent-present branch (install and skip clear the base already) and an agent folder that is then empty; Remove-LegacyMsiLoaderFolder removes MSI 1.3's folder under Program Files in every outcome while devices still enroll with 1.3. - Tests: installer unit tests for both functions; the loader's MSI contract; the harness asserts after every run that nothing exists outside ProgramData, runs the MSI 1.4 action read from Package.wxs (valid, foreign, unsigned, changed; install, skip, marker only, agent present) and expects MSI 1.3's folder gone; BootstrapContractTests pin the directory chain, the publisher pin, the escapes and the working directory. - build-msi.yml: smoke job installs the built MSI on windows-2022/2025 with the deployment marker set and expects nothing under ProgramData or Program Files, then uninstalls. - internal: D-384, footprint doc, plan, lessons, backlog. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -198,11 +198,90 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# The real chain on a runner, before anything is signed or published: msiexec installs the
|
||||
# MSI from the build, its action checks the loader copy and runs it, the loader fetches the
|
||||
# published installer, which finds the deployment marker set here and removes the copy. The
|
||||
# marker is the installer's first guard, so no agent is ever installed on the runner. Needs
|
||||
# the published installer to be 2.9 or later, which removes the copy in every outcome.
|
||||
smoke:
|
||||
name: Install on a runner (${{ matrix.os }})
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-2022, windows-2025]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download unsigned MSI
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bootstrap-msi-unsigned
|
||||
path: ${{ runner.temp }}/msi
|
||||
|
||||
- name: Install with the deployment marker set, check the footprint, uninstall
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = Join-Path $env:RUNNER_TEMP "msi/$env:STABLE_MSI"
|
||||
$wxs = Get-Content "$env:MSI_PROJECT_DIR/Package.wxs" -Raw
|
||||
if ($wxs -notmatch 'Version="(\d+\.\d+\.\d+)"') { throw 'Could not parse Version from Package.wxs' }
|
||||
$version = $Matches[1]
|
||||
|
||||
New-Item -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Force | Out-Null
|
||||
Set-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -Value ((Get-Date).ToUniversalTime().ToString('o'))
|
||||
|
||||
function Invoke-Msiexec {
|
||||
param([string]$Verb, [string]$Log)
|
||||
$p = Start-Process -FilePath msiexec.exe -ArgumentList "/$Verb `"$msi`" /qn /l*v `"$Log`"" -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { Get-Content $Log | Select-Object -Last 60; throw "msiexec /$Verb exited with $($p.ExitCode)" }
|
||||
}
|
||||
function Get-Registration {
|
||||
Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' | Get-ItemProperty | Where-Object { $_.DisplayName -eq 'Autopilot Monitor Bootstrap' }
|
||||
}
|
||||
function Show-Tree {
|
||||
param([string]$Path)
|
||||
Get-ChildItem -LiteralPath $Path -Recurse -Force | ForEach-Object { Write-Host " $($_.FullName)" }
|
||||
}
|
||||
|
||||
$installLog = Join-Path $env:RUNNER_TEMP 'msi-install.log'
|
||||
Invoke-Msiexec -Verb i -Log $installLog
|
||||
$registered = Get-Registration
|
||||
if (-not $registered) { throw 'The product is not registered after the install.' }
|
||||
if ($registered.DisplayVersion -ne $version) { throw "Registered version $($registered.DisplayVersion), Package.wxs says $version." }
|
||||
if ((Get-Content $installLog -Raw) -notmatch 'RunBootstrap') { throw 'The install log does not show the action.' }
|
||||
Write-Host "Registered: $($registered.DisplayName) $($registered.DisplayVersion)"
|
||||
|
||||
# Only the installer removes the copy the MSI put under ProgramData: its absence
|
||||
# proves the action ran the loader and the loader ran the installer.
|
||||
foreach ($path in @("$env:ProgramW6432\AutopilotMonitor", "$env:ProgramData\AutopilotMonitor")) {
|
||||
if (Test-Path -LiteralPath $path) { Show-Tree $path; throw "$path exists after the run." }
|
||||
}
|
||||
$runFolders = @(Get-ChildItem -LiteralPath $env:ProgramData -Directory -Filter 'AutopilotMonitor-Loader-*' -Force)
|
||||
if ($runFolders.Count -ne 0) {
|
||||
foreach ($folder in $runFolders) {
|
||||
Show-Tree $folder.FullName
|
||||
Get-ChildItem -LiteralPath $folder.FullName -Filter '*.log' | ForEach-Object { Get-Content -LiteralPath $_.FullName }
|
||||
}
|
||||
throw 'A loader run folder stays: the loader did not end with exit 0.'
|
||||
}
|
||||
Write-Host 'After the install: nothing under ProgramData or Program Files.'
|
||||
|
||||
Invoke-Msiexec -Verb x -Log (Join-Path $env:RUNNER_TEMP 'msi-uninstall.log')
|
||||
if (Get-Registration) { throw 'The product is still registered after the uninstall.' }
|
||||
Remove-Item -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Recurse -Force
|
||||
Write-Host 'Uninstalled; the registration is gone.'
|
||||
|
||||
# Trust zone: signs, verifies and publishes the MSI as data; never builds. Environment
|
||||
# `release`, like sign-loader.
|
||||
release:
|
||||
name: Sign and publish MSI
|
||||
needs: build
|
||||
needs: smoke
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
permissions:
|
||||
|
||||
+1
-1
Submodule internal updated: 3cd36a521f...4cf6b4d840
@@ -70,8 +70,8 @@ function global:HarnessInvokeRestMethod {
|
||||
|
||||
function global:HarnessGetAuthenticodeSignature {
|
||||
[CmdletBinding()]
|
||||
param([string]$FilePath)
|
||||
Write-HarnessTrace 'Get-AuthenticodeSignature' $FilePath
|
||||
param([string]$FilePath, [byte[]]$Content, [string[]]$SourcePathOrExtension)
|
||||
Write-HarnessTrace 'Get-AuthenticodeSignature' $(if ($Content) { "content:$($Content.Length)" } else { $FilePath })
|
||||
return [pscustomobject]@{
|
||||
Status = $global:HarnessOptions.SignatureStatus
|
||||
SignerCertificate = [pscustomobject]@{ Subject = $global:HarnessOptions.SignerSubject; Thumbprint = 'HARNESS' }
|
||||
@@ -188,8 +188,11 @@ if ($global:HarnessOptions.SwapElevation) { $swaps['Test-IsElevated'] = 'Harness
|
||||
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) {
|
||||
# The bootstrap MSI's action is a -Command line; the OOBE script arrives as irm | iex, which
|
||||
# ignores #Requires.
|
||||
if ($global:HarnessOptions.CommandText) {
|
||||
Invoke-Expression $global:HarnessOptions.CommandText
|
||||
} elseif ($global:HarnessOptions.Iex) {
|
||||
Invoke-Expression ([System.IO.File]::ReadAllText($global:HarnessOptions.Script))
|
||||
} else {
|
||||
& $global:HarnessOptions.Script @args
|
||||
@@ -228,6 +231,7 @@ exit $LASTEXITCODE
|
||||
Root = $root
|
||||
ProgramData = Join-Path $root 'ProgramData'
|
||||
Base = Join-Path $root 'ProgramData\AutopilotMonitor'
|
||||
ProgramFiles = Join-Path $root 'ProgramFiles'
|
||||
Temp = Join-Path $root 'Temp'
|
||||
Server = Join-Path $root 'Server'
|
||||
Outside = Join-Path $root 'Outside'
|
||||
@@ -262,9 +266,11 @@ exit $LASTEXITCODE
|
||||
CatalogValid = $true
|
||||
Elevated = $true
|
||||
Iex = $false
|
||||
CommandText = $null
|
||||
AgentStartFails = $false
|
||||
}
|
||||
foreach ($key in $Options.Keys) { $settings[$key] = $Options[$key] }
|
||||
$script:promiseScenario = $Scenario
|
||||
$settings.SwapElevation = ([bool]$settings.Elevated -ne $script:isElevated)
|
||||
$settings.SwapDacl = -not $script:isElevated
|
||||
|
||||
@@ -287,6 +293,7 @@ exit $LASTEXITCODE
|
||||
$psi.EnvironmentVariables['ALLUSERSPROFILE'] = $Scenario.ProgramData
|
||||
$psi.EnvironmentVariables['TEMP'] = $Scenario.Temp
|
||||
$psi.EnvironmentVariables['TMP'] = $Scenario.Temp
|
||||
$psi.EnvironmentVariables['ProgramW6432'] = $Scenario.ProgramFiles
|
||||
$process = [System.Diagnostics.Process]::Start($psi)
|
||||
$stdout = $process.StandardOutput.ReadToEndAsync()
|
||||
$stderr = $process.StandardError.ReadToEndAsync()
|
||||
@@ -328,6 +335,25 @@ exit $LASTEXITCODE
|
||||
return ($sorted -join "`n")
|
||||
}
|
||||
|
||||
# The promise every run keeps, whatever the scenario asserts about ProgramData: nothing of
|
||||
# ours anywhere else. The scenario's own fixture folders are the only other entries.
|
||||
function Assert-NothingOutsideProgramData {
|
||||
param($Scenario)
|
||||
$fixtures = @('ProgramData', 'ProgramFiles', 'Temp', 'Server', 'Outside', 'Ime', 'Harness')
|
||||
$others = @([System.IO.Directory]::GetFileSystemEntries($Scenario.Root) | ForEach-Object { [System.IO.Path]::GetFileName($_) } | Where-Object { $_ -notin $fixtures })
|
||||
$others | Should -BeNullOrEmpty -Because 'the scripts write nowhere but ProgramData'
|
||||
Get-Tree $Scenario.ProgramFiles | Should -BeNullOrEmpty -Because 'nothing of ours stays under Program Files'
|
||||
Get-Tree $Scenario.Temp | Should -BeNullOrEmpty -Because 'the scripts never stage in the shared temp folder'
|
||||
}
|
||||
|
||||
# Every Describe runs this after each test: the last run of the test kept the promise.
|
||||
function Assert-PromiseOfLastRun {
|
||||
if (-not $script:promiseScenario) { return }
|
||||
$scenario = $script:promiseScenario
|
||||
$script:promiseScenario = $null
|
||||
Assert-NothingOutsideProgramData $scenario
|
||||
}
|
||||
|
||||
function Get-StdErrLines {
|
||||
param($Run)
|
||||
return @($Run.StdErr -split "`r?`n" | Where-Object { $_ })
|
||||
@@ -369,6 +395,7 @@ exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
Describe 'loader 1.4' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
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')
|
||||
@@ -471,7 +498,7 @@ Describe 'loader 1.4' {
|
||||
It 'runs nothing it cannot verify: <Case>' -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.8'; Options = @{}; Edit = @('$ScriptVersion = "2.8"', '$ScriptVersion = "2.7"') }
|
||||
@{ Case = 'an installer below 2.8'; Options = @{}; Edit = @('$ScriptVersion = "2.9"', '$ScriptVersion = "2.7"') }
|
||||
@{ Case = 'not the installer'; Options = @{}; Edit = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptRole = "AutopilotMonitor-Other"') }
|
||||
) {
|
||||
$s = New-Scenario
|
||||
@@ -502,6 +529,7 @@ Describe 'loader 1.4' {
|
||||
}
|
||||
|
||||
Describe 'installer assigned directly' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
BeforeEach {
|
||||
$s = New-Scenario
|
||||
$imeCopy = Join-Path $s.Ime '0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.ps1'
|
||||
@@ -605,6 +633,7 @@ Describe 'installer assigned directly' {
|
||||
}
|
||||
|
||||
Describe 'loader 1.2, frozen' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
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
|
||||
@@ -644,6 +673,7 @@ Describe 'loader 1.2, frozen' {
|
||||
}
|
||||
|
||||
Describe 'bootstrap MSI layouts, started in the folder the MSI installed' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
It '<Name> skips and leaves <Left>' -TestCases @(
|
||||
@{ Name = 'MSI 1.3 (loader in Program Files)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Location = 'program-files'; Arguments = @('-LogFileName', 'bootstrap-msi.log'); Left = 'nothing'; Tree = @() }
|
||||
@{ Name = 'MSI 1.2 (loader 1.3)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Arguments = @('-LogFileName', 'bootstrap-msi.log'); Left = 'nothing'; Tree = @() }
|
||||
@@ -660,6 +690,26 @@ Describe 'bootstrap MSI layouts, started in the folder the MSI installed' {
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree $Tree)
|
||||
if ($Location -eq 'program-files') { $run.StdOut | Should -Match 'Bootstrap removed, and with it the empty' }
|
||||
}
|
||||
|
||||
# MSI 1.3 installed its loader under Program Files, where nothing of ours belongs; the
|
||||
# installer takes it away in every outcome, also when an agent is present.
|
||||
It 'MSI 1.3 leaves nothing under Program Files when an agent is present' {
|
||||
$s = New-Scenario
|
||||
$msi = Join-Path $s.ProgramFiles 'AutopilotMonitor\Bootstrap'
|
||||
New-Item -ItemType Directory -Path $msi -Force | Out-Null
|
||||
Copy-Item -LiteralPath $script:loaderPath -Destination (Join-Path $msi 'Start-AutopilotMonitor.ps1')
|
||||
Add-File (Join-Path $s.Base 'Logs\agent.log') 'kept log'
|
||||
$before = Get-Tree $s.ProgramData -Content
|
||||
$run = Invoke-Scenario $s -Script (Join-Path $msi 'Start-AutopilotMonitor.ps1') -Arguments @('-LogFileName', 'bootstrap-msi.log') -WorkingDirectory $msi -Options @{ Marker = '2026-10-01T08:00:00Z' }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData -Content | Should -Be $before
|
||||
Test-Path -LiteralPath (Join-Path $s.ProgramFiles 'AutopilotMonitor') | Should -BeFalse
|
||||
$run.StdOut | Should -Match 'Agent present \(deployment marker\): .* is left as it is\.'
|
||||
$run.StdOut | Should -Match 'Bootstrap removed, and with it the empty'
|
||||
}
|
||||
|
||||
It '<Name> installs' -TestCases @(
|
||||
@@ -678,10 +728,123 @@ Describe 'bootstrap MSI layouts, started in the folder the MSI installed' {
|
||||
$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'))
|
||||
if ($Location -eq 'program-files') { $run.StdOut | Should -Match 'Bootstrap removed, and with it the empty' }
|
||||
}
|
||||
}
|
||||
|
||||
# MSI 1.4 installs the loader copy under <base>\Msi and runs this command as SYSTEM from
|
||||
# System32: read the copy once, check our signature on those bytes, run those bytes. The
|
||||
# command is taken from Package.wxs and formatted as Windows Installer formats it (measured
|
||||
# on the built MSI, 2026-10-23); the wrapper runs it where the stubs apply.
|
||||
Describe 'bootstrap MSI 1.4: the action checks the loader copy and runs what it read' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
BeforeAll {
|
||||
$wxs = [xml][System.IO.File]::ReadAllText((Join-Path $script:repoRoot 'src\Agent\AutopilotMonitor.BootstrapMsi\Package.wxs'))
|
||||
$action = @($wxs.Wix.Package.CustomAction) | Where-Object { $_.Id -eq 'RunBootstrap' }
|
||||
if ($action.ExeCommand -notmatch ' -Command "(.*)"$') { throw 'Package.wxs: the action has no -Command argument.' }
|
||||
$script:actionCommand = $Matches[1]
|
||||
|
||||
function Format-MsiAction {
|
||||
param([string]$Text, [string]$InstallFolder)
|
||||
return $Text.Replace('[\[]', '[').Replace('[\]]', ']').Replace('[\{]', '{').Replace('[\}]', '}').Replace('[INSTALLFOLDER]', $InstallFolder)
|
||||
}
|
||||
|
||||
function Add-MsiCopy {
|
||||
param($Scenario)
|
||||
$msi = Join-Path $Scenario.Base 'Msi'
|
||||
New-Item -ItemType Directory -Path $msi -Force | Out-Null
|
||||
Copy-Item -LiteralPath $script:loaderPath -Destination (Join-Path $msi 'Start-AutopilotMonitor.ps1')
|
||||
}
|
||||
|
||||
function Invoke-MsiAction {
|
||||
param($Scenario, [hashtable]$Options = @{})
|
||||
$command = Format-MsiAction -Text $script:actionCommand -InstallFolder ((Join-Path $Scenario.Base 'Msi') + '\')
|
||||
return Invoke-Scenario $Scenario -WorkingDirectory "$env:SystemRoot\System32" -Options ($Options + @{ CommandText = $command })
|
||||
}
|
||||
|
||||
$script:copyOnlyTree = @('AutopilotMonitor\', 'AutopilotMonitor\Msi\', 'AutopilotMonitor\Msi\Start-AutopilotMonitor.ps1')
|
||||
}
|
||||
|
||||
It 'is PowerShell that parses, with every bracket and brace escaped for Windows Installer' {
|
||||
# Without the [\x] escapes, the only bracket left is the one property reference.
|
||||
($script:actionCommand -replace '\[\\.\]', '').Replace('[INSTALLFOLDER]', '') | Should -Not -Match '[{}\[\]]'
|
||||
@([regex]::Matches($script:actionCommand, '\[[A-Za-z0-9_.]+\]') | ForEach-Object { $_.Value } | Sort-Object -Unique) | Should -Be @('[INSTALLFOLDER]')
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseInput((Format-MsiAction -Text $script:actionCommand -InstallFolder 'C:\ProgramData\AutopilotMonitor\Msi\'), [ref]$null, [ref]$errors) | Out-Null
|
||||
@($errors) | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'installs, and the copy goes with the emptied agent folder' {
|
||||
$s = New-Scenario
|
||||
Add-MsiCopy $s
|
||||
$run = Invoke-MsiAction $s
|
||||
|
||||
$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'))
|
||||
@($run.Trace | Where-Object { $_ -like 'Get-AuthenticodeSignature|content:*' }).Count | Should -Be 1
|
||||
@($run.Trace | Where-Object { $_ -like 'Get-BootstrapDecision|*' }).Count | Should -Be 1
|
||||
$run.StdOut | Should -Match 'Bootstrap Completed Successfully'
|
||||
Get-Content -LiteralPath (Join-Path $s.Base 'Logs\bootstrap-msi.log') -Raw | Should -Match '=== Autopilot Monitor bootstrap loader v1\.4 started'
|
||||
}
|
||||
|
||||
It 'skips and leaves nothing behind' {
|
||||
$s = New-Scenario
|
||||
Add-MsiCopy $s
|
||||
$run = Invoke-MsiAction $s -Options @{ Install = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
$run.StdOut | Should -Match 'No agent on this device: .* removed\.'
|
||||
}
|
||||
|
||||
It 'leaves nothing behind when only the deployment marker is there' {
|
||||
$s = New-Scenario
|
||||
Add-MsiCopy $s
|
||||
$run = Invoke-MsiAction $s -Options @{ Marker = '2026-10-01T08:00:00Z' }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
$run.StdOut | Should -Match 'Agent present \(deployment marker\): .*\\Msi removed, and with it the empty'
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*Agent.zip' }).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'removes the copy and leaves the folder of an agent as it is' {
|
||||
$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'
|
||||
Add-MsiCopy $s
|
||||
$run = Invoke-MsiAction $s
|
||||
|
||||
$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'))
|
||||
$run.StdOut | Should -Match 'Agent present \(agent executable\): .*\\Msi removed\.'
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-AgentInstallMode|*' }).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'runs nothing it cannot verify: <Case>' -TestCases @(
|
||||
@{ Case = 'unsigned'; Options = @{ SignatureStatus = 'NotSigned' } }
|
||||
@{ Case = 'changed after signing'; Options = @{ SignatureStatus = 'HashMismatch' } }
|
||||
@{ Case = 'another publisher'; Options = @{ SignerSubject = 'CN=Somebody Else, O=Somebody Else Ltd, C=US' } }
|
||||
) {
|
||||
$s = New-Scenario
|
||||
Add-MsiCopy $s
|
||||
$run = Invoke-MsiAction $s -Options $Options
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree $script:copyOnlyTree)
|
||||
@($run.Trace | Where-Object { $_ -like 'Get-AuthenticodeSignature|content:*' }).Count | Should -Be 1
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*' }).Count | Should -Be 0
|
||||
@($run.Trace | Where-Object { $_ -like 'Get-BootstrapDecision|*' }).Count | Should -Be 0
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'OOBE script, as the backend generates it' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
It 'installs the agent with the bootstrap token and leaves only the agent folder' {
|
||||
$s = New-Scenario
|
||||
Add-Leftovers $s
|
||||
@@ -733,6 +896,7 @@ Describe 'OOBE script, as the backend generates it' {
|
||||
}
|
||||
|
||||
Describe 'publish pipeline and dev channel' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
BeforeAll {
|
||||
$script:rendered = Join-Path $TestDrive 'rendered'
|
||||
$script:pwsh = (Get-Command pwsh -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
|
||||
@@ -768,9 +932,9 @@ Describe 'publish pipeline and dev channel' {
|
||||
}
|
||||
|
||||
It 'refuses to publish an installer the loader would refuse: <Case>' -TestCases @(
|
||||
@{ Case = 'below the minimum version'; Old = '$ScriptVersion = "2.8"'; New = '$ScriptVersion = "2.7"' }
|
||||
@{ Case = 'below the minimum version'; Old = '$ScriptVersion = "2.9"'; New = '$ScriptVersion = "2.7"' }
|
||||
@{ Case = 'without its role line'; Old = '$ScriptRole = "AutopilotMonitor-Installer"'; New = '# no role' }
|
||||
@{ Case = 'with a second version line'; Old = '$ScriptVersion = "2.8"'; New = "`$ScriptVersion = `"2.8`"`n`$ScriptVersion = `"2.9`"" }
|
||||
@{ Case = 'with a second version line'; Old = '$ScriptVersion = "2.9"'; New = "`$ScriptVersion = `"2.9`"`n`$ScriptVersion = `"3.0`"" }
|
||||
) {
|
||||
$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
|
||||
@@ -817,7 +981,7 @@ Describe 'publish pipeline and dev channel' {
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
$run.StdOut | Should -Match 'Installer accepted: AutopilotMonitor-Installer v2\.8'
|
||||
$run.StdOut | Should -Match 'Installer accepted: AutopilotMonitor-Installer v2\.9'
|
||||
$run.StdOut | Should -Match 'Bootstrap Completed Successfully'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -800,6 +800,153 @@ Describe 'Remove-AgentBaseOnSkip' {
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Remove-MsiTransportFolder' {
|
||||
BeforeEach {
|
||||
Mock Start-Sleep { }
|
||||
Mock New-ProtectedDirectorySecurity { New-TestDirectorySecurity }
|
||||
Mock Test-IsOwnedByUs { $true }
|
||||
$AgentBasePath = Join-Path $TestDrive "AutopilotMonitor-$([guid]::NewGuid().ToString('N').Substring(0, 8))"
|
||||
$MsiTransportPath = Join-Path $AgentBasePath 'Msi'
|
||||
}
|
||||
|
||||
It 'has nothing to do without an agent folder or without the copy' {
|
||||
Remove-MsiTransportFolder | Should -BeNullOrEmpty
|
||||
New-Item -ItemType Directory -Path (Join-Path $AgentBasePath 'Logs') -Force | Out-Null
|
||||
Remove-MsiTransportFolder | Should -BeNullOrEmpty
|
||||
Test-Path (Join-Path $AgentBasePath 'Logs') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'removes the copy and keeps an agent folder that holds anything else' {
|
||||
New-Item -ItemType File -Path (Join-Path $AgentBasePath 'Logs\agent.log') -Value 'kept' -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
|
||||
|
||||
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath removed."
|
||||
|
||||
Test-Path $MsiTransportPath | Should -BeFalse
|
||||
Get-Content (Join-Path $AgentBasePath 'Logs\agent.log') | Should -Be 'kept'
|
||||
}
|
||||
|
||||
It 'removes the copy and with it an agent folder that is then empty' {
|
||||
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
|
||||
|
||||
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath removed, and with it the empty $AgentBasePath."
|
||||
|
||||
Test-Path $AgentBasePath | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'leaves a copy that is not ours, and says so' {
|
||||
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
|
||||
Mock Test-IsOwnedByUs { $Path -notlike '*\Msi' }
|
||||
|
||||
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath left as it is (Foreign)."
|
||||
|
||||
Test-Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'touches nothing below an agent folder that is not ours' {
|
||||
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
|
||||
Mock Test-IsOwnedByUs { $false }
|
||||
|
||||
Remove-MsiTransportFolder | Should -BeNullOrEmpty
|
||||
|
||||
Test-Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'never follows a link planted as the copy' {
|
||||
New-Item -ItemType Directory -Path $AgentBasePath -Force | Out-Null
|
||||
$target = New-SentinelJunction -Link $MsiTransportPath
|
||||
|
||||
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath left as it is (Link)."
|
||||
|
||||
Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'never throws' {
|
||||
Mock Get-FolderState { throw 'unexpected' }
|
||||
{ Remove-MsiTransportFolder } | Should -Not -Throw
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Remove-LegacyMsiLoaderFolder' {
|
||||
BeforeEach {
|
||||
Mock Start-Sleep { }
|
||||
Mock New-ProtectedDirectorySecurity { New-TestDirectorySecurity }
|
||||
Mock Test-IsOwnedByUs { $true }
|
||||
$script:realProgramW6432 = $env:ProgramW6432
|
||||
$script:realProgramFiles = $env:ProgramFiles
|
||||
$env:ProgramW6432 = New-TestFolder 'ProgramFiles64'
|
||||
$env:ProgramFiles = New-TestFolder 'ProgramFiles32'
|
||||
$script:company = Join-Path $env:ProgramW6432 'AutopilotMonitor'
|
||||
$script:loader = Join-Path $script:company 'Bootstrap\Start-AutopilotMonitor.ps1'
|
||||
}
|
||||
AfterEach {
|
||||
$env:ProgramW6432 = $script:realProgramW6432
|
||||
$env:ProgramFiles = $script:realProgramFiles
|
||||
}
|
||||
|
||||
It 'has nothing to do where MSI 1.3 never ran' {
|
||||
Remove-LegacyMsiLoaderFolder | Should -BeNullOrEmpty
|
||||
New-Item -ItemType Directory -Path $script:company -Force | Out-Null
|
||||
Remove-LegacyMsiLoaderFolder | Should -BeNullOrEmpty
|
||||
Test-Path $script:company | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'removes what MSI 1.3 installed, and the company folder with it' {
|
||||
New-Item -ItemType File -Path $script:loader -Force | Out-Null
|
||||
|
||||
Remove-LegacyMsiLoaderFolder | Should -Be "$(Split-Path $script:loader) removed, and with it the empty $script:company."
|
||||
|
||||
Test-Path $script:company | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'keeps the company folder when anything else is in it' {
|
||||
New-Item -ItemType File -Path $script:loader -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $script:company 'other.txt') -Value 'x'
|
||||
|
||||
Remove-LegacyMsiLoaderFolder | Should -Be "$(Split-Path $script:loader) removed."
|
||||
|
||||
Test-Path (Split-Path $script:loader) | Should -BeFalse
|
||||
Test-Path (Join-Path $script:company 'other.txt') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'reads the 64-bit Program Files, also from the 32-bit host' {
|
||||
$other = Join-Path $env:ProgramFiles 'AutopilotMonitor\Bootstrap\Start-AutopilotMonitor.ps1'
|
||||
New-Item -ItemType File -Path $script:loader -Force | Out-Null
|
||||
New-Item -ItemType File -Path $other -Force | Out-Null
|
||||
|
||||
Remove-LegacyMsiLoaderFolder | Should -BeLike '*removed*'
|
||||
|
||||
Test-Path $script:company | Should -BeFalse
|
||||
Test-Path $other | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'leaves a folder that is not ours' {
|
||||
New-Item -ItemType File -Path $script:loader -Force | Out-Null
|
||||
Mock Test-IsOwnedByUs { $false }
|
||||
|
||||
Remove-LegacyMsiLoaderFolder | Should -Be "$script:company left as it is (Foreign)."
|
||||
|
||||
Test-Path $script:loader | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'never follows a link' {
|
||||
$target = New-SentinelJunction -Link $script:company
|
||||
|
||||
Remove-LegacyMsiLoaderFolder | Should -Be "$script:company left as it is (Link)."
|
||||
|
||||
Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'never throws' {
|
||||
Mock Get-FolderState { throw 'unexpected' }
|
||||
{ Remove-LegacyMsiLoaderFolder } | Should -Not -Throw
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-AgentPayloadRejections' {
|
||||
# The payload catalog check, shared verbatim with the OOBE go-script. What matters is the
|
||||
# DECISION: which payload is accepted and which is refused. Test-FileCatalog is mocked for
|
||||
@@ -1122,7 +1269,7 @@ Describe 'script text' {
|
||||
|
||||
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\.8"\s*$')).Count | Should -Be 1
|
||||
([regex]::Matches($script:text, '(?m)^\$ScriptVersion = "2\.9"\s*$')).Count | Should -Be 1
|
||||
}
|
||||
|
||||
It 'never stages anything in the shared temp folder' {
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
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.
|
||||
that the device is not installed again. A skipped device without an agent keeps nothing,
|
||||
and the loader copy the bootstrap MSI installs is removed in every outcome.
|
||||
|
||||
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
|
||||
@@ -53,7 +54,7 @@ param(
|
||||
)
|
||||
|
||||
# The loader checks these two lines before it runs this script.
|
||||
$ScriptVersion = "2.8"
|
||||
$ScriptVersion = "2.9"
|
||||
$ScriptRole = "AutopilotMonitor-Installer"
|
||||
|
||||
# Signer of the agent's file catalog; a subject pattern, so certificate renewals keep working.
|
||||
@@ -69,6 +70,9 @@ $LogFile = "$AgentLogPath\bootstrap-script.log"
|
||||
$LegacyInstallerPath = "$AgentBasePath\Install-AutopilotMonitor.ps1"
|
||||
$LoaderRunFolderPrefix = "AutopilotMonitor-Loader-"
|
||||
|
||||
# Where the bootstrap MSI installs its loader copy; this script removes it in every outcome.
|
||||
$MsiTransportPath = "$AgentBasePath\Msi"
|
||||
|
||||
$AgentExeName = "AutopilotMonitor.Agent.exe"
|
||||
$AgentTaskName = "AutopilotMonitor-Agent"
|
||||
$DeploymentMarkerKey = "SOFTWARE\AutopilotMonitor"
|
||||
@@ -608,6 +612,57 @@ function Remove-AgentBaseOnSkip {
|
||||
}
|
||||
}
|
||||
|
||||
# The loader copy the bootstrap MSI installs. It goes whatever the outcome; an agent folder
|
||||
# that is then empty goes with it (a running agent, or logs kept after enrollment, keep it).
|
||||
# Returns what was done, or $null when there was nothing to do; never throws.
|
||||
function Remove-MsiTransportFolder {
|
||||
try {
|
||||
if ((Get-FolderState $AgentBasePath) -ne 'Ours') { return $null }
|
||||
$state = Get-FolderState $MsiTransportPath
|
||||
if ($state -eq 'Missing') { return $null }
|
||||
if ($state -ne 'Ours') { return "$MsiTransportPath left as it is ($state)." }
|
||||
$left = Remove-OwnTree -Path $MsiTransportPath
|
||||
if ($left.Foreign.Count -gt 0 -or $left.Locked.Count -gt 0) {
|
||||
return ("$MsiTransportPath kept: {0} entries of another account, {1} in use." -f $left.Foreign.Count, $left.Locked.Count)
|
||||
}
|
||||
if (@([System.IO.Directory]::EnumerateFileSystemEntries($AgentBasePath)).Count -eq 0) {
|
||||
[System.IO.Directory]::Delete($AgentBasePath, $false)
|
||||
return "$MsiTransportPath removed, and with it the empty $AgentBasePath."
|
||||
}
|
||||
return "$MsiTransportPath removed."
|
||||
} catch {
|
||||
return "$MsiTransportPath left as it is ($($_.Exception.Message))."
|
||||
}
|
||||
}
|
||||
|
||||
# What bootstrap MSI 1.3 installed under Program Files, on devices that still enroll with it.
|
||||
# Returns what was done, or $null when there was nothing to do; never throws.
|
||||
function Remove-LegacyMsiLoaderFolder {
|
||||
try {
|
||||
$programFiles = if ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles }
|
||||
if (-not $programFiles) { return $null }
|
||||
$company = Join-Path $programFiles 'AutopilotMonitor'
|
||||
$companyState = Get-FolderState $company
|
||||
if ($companyState -eq 'Missing') { return $null }
|
||||
if ($companyState -ne 'Ours') { return "$company left as it is ($companyState)." }
|
||||
$path = Join-Path $company 'Bootstrap'
|
||||
$state = Get-FolderState $path
|
||||
if ($state -eq 'Missing') { return $null }
|
||||
if ($state -ne 'Ours') { return "$path left as it is ($state)." }
|
||||
$left = Remove-OwnTree -Path $path
|
||||
if ($left.Foreign.Count -gt 0 -or $left.Locked.Count -gt 0) {
|
||||
return ("$path kept: {0} entries of another account, {1} in use." -f $left.Foreign.Count, $left.Locked.Count)
|
||||
}
|
||||
if (@([System.IO.Directory]::EnumerateFileSystemEntries($company)).Count -eq 0) {
|
||||
[System.IO.Directory]::Delete($company, $false)
|
||||
return "$path removed, and with it the empty $company."
|
||||
}
|
||||
return "$path removed."
|
||||
} catch {
|
||||
return "The loader folder of bootstrap MSI 1.3 is left as it is ($($_.Exception.Message))."
|
||||
}
|
||||
}
|
||||
|
||||
function Test-IsLoaderRunFolder {
|
||||
param([string]$Folder)
|
||||
if (-not $Folder) { return $false }
|
||||
@@ -711,12 +766,17 @@ function Invoke-Bootstrap {
|
||||
$decision = Get-BootstrapDecision -MaxBootstrapWindowHours $MaxBootstrapWindowHours -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes
|
||||
$agent = Get-AgentPresence
|
||||
if ($agent) {
|
||||
Write-Log "Agent present ($agent): $AgentBasePath is left as it is."
|
||||
$transport = Remove-MsiTransportFolder
|
||||
Write-Log "Agent present ($agent): $(if ($transport) { $transport } else { "$AgentBasePath is left as it is." })"
|
||||
$legacy = Remove-LegacyMsiLoaderFolder
|
||||
if ($legacy) { Write-Log $legacy }
|
||||
Remove-StaleLoaderRunFolders -ScriptRoot $PSScriptRoot
|
||||
exit 0
|
||||
}
|
||||
if (-not $decision.Install) {
|
||||
Write-Log "No agent on this device: $(Remove-AgentBaseOnSkip -Path $AgentBasePath)"
|
||||
$legacy = Remove-LegacyMsiLoaderFolder
|
||||
if ($legacy) { Write-Log $legacy }
|
||||
Remove-StaleLoaderRunFolders -ScriptRoot $PSScriptRoot
|
||||
exit 0
|
||||
}
|
||||
@@ -729,6 +789,8 @@ function Invoke-Bootstrap {
|
||||
Write-LoaderLogs $loaderLogs
|
||||
Open-LogFile
|
||||
Write-Log "Agent folder prepared: $AgentBasePath (protected, emptied)."
|
||||
$legacy = Remove-LegacyMsiLoaderFolder
|
||||
if ($legacy) { Write-Log $legacy }
|
||||
Remove-StaleLoaderRunFolders -ScriptRoot $PSScriptRoot
|
||||
|
||||
$agentExePath = Join-Path $AgentBinPath $AgentExeName
|
||||
|
||||
@@ -467,11 +467,15 @@ Describe 'publish contract' {
|
||||
$wxs.Contains('-LogFileName bootstrap-msi.log') | Should -BeTrue
|
||||
}
|
||||
|
||||
# The file a SYSTEM custom action runs must not sit where a standard user could have
|
||||
# created the folder first: Program Files, never ProgramData.
|
||||
It 'is installed by the bootstrap MSI under Program Files' {
|
||||
# Everything of ours lives under ProgramData, so the copy the MSI installs does too, where
|
||||
# the installer removes it. A standard user could have created that folder first, so the
|
||||
# action never runs the file as such: it checks the signature on the bytes it read and runs
|
||||
# those bytes, from System32.
|
||||
It 'is installed by the bootstrap MSI under ProgramData and run only after the check' {
|
||||
$wxs = Get-Content (Join-Path $PSScriptRoot '..\..\src\Agent\AutopilotMonitor.BootstrapMsi\Package.wxs') -Raw
|
||||
$wxs | Should -Match '<StandardDirectory Id="ProgramFiles64Folder">'
|
||||
$wxs | Should -Not -Match 'CommonAppDataFolder'
|
||||
$wxs | Should -Match '<StandardDirectory Id="CommonAppDataFolder">'
|
||||
$wxs | Should -Not -Match 'ProgramFiles'
|
||||
$wxs.Contains('Get-AuthenticodeSignature -Content $b -SourcePathOrExtension ''.ps1''') | Should -BeTrue
|
||||
$wxs.Contains('Directory="System64Folder"') | Should -BeTrue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
@@ -26,6 +28,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Bootstrap
|
||||
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 static readonly string Wxs = File.ReadAllText(Path.Combine(
|
||||
RepoPaths.Root(), "src", "Agent", "AutopilotMonitor.BootstrapMsi", "Package.wxs"));
|
||||
|
||||
private readonly string _logs = Path.Combine(Path.GetTempPath(), "apm-bootstrap-contract-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
@@ -80,8 +84,49 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Bootstrap
|
||||
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);
|
||||
Assert.Contains("-LogFileName " + BootstrapProvenanceReader.MsiLoaderLogName, Wxs);
|
||||
}
|
||||
|
||||
// The bootstrap MSI installs its loader copy where the installer removes it, starts
|
||||
// PowerShell in System32, and runs only bytes it has checked against the publisher pin
|
||||
// the loader applies to the installer. Windows Installer formats the command, so every
|
||||
// bracket and brace is escaped and the only property references are the two paths.
|
||||
[Fact]
|
||||
public void The_msi_puts_the_loader_copy_where_the_installer_removes_it_and_runs_only_what_it_checked()
|
||||
{
|
||||
XNamespace ns = "http://wixtoolset.org/schemas/v4/wxs";
|
||||
var package = XDocument.Parse(Wxs).Root.Element(ns + "Package");
|
||||
Assert.Equal("1fbe5515-d346-445c-b8b8-14ee79f97522", (string)package.Attribute("UpgradeCode"));
|
||||
|
||||
var root = package.Elements(ns + "StandardDirectory").Single();
|
||||
Assert.Equal("CommonAppDataFolder", (string)root.Attribute("Id"));
|
||||
var chain = root.Descendants(ns + "Directory").Select(d => (string)d.Attribute("Name")).ToArray();
|
||||
Assert.Equal("INSTALLFOLDER", (string)root.Descendants(ns + "Directory").Last().Attribute("Id"));
|
||||
var transport = Assignment(Installer, "MsiTransportPath").Replace("$AgentBasePath", Assignment(Installer, "AgentBasePath"));
|
||||
Assert.Equal("$env:ProgramData\\" + string.Join("\\", chain), transport);
|
||||
|
||||
var action = package.Elements(ns + "CustomAction").Single(a => (string)a.Attribute("Id") == "RunBootstrap");
|
||||
Assert.Equal("System64Folder", (string)action.Attribute("Directory"));
|
||||
Assert.Equal("deferred", (string)action.Attribute("Execute"));
|
||||
Assert.Equal("no", (string)action.Attribute("Impersonate"));
|
||||
Assert.Equal("ignore", (string)action.Attribute("Return"));
|
||||
|
||||
var exeCommand = (string)action.Attribute("ExeCommand");
|
||||
var command = exeCommand.Substring(exeCommand.IndexOf(" -Command ", StringComparison.Ordinal));
|
||||
Assert.Contains("-NonInteractive", exeCommand);
|
||||
Assert.Contains("[\\[]System.IO.File[\\]]::ReadAllBytes('[INSTALLFOLDER]Start-AutopilotMonitor.ps1')", command);
|
||||
Assert.Contains("Get-AuthenticodeSignature -Content $b -SourcePathOrExtension '.ps1'", command);
|
||||
Assert.Contains("$s.Status -eq 'Valid' -and $s.SignerCertificate.Subject -like '" + Assignment(Loader, "ExpectedPublisher") + "'", command);
|
||||
Assert.Contains("[\\[]scriptblock[\\]]::Create([\\[]System.Text.Encoding[\\]]::UTF8.GetString($b))", command);
|
||||
Assert.DoesNotContain("-File", command);
|
||||
// Without the [\x] escapes, the only bracket left is the one property reference.
|
||||
var unescaped = Regex.Replace(command, @"\[\\.\]", "");
|
||||
Assert.DoesNotMatch(@"[{}\[\]]", unescaped.Replace("[INSTALLFOLDER]", ""));
|
||||
Assert.Equal(new[] { "[INSTALLFOLDER]" }, Regex.Matches(command, @"\[[A-Za-z0-9_.]+\]").Cast<Match>().Select(m => m.Value).Distinct().ToArray());
|
||||
|
||||
var custom = package.Element(ns + "InstallExecuteSequence").Elements(ns + "Custom").Single();
|
||||
Assert.Equal("RunBootstrap", (string)custom.Attribute("Action"));
|
||||
Assert.Equal("NOT Installed", (string)custom.Attribute("Condition"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -11,16 +11,22 @@
|
||||
logic stays updatable without re-shipping the MSI, and both delivery channels share one
|
||||
implementation.
|
||||
|
||||
The loader is installed under Program Files, where only administrators and SYSTEM can
|
||||
create or change anything: the file that a SYSTEM custom action runs must not live in a
|
||||
folder a standard user could create first (ProgramData lets every user create folders).
|
||||
Everything of ours on a device lives under ProgramData. The loader copy goes to the agent
|
||||
folder, which the installer owns: it removes the copy in every outcome. Any standard user
|
||||
can create that folder first, and Windows Installer keeps a modified unversioned file, so
|
||||
the custom action never runs the file at that path as such: it reads the file once, checks
|
||||
our Authenticode signature on exactly those bytes (the publisher pin the loader applies to
|
||||
the installer) and runs exactly those bytes. PowerShell starts in System32, never in a
|
||||
folder users can write to. Windows Installer formats the command: every square bracket and
|
||||
every brace of the PowerShell text is written as [\[], [\]], [\{] or [\}], because a brace
|
||||
block that holds a bracket is a conditional block to the formatter and loses its braces.
|
||||
|
||||
Build: wix build Package.wxs -arch x64 -o bin\AutopilotMonitor-Bootstrap.msi
|
||||
-->
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
|
||||
<Package Name="Autopilot Monitor Bootstrap"
|
||||
Manufacturer="Autopilot Monitor"
|
||||
Version="1.3.0"
|
||||
Version="1.4.0"
|
||||
UpgradeCode="1fbe5515-d346-445c-b8b8-14ee79f97522"
|
||||
Scope="perMachine"
|
||||
Compressed="yes">
|
||||
@@ -28,16 +34,16 @@
|
||||
<MajorUpgrade DowngradeErrorMessage="A newer version of Autopilot Monitor Bootstrap is already installed." />
|
||||
<MediaTemplate EmbedCab="yes" />
|
||||
|
||||
<StandardDirectory Id="ProgramFiles64Folder">
|
||||
<StandardDirectory Id="CommonAppDataFolder">
|
||||
<Directory Id="CompanyFolder" Name="AutopilotMonitor">
|
||||
<Directory Id="INSTALLFOLDER" Name="Bootstrap" />
|
||||
<Directory Id="INSTALLFOLDER" Name="Msi" />
|
||||
</Directory>
|
||||
</StandardDirectory>
|
||||
|
||||
<ComponentGroup Id="MainComponents" Directory="INSTALLFOLDER">
|
||||
<!-- Source is relative to the build working directory (the project dir), not to this
|
||||
file. New component GUID because the key path changed (1.3.0: Program Files). -->
|
||||
<Component Id="RunnerScript" Guid="3C6F0E2D-8B41-4A77-9D5E-2F1B6C8A4D90">
|
||||
file. New component GUID because the key path changed (1.4.0: back to ProgramData). -->
|
||||
<Component Id="RunnerScript" Guid="5E8D1B6A-2C47-4F93-A1D0-7B9E3C5F8A21">
|
||||
<File Id="RunnerPs1"
|
||||
Source="..\..\..\scripts\Bootstrap\Start-AutopilotMonitor.ps1"
|
||||
Name="Start-AutopilotMonitor.ps1"
|
||||
@@ -49,13 +55,14 @@
|
||||
<ComponentGroupRef Id="MainComponents" />
|
||||
</Feature>
|
||||
|
||||
<!-- Deferred + no-impersonate: runs as SYSTEM after files land. Return=ignore keeps
|
||||
the LOB install green even if the loader hits a transient error (the loader is
|
||||
additionally fail-soft itself). -LogFileName keeps this channel distinguishable in
|
||||
a diagnostics package; the diag tooling keys on that name. -->
|
||||
<!-- Deferred + no-impersonate: runs as SYSTEM after the file lands. Return=ignore keeps
|
||||
the LOB install green whatever happens (the loader is fail-soft itself). Runs on
|
||||
every install of the product, upgrades included, so the installer gets to remove the
|
||||
copy. -LogFileName keeps this channel distinguishable in a diagnostics package; the
|
||||
diag tooling keys on that name. -->
|
||||
<CustomAction Id="RunBootstrap"
|
||||
Directory="INSTALLFOLDER"
|
||||
ExeCommand=""[System64Folder]WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "[INSTALLFOLDER]Start-AutopilotMonitor.ps1" -LogFileName bootstrap-msi.log"
|
||||
Directory="System64Folder"
|
||||
ExeCommand=""[System64Folder]WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$b = [\[]System.IO.File[\]]::ReadAllBytes('[INSTALLFOLDER]Start-AutopilotMonitor.ps1'); $s = Get-AuthenticodeSignature -Content $b -SourcePathOrExtension '.ps1'; if ($s.Status -eq 'Valid' -and $s.SignerCertificate.Subject -like '*O=glueckkanja AG*') [\{] & ([\[]scriptblock[\]]::Create([\[]System.Text.Encoding[\]]::UTF8.GetString($b))) -LogFileName bootstrap-msi.log [\}]""
|
||||
Execute="deferred"
|
||||
Impersonate="no"
|
||||
Return="ignore" />
|
||||
|
||||
Reference in New Issue
Block a user