diff --git a/.github/workflows/build-msi.yml b/.github/workflows/build-msi.yml index c6cc571e..f04943b8 100644 --- a/.github/workflows/build-msi.yml +++ b/.github/workflows/build-msi.yml @@ -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: diff --git a/internal b/internal index 3cd36a52..4cf6b4d8 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 3cd36a521f435c2bc5f0d3766ae6900335091e48 +Subproject commit 4cf6b4d84072c6ed455185d0a1f27aaea5d4acfe diff --git a/scripts/Bootstrap/Bootstrap.Integration.Tests.ps1 b/scripts/Bootstrap/Bootstrap.Integration.Tests.ps1 index 48f85c82..0aaf1029 100644 --- a/scripts/Bootstrap/Bootstrap.Integration.Tests.ps1 +++ b/scripts/Bootstrap/Bootstrap.Integration.Tests.ps1 @@ -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: ' -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 ' skips and leaves ' -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 ' 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 \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: ' -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: ' -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' } } diff --git a/scripts/Bootstrap/Install-AutopilotMonitor.Tests.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor.Tests.ps1 index c748bcdb..8e9e95ea 100644 --- a/scripts/Bootstrap/Install-AutopilotMonitor.Tests.ps1 +++ b/scripts/Bootstrap/Install-AutopilotMonitor.Tests.ps1 @@ -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' { diff --git a/scripts/Bootstrap/Install-AutopilotMonitor.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor.ps1 index 0b4a133a..0849f350 100644 --- a/scripts/Bootstrap/Install-AutopilotMonitor.ps1 +++ b/scripts/Bootstrap/Install-AutopilotMonitor.ps1 @@ -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 diff --git a/scripts/Bootstrap/Start-AutopilotMonitor.Tests.ps1 b/scripts/Bootstrap/Start-AutopilotMonitor.Tests.ps1 index ebe52199..b4849c9f 100644 --- a/scripts/Bootstrap/Start-AutopilotMonitor.Tests.ps1 +++ b/scripts/Bootstrap/Start-AutopilotMonitor.Tests.ps1 @@ -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 '' - $wxs | Should -Not -Match 'CommonAppDataFolder' + $wxs | Should -Match '' + $wxs | Should -Not -Match 'ProgramFiles' + $wxs.Contains('Get-AuthenticodeSignature -Content $b -SourcePathOrExtension ''.ps1''') | Should -BeTrue + $wxs.Contains('Directory="System64Folder"') | Should -BeTrue } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Bootstrap/BootstrapContractTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Bootstrap/BootstrapContractTests.cs index d0374ad3..0bbf6d6d 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Bootstrap/BootstrapContractTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Bootstrap/BootstrapContractTests.cs @@ -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().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] diff --git a/src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs b/src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs index 03e8dd4d..4419da95 100644 --- a/src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs +++ b/src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs @@ -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 --> @@ -28,16 +34,16 @@ - + - + - + file. New component GUID because the key path changed (1.4.0: back to ProgramData). --> + - +