diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index d3bb2fe7..8d5fc11e 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -35,6 +35,7 @@ jobs: - ./internal/detector/configaudit - ./internal/state - ./internal/detector/credentials + - ./internal/updatestate env: CGO_ENABLED: '0' # Short campaigns on the PR and merge-queue paths so they don't hold up a diff --git a/.github/workflows/msi-smoke.yml b/.github/workflows/msi-smoke.yml index 5b986e27..45a6590d 100644 --- a/.github/workflows/msi-smoke.yml +++ b/.github/workflows/msi-smoke.yml @@ -1,7 +1,8 @@ name: MSI Smoke Test -# Runs the full Windows MSI flow (build → install → verify → uninstall) -# on a fresh windows-latest runner. Catches breakage in the WiX manifest, +# Runs the full Windows MSI flow (build → install → verify → bootstrap run + +# SYSTEM update tick → repair → N→N+1 upgrade → uninstall) on a fresh +# windows-latest runner. Catches breakage in the WiX manifest, # the deferred-CA wiring, the configure --non-interactive path, or the # icacls hardening — all of which only surface on a real install. # @@ -44,9 +45,9 @@ concurrency: jobs: msi-smoke: - name: Build, install, verify, uninstall + name: Build, install, verify, repair, upgrade, uninstall runs-on: windows-latest - timeout-minutes: 15 + timeout-minutes: 25 steps: - name: Harden the runner (Audit all outbound calls) @@ -78,7 +79,9 @@ jobs: go build -trimpath -ldflags "-s -w" ` -o "$PWD\dmg-smoke.exe" ` ./cmd/stepsecurity-dev-machine-guard - go build -trimpath -ldflags "-s -w -H windowsgui" ` + # The launcher (= the bootstrap) carries the fault checkpoints, inert + # unless DMG_FAULT is set, so a step can hang a bootstrap on purpose. + go build -trimpath -tags selfupdate_faults -ldflags "-s -w -H windowsgui" ` -o "$PWD\dmg-smoke-task.exe" ` ./cmd/stepsecurity-dev-machine-guard-task # The MSI's third component: the Linux build of the same agent, which @@ -115,6 +118,35 @@ jobs: -out "dist\dmg-smoke.msi" Get-Item "dist\dmg-smoke.msi" | Select-Object Name, Length + # N+1: the same payload under the next patch version, for the + # major-upgrade path (seed\, no downgrade of a newer current). + $p = $version.Split('.') + $next = "$($p[0]).$($p[1]).$([int]$p[2] + 1)" + wix build packaging/windows/Product.wxs ` + -arch x64 ` + -ext WixToolset.Util.wixext ` + -d Arch=x64 ` + -d "Version=$next" ` + -d "BinaryPath=$PWD\dmg-smoke.exe" ` + -d "LauncherPath=$PWD\dmg-smoke-task.exe" ` + -d "LinuxBinaryPath=$PWD\dmg-smoke-linux" ` + -out "dist\dmg-smoke-next.msi" + # N+1 with the test-only late-failure hook (WIXFAILWHENDEFERRED=1 + # makes the transaction fail after adopt-seed), for the rollback path. + wix build packaging/windows/Product.wxs ` + -arch x64 ` + -ext WixToolset.Util.wixext ` + -d Arch=x64 ` + -d "Version=$next" ` + -d "TestFailUpgrade=1" ` + -d "BinaryPath=$PWD\dmg-smoke.exe" ` + -d "LauncherPath=$PWD\dmg-smoke-task.exe" ` + -d "LinuxBinaryPath=$PWD\dmg-smoke-linux" ` + -out "dist\dmg-smoke-next-fail.msi" + "MSI_VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "MSI_NEXT_VERSION=$next" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "INSTALLED_MSI=dist\dmg-smoke.msi" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + - name: Install MSI with synthetic tenant config shell: pwsh run: | @@ -123,11 +155,12 @@ jobs: # --ignore-telemetry-error so initial telemetry POST failure # doesn't roll back the install. Exit 0 expected. $p = Start-Process -FilePath "msiexec.exe" -ArgumentList @( - "/i", "dist\dmg-smoke.msi", "/qn", + "/i", "dist\dmg-smoke.msi", "/qn", "/norestart", "CUSTOMERID=ci-smoke", "APIENDPOINT=https://api.invalid.example", "APIKEY=ci-test-fake-api-key", "SCANFREQUENCY=4", + "AUTOUPDATE=0", "/l*v", "dist\dmg-install.log" ) -Wait -PassThru Write-Host "msiexec exit: $($p.ExitCode)" @@ -138,182 +171,397 @@ jobs: exit 1 } - - name: Verify install artifacts + - name: Write assertion helpers shell: pwsh run: | - $bin = "C:\Program Files\StepSecurity\stepsecurity-dev-machine-guard.exe" - $launcher = "C:\Program Files\StepSecurity\stepsecurity-dev-machine-guard-task.exe" - $cfg = "C:\ProgramData\StepSecurity\config.json" - $task = "StepSecurity Dev Machine Guard" - - $checks = @() - - # 1a. Agent binary on disk - if (Test-Path $bin) { - Write-Host "[OK] Agent present at $bin" - $checks += $true - } else { - Write-Host "::error::Agent missing: $bin" - $checks += $false + # Shared by the verify steps below (dot-sourced). + @' + $script:fail = $false + $root = "C:\Program Files\StepSecurity" + $boot = "$root\stepsecurity-dev-machine-guard-bootstrap.exe" + $data = "C:\ProgramData\StepSecurity" + $tasks = @("StepSecurity Dev Machine Guard", "StepSecurity Dev Machine Guard (Logon)", "StepSecurity Dev Machine Guard (Update)") + function Check($ok, $msg) { + if ($ok) { Write-Host "[OK] $msg" } else { Write-Host "::error::$msg"; $script:fail = $true } } - - # 1b. Launcher binary on disk - if (Test-Path $launcher) { - Write-Host "[OK] Launcher present at $launcher" - $checks += $true - } else { - Write-Host "::error::Launcher missing: $launcher" - $checks += $false + function Done { if ($script:fail) { exit 1 }; exit 0 } + function State { Get-Content "$root\state\state.json" -Raw | ConvertFrom-Json } + # auto_update in the machine config: 'true', 'false' or '' (missing). + function AutoUpdate { + $c = Get-Content "$data\config.json" -Raw | ConvertFrom-Json + if ($null -eq $c.auto_update) { return '' } + return "$($c.auto_update)".ToLower() } - - # 1c. The Linux agent, beside the Windows one. "Beside" is the whole - # requirement: the WSL scan phase resolves this binary relative to - # its own executable, so a component that installs anywhere else - # ships fine and is silently inert. The ELF header is checked because - # Windows never executes this file, so nothing else here would notice - # if the wrong architecture were packaged. - $linuxAgent = "C:\Program Files\StepSecurity\dmg-linux" - if (Test-Path $linuxAgent) { - $hdr = [byte[]]::new(20) - $fs = [System.IO.File]::OpenRead($linuxAgent) - try { $null = $fs.Read($hdr, 0, 20) } finally { $fs.Dispose() } - # ELF magic, EI_CLASS=2 (64-bit), e_machine 0x3E (x86-64) at offset 18. - $machine = $hdr[18] + ($hdr[19] -shl 8) - if ($hdr[0] -eq 0x7F -and $hdr[1] -eq 0x45 -and $hdr[2] -eq 0x4C -and $hdr[3] -eq 0x46 ` - -and $hdr[4] -eq 2 -and $machine -eq 0x3E) { - Write-Host "[OK] dmg-linux present beside the agent, 64-bit x86-64 ELF" - $checks += $true - } else { - Write-Host "::error::dmg-linux is not a 64-bit x86-64 ELF (class=$($hdr[4]) machine=0x$('{0:X2}' -f $machine))" - $checks += $false + function TaskXml($name) { [xml](schtasks /query /tn $name /xml | Out-String) } + function UsableDir($d) { (Test-Path "$d\stepsecurity-dev-machine-guard.exe") -and (Test-Path "$d\stepsecurity-dev-machine-guard-task.exe") -and (Test-Path "$d\dmg-linux") } + function Msi($argList, $log) { + # Wait for msiexec itself only (not descendants), bounded, and dump + # the log tail plus any DMG processes when it doesn't finish. + # /norestart: a reboot-required result must fail the step, not + # restart the runner (which wedges the job with no logs). + $p = Start-Process -FilePath "msiexec.exe" -ArgumentList ($argList + @("/norestart", "/l*v", $log)) -PassThru + if (-not $p.WaitForExit(300000)) { + Write-Host "::error::msiexec $($argList -join ' ') still running after 5 min" + Get-Process | Where-Object { $_.Path -and ($_.Path -like "$root\*" -or $_.Name -like 'msiexec*') } | Format-Table Id, Name, Path -AutoSize | Out-String | Write-Host + Get-Content $log -Tail 120 -ErrorAction SilentlyContinue | Out-Host + $p.Kill(); return -1 } - } else { - Write-Host "::error::dmg-linux missing: $linuxAgent" - $checks += $false + if ($p.ExitCode -ne 0) { Get-Content $log -Tail 80 -ErrorAction SilentlyContinue | Out-Host } + return $p.ExitCode } - - # 2. Machine-wide config exists and contains our values - if (Test-Path $cfg) { - $content = Get-Content $cfg -Raw - if ($content -match '"customer_id"\s*:\s*"ci-smoke"' -and - $content -match '"api_key"\s*:\s*"ci-test-fake-api-key"') { - Write-Host "[OK] Config written with expected values" - $checks += $true - } else { - Write-Host "::error::Config content unexpected:" - Write-Host $content - $checks += $false + function DmgProcesses { Get-Process | Where-Object { $_.Path -and $_.Path -like "$root\*" } | Format-Table Id, Name, Path -AutoSize | Out-String | Write-Host } + function AssertTasks { + foreach ($t in $tasks) { + schtasks /query /tn $t 2>&1 | Out-Null + Check ($LASTEXITCODE -eq 0) "task registered: $t" } - } else { - Write-Host "::error::Config missing: $cfg" - $checks += $false + foreach ($t in $tasks[0..1]) { + $x = TaskXml $t + $exec = $x.Task.Actions.Exec + Check ($exec.Command.Trim('"') -eq $boot) "$t action runs the bootstrap ($($exec.Command))" + Check ($exec.Arguments -match '^run send-telemetry --install-dir=') "$t arguments: $($exec.Arguments)" + Check ($x.Task.Principals.Principal.GroupId -eq 'S-1-5-4' -or $x.Task.Principals.Principal.UserId -eq 'S-1-5-4') "$t principal is INTERACTIVE" + } + $u = TaskXml $tasks[2] + $exec = $u.Task.Actions.Exec + Check ($exec.Command.Trim('"') -eq $boot -and $exec.Arguments -eq 'update') "update task action: $($exec.Command) $($exec.Arguments)" + $uid = $u.Task.Principals.Principal.UserId + Check ($uid -eq 'S-1-5-18' -or $uid -match 'SYSTEM') "update task principal SYSTEM ($uid)" + $st = $u.Task.Settings + Check ($st.MultipleInstancesPolicy -eq 'IgnoreNew') "update task IgnoreNew" + Check ($st.ExecutionTimeLimit -eq 'PT30M') "update task ExecutionTimeLimit PT30M" + Check ($st.StartWhenAvailable -eq 'true') "update task StartWhenAvailable" + Check ($st.DisallowStartIfOnBatteries -eq 'false' -and $st.StopIfGoingOnBatteries -eq 'false') "update task runs on battery" + Check ($u.Task.Triggers.TimeTrigger.Repetition.Interval -eq 'PT1H' -and $u.Task.Triggers.TimeTrigger.RandomDelay -eq 'PT10M') "update task hourly + 10 min random delay" + Check ($u.Task.Triggers.BootTrigger.Delay -eq 'PT15M') "update task at boot + 15 min" + } + function AssertProtected($dir) { + # Exact: protected DACL, owner SYSTEM, and exactly these three + # explicit ACEs. Any extra ACE (e.g. Users:Modify) fails (R-B-3). + $acl = Get-Acl $dir + Check ($acl.Owner -eq 'NT AUTHORITY\SYSTEM') "$dir owner SYSTEM ($($acl.Owner))" + Check $acl.AreAccessRulesProtected "$dir DACL protected (inheritance off)" + $got = @($acl.Access | ForEach-Object { + "$($_.AccessControlType)|$($_.IdentityReference)|$($_.FileSystemRights)|$($_.InheritanceFlags)|$($_.IsInherited)" + } | Sort-Object) + $want = @( + 'Allow|BUILTIN\Administrators|FullControl|ContainerInherit, ObjectInherit|False', + 'Allow|BUILTIN\Users|ReadAndExecute, Synchronize|ContainerInherit, ObjectInherit|False', + 'Allow|NT AUTHORITY\SYSTEM|FullControl|ContainerInherit, ObjectInherit|False' + ) | Sort-Object + Check (($got -join "`n") -eq ($want -join "`n")) "$dir DACL is exactly SYSTEM F, Administrators F, Users RX:`n got: $($got -join '; ')" } + '@ | Set-Content -Path "$env:RUNNER_TEMP\dmg-assert.ps1" + + - name: Verify install artifacts + shell: pwsh + run: | + . "$env:RUNNER_TEMP\dmg-assert.ps1" + $ver = $env:MSI_VERSION + $seed = "$root\seed\$ver" + $cfg = "$data\config.json" + + # 1. Layout: bootstrap + seed; the legacy top-level agent/launcher are gone. + Check (Test-Path $boot) "bootstrap present" + Check (UsableDir $seed) "seed\$ver has the agent, launcher and dmg-linux" + Check (-not (Test-Path "$root\stepsecurity-dev-machine-guard.exe")) "no top-level agent" + Check (-not (Test-Path "$root\stepsecurity-dev-machine-guard-task.exe")) "no top-level launcher" + + # 1b. dmg-linux must sit beside agent.exe (the WSL scan phase looks next + # to its own executable) and be a 64-bit x86-64 ELF. + $hdr = [byte[]]::new(20) + $fs = [System.IO.File]::OpenRead("$seed\dmg-linux") + try { $null = $fs.Read($hdr, 0, 20) } finally { $fs.Dispose() } + $machine = $hdr[18] + ($hdr[19] -shl 8) + Check ($hdr[0] -eq 0x7F -and $hdr[1] -eq 0x45 -and $hdr[2] -eq 0x4C -and $hdr[3] -eq 0x46 -and $hdr[4] -eq 2 -and $machine -eq 0x3E) "dmg-linux is a 64-bit x86-64 ELF" + + # 2. adopt-seed: versions\ copied, state.json current = seed, + # versions\ and state\ protected. + Check ((UsableDir "$root\versions\$ver") -and (Test-Path "$root\versions\$ver\manifest.json")) "versions\$ver adopted from the seed, with manifest" + $state = State + Check ($state.schema -eq 1 -and $state.current -eq $ver) "state.json current = $ver (got $($state.current))" + AssertProtected "$root\versions" + AssertProtected "$root\state" + + # 3. AgentVersion (Intune detection) = MSI version; RunningVersion = current. + $av = (Get-ItemProperty -Path 'HKLM:\Software\StepSecurity' -Name AgentVersion -ErrorAction SilentlyContinue).AgentVersion + Check ($av -eq $ver) "AgentVersion = $ver ($av)" + $rv = (Get-ItemProperty -Path 'HKLM:\Software\StepSecurity' -Name RunningVersion -ErrorAction SilentlyContinue).RunningVersion + Check ($rv -eq $ver) "RunningVersion = $ver, set by adopt-seed with current ($rv)" + + # 4. Machine-wide config with our values; installed with AUTOUPDATE=0. + $content = Get-Content $cfg -Raw + Check ($content -match '"customer_id"\s*:\s*"ci-smoke"' -and $content -match '"api_key"\s*:\s*"ci-test-fake-api-key"') "config written with expected values" + Check ($content -match '"auto_update"\s*:\s*false') "AUTOUPDATE=0 wrote auto_update false" - # 3. ACL hardening on config.json — Users should be Read only, - # Administrators + SYSTEM should be Full Control, inheritance disabled. + # 5. config.json ACL hardening: Users R, Admins + SYSTEM F. $acl = & icacls $cfg | Out-String - $aclOk = $true - if ($acl -notmatch 'BUILTIN\\Users:\(R\)') { $aclOk = $false; Write-Host "::error::Users(R) ACE missing" } - if ($acl -notmatch 'BUILTIN\\Administrators:\(F\)') { $aclOk = $false; Write-Host "::error::Administrators(F) ACE missing" } - if ($acl -notmatch 'NT AUTHORITY\\SYSTEM:\(F\)') { $aclOk = $false; Write-Host "::error::SYSTEM(F) ACE missing" } - if ($aclOk) { - Write-Host "[OK] config.json ACL hardened (Users:R, Admins:F, SYSTEM:F)" - $checks += $true - } else { - Write-Host "::error::ACL not as expected:" - Write-Host $acl - $checks += $false - } + Check ($acl -match 'BUILTIN\\Users:\(R\)' -and $acl -match 'BUILTIN\\Administrators:\(F\)' -and $acl -match 'NT AUTHORITY\\SYSTEM:\(F\)') "config.json ACL hardened" - # 4. Scheduled task registered, runs as INTERACTIVE, and its - # action points at the launcher (not the agent directly). - $taskInfo = schtasks /query /tn $task /v /fo LIST 2>&1 - if ($LASTEXITCODE -eq 0) { - $runAs = ($taskInfo | Select-String -Pattern '^\s*Run As User:').ToString() - if ($runAs -match 'INTERACTIVE') { - Write-Host "[OK] Scheduled task registered, Run As User: INTERACTIVE" - $checks += $true - } else { - Write-Host "::error::Run As User wrong: $runAs" - $checks += $false - } + # 6. All three tasks, actions through the bootstrap. + AssertTasks + Done - $action = ($taskInfo | Select-String -Pattern '^\s*Task To Run:').ToString() - if ($action -match 'stepsecurity-dev-machine-guard-task\.exe') { - Write-Host "[OK] Task action points at GUI launcher" - $checks += $true - } else { - Write-Host "::error::Task action does not point at launcher: $action" - $checks += $false - } - } else { - Write-Host "::error::schtasks /query failed: $taskInfo" - $checks += $false + - name: Bootstrap run and SYSTEM update tick + shell: pwsh + run: | + . "$env:RUNNER_TEMP\dmg-assert.ps1" + $ver = $env:MSI_VERSION + + # run: the bootstrap resolves current and launches versions\\task.exe + # (GUI subsystem, hence Start-Process -Wait) and records the run. + $p = Start-Process -FilePath $boot -ArgumentList @("run", "version", "--install-dir=$data") -Wait -PassThru + Check ($p.ExitCode -eq 0) "bootstrap run exit 0 ($($p.ExitCode))" + $runs = Get-Content "$data\runs\$ver.json" -Raw | ConvertFrom-Json + $last = $runs.records[-1] + Check ($last.version -eq $ver -and $last.exit_code -eq 0) "run record finalized: $($runs | ConvertTo-Json -Compress)" + + # update: fire the real SYSTEM task and wait for it to finish. Until the + # agent ships self-update the agent exits non-zero; either way the + # bootstrap records the updater result in state.json as SYSTEM. + schtasks /run /tn $tasks[2] | Out-Null + $deadline = (Get-Date).AddMinutes(4) + do { + Start-Sleep -Seconds 3 + $info = Get-ScheduledTask -TaskName $tasks[2] | Get-ScheduledTaskInfo + $state = (Get-ScheduledTask -TaskName $tasks[2]).State + } while (($state -eq 'Running' -or $info.LastRunTime -lt (Get-Date).AddYears(-1)) -and (Get-Date) -lt $deadline) + Write-Host "update task: state=$state LastTaskResult=$($info.LastTaskResult)" + Check ($state -ne 'Running') "update task finished" + $s = State + Check ($s.updater.version -eq $ver) "state.updater recorded by the bootstrap: $($s.updater | ConvertTo-Json -Compress)" + Get-Content "$root\state\update.log" -ErrorAction SilentlyContinue | Select-Object -Last 20 + DmgProcesses + Done + + - name: Failed major upgrade rolls adopt-seed back (DMG-I5-2) + timeout-minutes: 8 + shell: pwsh + run: | + . "$env:RUNNER_TEMP\dmg-assert.ps1" + $ver = $env:MSI_VERSION + $next = $env:MSI_NEXT_VERSION + Check ((State).current -eq $ver) "baseline current = $ver" + Check (-not (Test-Path "$root\versions\$next")) "baseline: no versions\$next" + $props = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' + $pfr0 = @((Get-ItemProperty -Path $props -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations | Where-Object { $_ }) + # SCCM e2e B1: the upgrade ships a BOOTSTRAPFILE without auto_update. + $bf = "$env:RUNNER_TEMP\dmg-bootstrap.json" + '{"customer_id":"ci-smoke","api_endpoint":"https://api.invalid.example","api_key":"ci-test-rotated-key"}' | Set-Content -Path $bf -Encoding ascii + $code = Msi @("/i", "dist\dmg-smoke-next-fail.msi", "/qn", "WIXFAILWHENDEFERRED=1", "BOOTSTRAPFILE=$bf") "dist\dmg-upgrade-fail.log" + Check ($code -eq 1603) "forced-failure upgrade exits 1603 ($code)" + Check ((AutoUpdate) -eq 'false') "failed upgrade with BOOTSTRAPFILE left auto_update false (got $(AutoUpdate))" + # SCCM e2e B2: the rollback put back the previous install's MSI helper. + $helper = "$root\state\msi\stepsecurity-dev-machine-guard-bootstrap.exe" + Check ((Get-FileHash $helper).Hash -eq (Get-FileHash $boot).Hash) "state\msi helper restored to the installed bootstrap" + Check (-not (Test-Path "$helper.prev")) "no previous-helper copy left after the rollback" + $ranRollback = Select-String -Path "dist\dmg-upgrade-fail.log" -Pattern 'adopt-seed --rollback' -SimpleMatch -Quiet + Check $ranRollback "adopt-seed rollback action ran" + $s = State + Check ($s.current -eq $ver) "current restored to $ver (got $($s.current))" + Check (-not (Test-Path "$root\versions\$next")) "versions\$next created by the failed upgrade removed" + Check (-not (Test-Path "$root\state\adopt-snapshot.json")) "rollback snapshot removed" + # DMG-I5-7: the rollback restored no file over a running bootstrap. + $pfr = @((Get-ItemProperty -Path $props -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations | Where-Object { $_ }) + $new = @($pfr | Where-Object { $_ -match 'StepSecurity' -and $pfr0 -notcontains $_ }) + Check ($new.Count -eq 0) "the rollback left no pending file renames for our files ($($new -join '; '))" + Check (-not (Select-String -Path "dist\dmg-upgrade-fail.log" -Pattern 'is being held in use' -SimpleMatch -Quiet)) "no MSI file held in use during the failed upgrade" + Check (UsableDir "$root\seed\$ver") "seed\$ver restored" + $av = (Get-ItemProperty -Path 'HKLM:\Software\StepSecurity' -Name AgentVersion).AgentVersion + Check ($av -eq $ver) "AgentVersion = $ver ($av)" + AssertTasks + # The device still scans: the bootstrap launches current and records it. + $p = Start-Process -FilePath $boot -ArgumentList @("run", "version", "--install-dir=$data") -Wait -PassThru + Check ($p.ExitCode -eq 0) "bootstrap run after the rollback exits 0 ($($p.ExitCode))" + $runs = Get-Content "$data\runs\$ver.json" -Raw | ConvertFrom-Json + Check ($runs.records[-1].exit_code -eq 0) "run recorded on $ver" + Done + + - name: Repairs while a bootstrap holds its image never reboot (D29) + timeout-minutes: 12 + shell: pwsh + run: | + . "$env:RUNNER_TEMP\dmg-assert.ps1" + $ver = $env:MSI_VERSION + $boot0 = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime + Check (Test-Path "$root\state\msi\stepsecurity-dev-machine-guard-bootstrap.exe") "stop-for-msi helper present in state\msi" + $props = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' + $forms = @( + @{ name = "/i REINSTALL=ALL REINSTALLMODE=a"; args = @("/i", $env:INSTALLED_MSI, "REINSTALL=ALL", "REINSTALLMODE=a", "/qn") }, + @{ name = "/fa (ignores command-line properties)"; args = @("/fa", $env:INSTALLED_MSI, "/qn") } + ) + $i = 0 + foreach ($form in $forms) { + $i++ + # A bootstrap that hangs (boot.resolved:hang) holds its own image, + # an MSI file, for the whole repair: the D29 race, every time. + $env:DMG_FAULT = "boot.resolved:hang" + $bp = Start-Process -FilePath $boot -ArgumentList @("run", "version", "--install-dir=$data") -PassThru + Remove-Item Env:DMG_FAULT + Start-Sleep -Seconds 3 + Check (-not $bp.HasExited) "$($form.name): a hung bootstrap is running" + $pfr0 = @((Get-ItemProperty -Path $props -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations | Where-Object { $_ }) + if ($pfr0 -match 'StepSecurity') { Write-Host "pending renames before this repair (not ours to judge): $($pfr0 -match 'StepSecurity' -join '; ')" } + $log = "dist\dmg-repair-busy-$i.log" + $code = Msi $form.args $log + Check ($code -eq 0) "$($form.name): exit 0, not 3010/1641 ($code)" + Check (-not (Select-String -Path $log -Pattern 'is being held in use' -SimpleMatch -Quiet)) "$($form.name): no MSI file held in use" + Check (Select-String -Path $log -Pattern 'stop-for-msi' -SimpleMatch -Quiet) "$($form.name): stop-for-msi ran" + $pfr = @((Get-ItemProperty -Path $props -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations | Where-Object { $_ }) + $new = @($pfr | Where-Object { $_ -match 'StepSecurity' -and $pfr0 -notcontains $_ }) + Check ($new.Count -eq 0) "$($form.name): the repair scheduled no file renames for our files ($($new -join '; '))" + Check ((Get-CimInstance Win32_OperatingSystem).LastBootUpTime -eq $boot0) "$($form.name): no reboot (boot time unchanged)" + $bp.Refresh() + Check $bp.HasExited "$($form.name): the hung bootstrap was stopped" + Check ((State).current -eq $ver) "$($form.name): current kept ($((State).current))" + AssertTasks } + Done - if ($checks -contains $false) { - Write-Host "::error::One or more install verifications failed" - exit 1 + - name: SCCM-style repair keeps current, restores tasks, leaves AUTOUPDATE alone (M-7) + timeout-minutes: 8 + shell: pwsh + run: | + . "$env:RUNNER_TEMP\dmg-assert.ps1" + $ver = $env:MSI_VERSION + + # Simulate a self-updated device: a newer versions\ dir as current. + Copy-Item -Recurse "$root\versions\$ver" "$root\versions\99.0.0" + $s = State + $s.current = "99.0.0"; $s.previous = $ver + $s | ConvertTo-Json -Depth 10 | Set-Content -Path "$root\state\state.json" -Encoding utf8NoBOM + # And a deleted scan task, which the repair must restore. + schtasks /delete /tn $tasks[0] /f | Out-Null + + # Exactly what SCCM issues for a repair. + $code = Msi @("/fa", $env:INSTALLED_MSI, "/qn") "dist\dmg-repair.log" + Check ($code -eq 0) "msiexec /fa exit 0 ($code)" + $s = State + Check ($s.current -eq "99.0.0" -and $s.previous -eq $ver) "repair kept current 99.0.0 (got $($s.current)/$($s.previous))" + Check ((AutoUpdate) -eq 'false') "repair left auto_update false (got $(AutoUpdate))" + AssertTasks + Done + + - name: Major upgrade N to N+1 keeps tasks, never downgrades, leaves AUTOUPDATE alone + timeout-minutes: 8 + shell: pwsh + run: | + . "$env:RUNNER_TEMP\dmg-assert.ps1" + $next = $env:MSI_NEXT_VERSION + # SCCM e2e B1: an upgrade with a BOOTSTRAPFILE (no auto_update in it) + # merges into the machine config: the key rotates, the opt-out stays. + $bf = "$env:RUNNER_TEMP\dmg-bootstrap.json" + '{"customer_id":"ci-smoke","api_endpoint":"https://api.invalid.example","api_key":"ci-test-rotated-key"}' | Set-Content -Path $bf -Encoding ascii + $code = Msi @("/i", "dist\dmg-smoke-next.msi", "/qn", "BOOTSTRAPFILE=$bf") "dist\dmg-upgrade.log" + Check ($code -eq 0) "upgrade msiexec exit 0 ($code)" + $cfg = Get-Content "$data\config.json" -Raw | ConvertFrom-Json + Check ($cfg.api_key -eq 'ci-test-rotated-key') "BOOTSTRAPFILE rotated api_key" + if ($code -eq 0) { + "INSTALLED_MSI=dist\dmg-smoke-next.msi" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 } - Write-Host "All install-time verifications passed." + # Unset AUTOUPDATE against a false baseline: must stay false (R-B-8). + Check ((AutoUpdate) -eq 'false') "upgrade without AUTOUPDATE left auto_update false (got $(AutoUpdate))" + Check (UsableDir "$root\seed\$next") "seed\$next installed" + Check (-not (Test-Path "$root\seed\$env:MSI_VERSION")) "old seed removed" + Check (UsableDir "$root\versions\$next") "versions\$next adopted" + $s = State + Check ($s.current -eq "99.0.0") "seed $next < current 99.0.0: no downgrade (got $($s.current))" + $av = (Get-ItemProperty -Path 'HKLM:\Software\StepSecurity' -Name AgentVersion).AgentVersion + Check ($av -eq $next) "AgentVersion = $next ($av)" + # Present before uninstall, so "RunningVersion removed" below can fail. + $rv = (Get-ItemProperty -Path 'HKLM:\Software\StepSecurity' -Name RunningVersion -ErrorAction SilentlyContinue).RunningVersion + Check ([bool]$rv) "RunningVersion present before uninstall ($rv)" + # The old product's uninstall CA is skipped on upgrade (M-6). + $ranUninstall = Select-String -Path "dist\dmg-upgrade.log" -Pattern 'Doing action: RunUninstallScheduledTask' -SimpleMatch -Quiet + Check (-not $ranUninstall) "old uninstall CA not run during the upgrade" + AssertTasks + Done + + - name: Re-enable self-update with AUTOUPDATE=1 (README command); bad values only warn + timeout-minutes: 8 + shell: pwsh + run: | + . "$env:RUNNER_TEMP\dmg-assert.ps1" + # The in-place repair the README documents: msiexec /f ignores + # properties, so it is /i with REINSTALL=ALL REINSTALLMODE=omus. + $code = Msi @("/i", $env:INSTALLED_MSI, "/qn", "REINSTALL=ALL", "REINSTALLMODE=omus", "AUTOUPDATE=1") "dist\dmg-reenable.log" + Check ($code -eq 0) "reinstall with AUTOUPDATE=1 exit 0 ($code)" + Check ((AutoUpdate) -eq 'true') "AUTOUPDATE=1 re-enabled auto_update (got $(AutoUpdate))" + Check ((State).current -eq "99.0.0") "reinstall kept current 99.0.0" + + # Any other value: logged as a warning in the MSI log, no failure, + # setting unchanged. + $code = Msi @("/i", $env:INSTALLED_MSI, "/qn", "REINSTALL=ALL", "REINSTALLMODE=omus", "AUTOUPDATE=maybe") "dist\dmg-badvalue.log" + Check ($code -eq 0) "reinstall with AUTOUPDATE=maybe exit 0 ($code)" + Check ((AutoUpdate) -eq 'true') "AUTOUPDATE=maybe left auto_update true (got $(AutoUpdate))" + $warned = Select-String -Path "dist\dmg-badvalue.log" -Pattern 'auto-update must be true or false' -SimpleMatch -Quiet + Check $warned "AUTOUPDATE=maybe: configure's rejection is in the MSI log" + Done - name: Uninstall MSI if: always() + timeout-minutes: 8 shell: pwsh run: | - if (Test-Path "dist\dmg-smoke.msi") { - $p = Start-Process -FilePath "msiexec.exe" -ArgumentList @( - "/x", "dist\dmg-smoke.msi", "/qn", - "/l*v", "dist\dmg-uninstall.log" - ) -Wait -PassThru - Write-Host "msiexec /x exit: $($p.ExitCode)" + . "$env:RUNNER_TEMP\dmg-assert.ps1" + $msi = $env:INSTALLED_MSI + if ($msi -and (Test-Path $msi)) { + $code = Msi @("/x", $msi, "/qn") "dist\dmg-uninstall.log" + Write-Host "msiexec /x $msi exit: $code" + if ($code -ne 0) { exit 1 } } - name: Verify uninstall cleanup shell: pwsh run: | - $task = "StepSecurity Dev Machine Guard" - - # All three shipped binaries should be gone. dmg-linux is 11-12 MB, so - # an uninstall that leaves it behind is the largest single piece of - # litter this package can produce. - foreach ($leftover in @( - "C:\Program Files\StepSecurity\stepsecurity-dev-machine-guard.exe", - "C:\Program Files\StepSecurity\stepsecurity-dev-machine-guard-task.exe", - "C:\Program Files\StepSecurity\dmg-linux" - )) { - if (Test-Path $leftover) { - Write-Host "::error::File still present after uninstall: $leftover" - exit 1 - } + . "$env:RUNNER_TEMP\dmg-assert.ps1" + foreach ($leftover in @($boot, "$root\seed", "$root\versions", "$root\state")) { + Check (-not (Test-Path $leftover)) "removed: $leftover" } - Write-Host "[OK] Agent + launcher + linux agent removed" - - # Scheduled task should be gone. Capture the exit code into a - # local — PowerShell uses $LASTEXITCODE from the last native call - # as the script's exit code, so we MUST clear it (via 'exit 0' - # below) once we've made our decision, otherwise the step is - # marked failed despite all checks passing. - schtasks /query /tn $task 2>&1 | Out-Null - $schtasksExit = $LASTEXITCODE - if ($schtasksExit -eq 0) { - Write-Host "::error::Scheduled task still registered after uninstall" - exit 1 + foreach ($t in $tasks) { + schtasks /query /tn $t 2>&1 | Out-Null + Check ($LASTEXITCODE -ne 0) "task removed: $t" } - Write-Host "[OK] Scheduled task removed" + $reg = Get-ItemProperty -Path 'HKLM:\Software\StepSecurity' -ErrorAction SilentlyContinue + Check (-not $reg.RunningVersion) "RunningVersion removed" + Check (-not $reg.AgentVersion) "AgentVersion removed" + $procs = Get-Process | Where-Object { $_.Path -and $_.Path.StartsWith("$root\", [StringComparison]::OrdinalIgnoreCase) } + Check (-not $procs) "no DMG processes left running" # Note: C:\ProgramData\StepSecurity\config.json intentionally persists - # across uninstall — it's tenant config, not MSI-managed payload. This - # mirrors the documented behavior in docs/deploying-via-sccm.md. + # across uninstall — it's tenant config, not MSI-managed payload (M-8 + # is out of scope). This mirrors docs/deploying-via-sccm.md. + Done - exit 0 + - name: Fresh install with AUTOUPDATE unset defaults to on + timeout-minutes: 8 + shell: pwsh + run: | + . "$env:RUNNER_TEMP\dmg-assert.ps1" + # ProgramData survives uninstall; start from no machine config. + Remove-Item "$data\config.json" -Force -ErrorAction SilentlyContinue + $code = Msi @("/i", "dist\dmg-smoke.msi", "/qn", "CUSTOMERID=ci-smoke", "APIENDPOINT=https://api.invalid.example", "APIKEY=ci-test-fake-api-key") "dist\dmg-fresh.log" + Check ($code -eq 0) "fresh install exit 0 ($code)" + Check (Test-Path "$data\config.json") "config written" + Check ((AutoUpdate) -ne 'false') "unset AUTOUPDATE: auto_update not written false (got '$(AutoUpdate)', missing = on)" + $code = Msi @("/x", "dist\dmg-smoke.msi", "/qn") "dist\dmg-fresh-uninstall.log" + Check ($code -eq 0) "uninstall exit 0 ($code)" + Done - name: Upload install logs on failure - if: failure() + if: failure() || cancelled() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: msi-smoke-logs path: | dist/dmg-install.log + C:/Program Files/StepSecurity/state/update.log + dist/dmg-repair.log + dist/dmg-reenable.log + dist/dmg-badvalue.log + dist/dmg-fresh.log + dist/dmg-upgrade.log + dist/dmg-upgrade-fail.log + dist/dmg-repair-busy-*.log dist/dmg-uninstall.log if-no-files-found: ignore diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 89016ff3..4045d0a8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -112,6 +112,28 @@ jobs: with: go-version-file: go.mod - run: make test + - name: Test self-update fault injection (selfupdate_faults tag) + run: go test -race -count=1 -tags selfupdate_faults,selfupdate_testkeys ./internal/faultinject/ ./internal/updatestate/ ./internal/selfupdate/ ./internal/authenticode/ ./internal/telemetry/ + - name: Test bootstrap fault checkpoints (selfupdate_faults tag) + run: go test -race -count=1 -tags selfupdate_faults ./internal/launcher/ + + # Self-update U0 harness on Linux: the trace-driven kill matrix and the + # state corruption matrix (portable parts; Windows runs them natively in + # test-windows-native). + test-selfupdate-linux: + name: Test (self-update kill matrix, linux) + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version-file: go.mod + - run: go test -race -count=1 -tags selfupdate_faults,selfupdate_testkeys ./internal/faultinject/ ./internal/updatestate/ ./internal/selfupdate/ # These packages have Windows-only code and Windows-specific behavior whose # tests the macOS test job can only cross-compile, not run: @@ -126,6 +148,9 @@ jobs: # - executor: Readlink on a real directory junction (ModeIrregular entry, # NT-namespace target), which the skills detector's junction folding # depends on + # - updatestate: MoveFileEx state replace, LockFileEx, protected DACLs + # - selfupdate: native arch (IsWow64Process2), WinHTTP machine proxy + # - authenticode: WinVerifyTrust against a test-signed copy (elevated runner) # - detector TestReal_*: the Kiro CLI registry reader against a scratch # HKCU key, and skills junction folding over real junctions (the rest of # the detector suite assumes a POSIX host, hence the -run filter) @@ -144,10 +169,25 @@ jobs: - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: go.mod - - run: go test -race -count=1 ./internal/devicepolicy/ ./internal/secureuserfile/ ./internal/detector/credentials/ ./internal/safepath/ ./internal/executor/ + - run: go test -race -count=1 ./internal/devicepolicy/ ./internal/secureuserfile/ ./internal/detector/credentials/ ./internal/safepath/ ./internal/executor/ ./internal/updatestate/ ./internal/selfupdate/ ./internal/authenticode/ ./internal/awakeclock/ - run: go test -race -count=1 -run '^TestReal_' ./internal/detector/ - name: Test scheduled scan console suppression - run: go test -race -count=1 -run 'Test(IsTaskRegistered|DetectInvocationMethod_Windows|HideWindow)' ./internal/schtasks/ ./internal/telemetry/ ./internal/winproc/ + run: go test -race -count=1 -run 'Test(IsTaskRegistered|DetectInvocationMethod_Windows|HideWindow|StopUnderThenWaitReleased)' ./internal/schtasks/ ./internal/telemetry/ ./internal/winproc/ + # One command per step: the default pwsh shell only fails a step on the + # LAST native command's exit code, so a multi-line run would mask an + # earlier failure. + - name: Test self-update fault injection + U0 kill matrix (selfupdate_faults tag) + run: go test -race -count=1 -tags selfupdate_faults,selfupdate_testkeys ./internal/faultinject/ ./internal/updatestate/ ./internal/selfupdate/ ./internal/authenticode/ + - name: Test scan panic/hang run classification (selfupdate_faults tag) + run: go test -race -count=1 -tags selfupdate_faults,selfupdate_testkeys -run 'Test(ScanPanicIsABadRun|ScanHangAfterStartedIsABadRun)' ./internal/telemetry/ + - name: Authenticode against test-signed binaries (verbose, shows skips) + run: go test -count=1 -v -run 'TestVerify|TestForgedBag|TestTrustData|TestSameSeedPublisher' ./internal/authenticode/ + - name: Test launcher bootstrap mode + run: go test -race -count=1 ./internal/launcher/ + - name: Test launcher bootstrap fault checkpoints (selfupdate_faults tag) + run: go test -race -count=1 -tags selfupdate_faults ./internal/launcher/ + - name: Test self-update scheduled tasks + run: go test -race -count=1 -run 'Test(UpdateTask|ResolveBootstrapRoot|BuildCreateArgs_Bootstrap|InstallUpdateTask|Uninstall_|TerminateUnder|WaitForInstanceMutex|StopProcess)' ./internal/schtasks/ smoke: name: Smoke Tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 62e6c50b..4a768c9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ See [VERSIONING.md](VERSIONING.md) for why the version starts at 1.8.1. ### Added +- **Windows MSI installs update themselves.** The MSI now installs a stable bootstrap, a seed copy of the agent and a SYSTEM `StepSecurity Dev Machine Guard (Update)` task. The task follows the tenant's `latest-binary` target. It verifies each release's SSH-signed checksums, sha256 and Authenticode publisher (it must match the running agent), stages it as an immutable `versions\` directory, smoke-tests it as SYSTEM and switches only when no scan is running. A new version is on probation until a scan reaches the backend; two crashes roll it back and quarantine it on that device. Every outcome is reported as a `run-status` event with `status: "update"`. The MSI never downgrades a self-updated device, and repairs keep the current version. `AUTOUPDATE=0` opts out (the device still reports `update_disabled`). Devices on older MSIs need one final MSI push. See `docs/windows-self-update.md`, and `docs/windows-self-update-admin.md` for SCCM/Intune admins (network and proxy requirements, the safe repair command, verifying devices and troubleshooting by update event). - Passive PHP/Composer package inventory and configuration audit for developer machines. Collects project and global declarations, lockfiles and installed receipts, with redacted settings and configuration findings. PHP, Composer, plugins and scripts are never executed. - GitHub Copilot plugin inventory for the CLI, standalone app and VS Code. Collects recorded installations and live directory-marketplace selections, supplied skills, custom agents, command/rule/hook/LSP declarations, and sanitized plugin/user/project MCP definitions. Collection is file-only; uncertain formats report incomplete coverage. Runtime activation is not inferred. No Copilot usage or credential collection is added. diff --git a/cmd/stepsecurity-dev-machine-guard-task/main.go b/cmd/stepsecurity-dev-machine-guard-task/main.go index 70dd10a2..17d3f7c5 100644 --- a/cmd/stepsecurity-dev-machine-guard-task/main.go +++ b/cmd/stepsecurity-dev-machine-guard-task/main.go @@ -25,6 +25,16 @@ // `powershell.exe -File loader.ps1 send-telemetry` in the same // no-console envelope the MSI flow uses for the agent. // +// - Bootstrap mode. Invoked under the file name +// stepsecurity-dev-machine-guard-bootstrap.exe (the MSI installs a +// second copy of this launcher under that name), the verbs run / +// update / adopt-seed resolve the self-update state.json pointer and +// launch the versioned binaries. See internal/launcher/bootstrap.go. +// +// When launched by the bootstrap (STEPSEC_BOOTSTRAP_VERSION set), failing to +// resolve or start the agent exits 70 so the run counts as bad (D5); other +// invocations keep their legacy exit codes. +// // The agent (and any --exec target) stays console-subsystem so // interactive CLI use continues to work normally. // @@ -40,14 +50,13 @@ import ( "os" "os/exec" "syscall" - "unsafe" + "github.com/step-security/dev-machine-guard/internal/faultinject" "github.com/step-security/dev-machine-guard/internal/launcher" + "github.com/step-security/dev-machine-guard/internal/winproc" "golang.org/x/sys/windows" ) -const createNoWindow uint32 = 0x08000000 - func main() { os.Exit(run(os.Args[1:])) } @@ -56,6 +65,11 @@ func main() { // after the program name (os.Args[1:] in main); accepting it explicitly // keeps the windows-only test path open if we add one later. func run(argv []string) int { + if me, err := os.Executable(); err == nil && launcher.IsBootstrapBinary(me) { + return launcher.NewBootstrap(me, winproc.IsLocalSystem).Main(argv) + } + underBootstrap := os.Getenv(launcher.EnvBootstrapVersion) != "" + target, childArgs, err := launcher.ResolveTarget(argv) if err != nil { // Two distinct failure shapes, matched to the legacy contract: @@ -76,16 +90,22 @@ func run(argv []string) int { fmt.Fprintln(os.Stderr, err) return 2 } + if underBootstrap { + return launcher.ExitCannotStart + } return 1 } cmd := exec.Command(target, childArgs...) cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, - CreationFlags: createNoWindow, + CreationFlags: launcher.CreateNoWindow, } if err := cmd.Start(); err != nil { + if underBootstrap { + return launcher.ExitCannotStart + } return 1 } @@ -94,17 +114,18 @@ func run(argv []string) int { // exit, which fires the kill. Failure here only weakens lifecycle // (orphan possible on forced termination), not the work the child // was started to do. - if job, jerr := newKillOnCloseJob(); jerr == nil { - if h, oerr := windows.OpenProcess( - windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, - false, - uint32(cmd.Process.Pid), - ); oerr == nil { - _ = windows.AssignProcessToJobObject(job, h) - _ = windows.CloseHandle(h) + if job, jerr := launcher.NewKillOnCloseJob(); jerr == nil { + if aerr := launcher.AssignToJob(job, cmd.Process.Pid); aerr != nil { + _ = windows.CloseHandle(job) } } + // Lets a test build crash a version's scans after launch (harness + // baked fault boot.launched@user:exit2); a no-op in release builds. + if underBootstrap { + _ = faultinject.Point("boot.launched") + } + if err := cmd.Wait(); err != nil { var ee *exec.ExitError if errors.As(err, &ee) { @@ -114,27 +135,3 @@ func run(argv []string) int { } return 0 } - -func newKillOnCloseJob() (windows.Handle, error) { - job, err := windows.CreateJobObject(nil, nil) - if err != nil { - return 0, err - } - info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{ - BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{ - // BREAKAWAY_OK lets the agent's CREATE_BREAKAWAY_FROM_JOB spawns - // (WSL guest scans) outlive this run instead of being killed. - LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | windows.JOB_OBJECT_LIMIT_BREAKAWAY_OK, - }, - } - if _, err := windows.SetInformationJobObject( - job, - windows.JobObjectExtendedLimitInformation, - uintptr(unsafe.Pointer(&info)), - uint32(unsafe.Sizeof(info)), - ); err != nil { - _ = windows.CloseHandle(job) - return 0, err - } - return job, nil -} diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index 0bab30b0..970a1bc8 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -74,6 +74,10 @@ func main() { os.Exit(aiagentscli.RunHook(os.Stdin, os.Stdout, os.Stderr, os.Args[2:])) } + // agent.prestart fault checkpoint and the Windows self-update verbs + // (self-update --system, self-check); they exit here. + runEarlyVerbs(os.Args[1:]) + // Load persisted config (~/.stepsecurity/config.json) before parsing CLI // --config must be honoured before Load(), which runs ahead of flag // parsing and keeps the first values it reads. @@ -238,6 +242,7 @@ func main() { APIEndpoint: cfg.ConfigAPIEndpoint, APIKey: cfg.ConfigAPIKey, ScanFrequency: cfg.ConfigScanFrequency, + AutoUpdate: cfg.ConfigAutoUpdate, } if opts.APIKey == "" { // Env-var fallback keeps the secret off the msiexec command @@ -274,6 +279,7 @@ func main() { log.Error("Enterprise configuration not found. Run '%s configure' or download the script from your StepSecurity dashboard.", os.Args[0]) os.Exit(1) } + reportBootstrapFallback() // Server-driven run gate: exit 0 quietly when the backend says this // invocation isn't due (or another instance is mid-scan). Sits before // the watchdog and telemetry.Run so a skipped wakeup posts no beacon, @@ -294,7 +300,7 @@ func main() { if telemetryErr != nil { restoreTarget() log.Error("%v", telemetryErr) - os.Exit(1) + os.Exit(telemetry.ExitCode(telemetryErr)) } runHookStateReconcile(exec, log, cfg) if targetOK { @@ -417,7 +423,7 @@ func main() { } else { restoreTarget() log.Error("%v", telemetryErr) - os.Exit(1) + os.Exit(telemetry.ExitCode(telemetryErr)) } } runHookStateReconcile(exec, log, cfg) @@ -546,7 +552,7 @@ func main() { restoreTarget() if telemetryErr != nil { log.Error("%v", telemetryErr) - os.Exit(1) + os.Exit(telemetry.ExitCode(telemetryErr)) } default: log.Debug("dispatch: community scan (default)") diff --git a/cmd/stepsecurity-dev-machine-guard/selfupdate.go b/cmd/stepsecurity-dev-machine-guard/selfupdate.go new file mode 100644 index 00000000..bcb7c746 --- /dev/null +++ b/cmd/stepsecurity-dev-machine-guard/selfupdate.go @@ -0,0 +1,290 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/step-security/dev-machine-guard/internal/authenticode" + "github.com/step-security/dev-machine-guard/internal/buildinfo" + "github.com/step-security/dev-machine-guard/internal/config" + "github.com/step-security/dev-machine-guard/internal/device" + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/faultinject" + "github.com/step-security/dev-machine-guard/internal/lock" + "github.com/step-security/dev-machine-guard/internal/model" + "github.com/step-security/dev-machine-guard/internal/selfupdate" + "github.com/step-security/dev-machine-guard/internal/telemetry" + "github.com/step-security/dev-machine-guard/internal/updatestate" + "github.com/step-security/dev-machine-guard/internal/winproc" +) + +const ( + installChannelMSI = "msi" + deviceProbeTimeout = 30 * time.Second + updateLogMaxBytes = 1 << 20 + selfCheckExitFailed = 1 +) + +// runEarlyVerbs handles the verbs that must not go through the scan CLI: +// the agent.prestart fault checkpoint (spec Contracts, harness H0) and the +// self-update verbs, which exit here. +func runEarlyVerbs(args []string) { + if err := faultinject.Point("agent.prestart"); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(3) + } + if len(args) == 0 { + return + } + switch args[0] { + case "self-update": + os.Exit(runSelfUpdate(args[1:])) + case "self-check": + os.Exit(runSelfCheck()) + } +} + +// resolveDeviceID is the scan's device id (user context), for events the +// scanning agent sends. SYSTEM paths use device.SystemID instead. +func resolveDeviceID(ctx context.Context) string { + ctx, cancel := context.WithTimeout(ctx, deviceProbeTimeout) + defer cancel() + return device.ID(model.Device{SerialNumber: device.SerialNumber(ctx, executor.NewReal())}) +} + +// selfCheckDeviceID resolves the self-check's device id. As SYSTEM (the +// updater's S5 smoke) it goes through device.SystemID like every other +// SYSTEM resolution in self-update, so it never writes the shared +// device-id file from a fallback; elsewhere it is the scan's resolution. +func selfCheckDeviceID(ctx context.Context, exec executor.Executor, system bool, cachePath string) string { + if system { + // Short probe: the smoke test's limit must exceed this (asserted + // in selfupdate); a wedged WMI falls through to the SYSTEM cache. + return device.SystemID(ctx, exec, cachePath, selfupdate.SelfCheckProbeTimeout) + } + ctx, cancel := context.WithTimeout(ctx, deviceProbeTimeout) + defer cancel() + return device.ID(model.Device{SerialNumber: device.SerialNumber(ctx, exec)}) +} + +// runSelfCheck is `agent self-check`: the S5 smoke test run by the updater +// against a staged version. It always exits 0 once it can print. +func runSelfCheck() int { + config.Load() + sc := selfupdate.SelfCheck{ + Version: buildinfo.Version, + ConfigOK: config.LoadError() == nil && config.IsEnterpriseMode(), + } + system := runtime.GOOS == model.PlatformWindows && winproc.IsLocalSystem() + cachePath := "" + if system { + if l, err := ownLayout(); err == nil { + cachePath = filepath.Join(l.StateDir(), "device-id") + } + } + sc.DeviceID = selfCheckDeviceID(context.Background(), executor.NewReal(), system, cachePath) + if err := json.NewEncoder(os.Stdout).Encode(sc); err != nil { + return selfCheckExitFailed + } + return 0 +} + +// runSelfUpdate is `agent self-update --system`, run by the SYSTEM update +// task through the bootstrap. +func runSelfUpdate(args []string) int { + if len(args) != 1 || args[0] != "--system" { + fmt.Fprintln(os.Stderr, "usage: self-update --system") + return 2 + } + if runtime.GOOS != model.PlatformWindows { + fmt.Fprintln(os.Stderr, "self-update --system is only supported on Windows MSI installs") + return 2 + } + selfExe, err := os.Executable() + if err != nil { + fmt.Fprintf(os.Stderr, "self-update: own path: %v\n", err) + return selfupdate.ExitFailed + } + // The root comes from our own path (any INSTALLFOLDER), by the + // bootstrap's rule; an untrusted root is refused, never guessed. + layout, err := updatestate.ResolveLayout(selfExe) + if err != nil { + fmt.Fprintf(os.Stderr, "self-update: %s: %v\n", selfupdate.EventStateCorrupt, err) + return selfupdate.ExitFailed + } + logf := newUpdateLog(layout.LogFile()) + + config.Load() + if err := config.LoadError(); err != nil { + logf("self-update: config: %v", err) + } + if !config.IsEnterpriseMode() { + logf("self-update: no enterprise configuration; nothing to do") + return selfupdate.ExitFailed + } + self, err := updatestate.ParseVersion(buildinfo.Version) + if err != nil { + logf("self-update: own version %q is not x.y.z", buildinfo.Version) + return selfupdate.ExitFailed + } + httpClient := selfupdate.NewHTTPClient() + // The SYSTEM chain never reads the user-writable device-id file. + deviceID := sync.OnceValue(func() string { + return device.SystemID(context.Background(), executor.NewReal(), filepath.Join(layout.StateDir(), "device-id"), device.SystemProbeTimeout) + }) + exec := executor.NewReal() + machineConfig := config.ConfigFilePath() + + deps := &selfupdate.Deps{ + Layout: layout, + Self: self, + SelfExe: selfExe, + Client: &selfupdate.Client{ + HTTP: httpClient, APIEndpoint: config.APIEndpoint, CustomerID: config.CustomerID, + APIKey: config.APIKey, AgentVersion: buildinfo.Version, Arch: selfupdate.NativeArch(), + LagBehind: config.UpdateLagBehind, CooldownHours: config.UpdateCooldownHours, + MirrorURL: config.UpdateMirrorURL, + }, + AutoUpdate: config.AutoUpdate == nil || *config.AutoUpdate, + VerifyPublisher: func(p string) error { return authenticode.SamePublisher(p, selfExe) }, + VerifySeed: func(p string) error { return authenticode.SameSeedPublisher(p, selfExe) }, + InstallerBusy: selfupdate.InstallerBusy, + Smoke: selfupdate.RunSmoke, + AcquireInstance: func() (func(), error) { + lk, err := lock.Acquire(exec) + if err != nil { + return nil, err + } + return lk.Release, nil + }, + EnsureACLs: func() ([]string, error) { + var drifted []string + var errs []error + for _, d := range []string{layout.VersionsDir(), layout.StateDir()} { + fixed, err := updatestate.EnsureProtectedDir(d) + if fixed { + drifted = append(drifted, d) + } + errs = append(errs, err) + } + fixed, err := selfupdate.EnsureConfigACL(machineConfig) + if fixed { + drifted = append(drifted, machineConfig) + } + errs = append(errs, err) + return drifted, errors.Join(errs...) + }, + RefreshDeviceID: func(context.Context) { _ = deviceID() }, + Send: func(ctx context.Context, ev updatestate.OutboxEvent) error { + err := telemetry.PostUpdateEvent(ctx, httpClient, telemetry.UpdateEvent{ + ExecutionID: ev.ExecutionID, DeviceID: deviceID(), InstallChannel: installChannelMSI, + Event: ev.Event, From: ev.From, To: ev.To, Error: ev.Error, + }) + if errors.Is(err, telemetry.ErrUpdateEventRejected) { + return fmt.Errorf("%w: %w", selfupdate.ErrEventRejected, err) + } + return err + }, + SetRunningVersion: func(v string) { + if err := selfupdate.SetRunningVersion(v); err != nil { + logf("self-update: RunningVersion: %v", err) + } + }, + FreeBytes: selfupdate.FreeBytes, + Now: time.Now, + Logf: logf, + } + return selfupdate.Run(context.Background(), deps) +} + +// newUpdateLog appends timestamped lines to state\update.log (kept under +// 1 MiB, one rotated copy) and stderr. +func newUpdateLog(path string) func(string, ...any) { + var mu sync.Mutex + return func(format string, args ...any) { + line := fmt.Sprintf("%s %s\n", time.Now().UTC().Format(time.RFC3339), fmt.Sprintf(format, args...)) + mu.Lock() + defer mu.Unlock() + _, _ = io.WriteString(os.Stderr, line) + if fi, err := os.Stat(path); err == nil && fi.Size() > updateLogMaxBytes { + _ = os.Rename(path, path+".1") + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { // #nosec G301 -- state\ ACL is set by the updater + return + } + // #nosec G302 G304 -- fixed path inside the protected state directory. + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return + } + _, _ = f.WriteString(line) + _ = f.Close() + } +} + +// parseBootstrapFallback reads STEPSEC_BOOTSTRAP_FALLBACK=[:] +// as dmg-boot writes it: the reason is the part before the first ':', and +// the suffix names the version that was missing or failed to start. Spec +// §7: update_from = the version state.json named (the suffix, else named, +// state.json's current); update_to = the version actually run (ran, from +// STEPSEC_BOOTSTRAP_VERSION, else self). +func parseBootstrapFallback(env, named, ran, self string) (reason, from, to string) { + reason, suffix, _ := strings.Cut(env, ":") + from = named + if v, _, _ := strings.Cut(suffix, ":"); v != "" { + if pv, err := updatestate.ParseVersion(v); err == nil { + from = pv.String() + } + } + to = self + if pv, err := updatestate.ParseVersion(ran); err == nil { + to = pv.String() + } + return reason, from, to +} + +// reportBootstrapFallback sends update_pointer_invalid when the bootstrap +// ran a fallback version (STEPSEC_BOOTSTRAP_FALLBACK=[:]). +// Best-effort; the bootstrap sets it on every fallback run until the +// updater repairs the pointer. +func reportBootstrapFallback() { + env := os.Getenv(telemetry.EnvBootstrapFallback) + if env == "" || runtime.GOOS != model.PlatformWindows { + return + } + named := "" + if l, err := ownLayout(); err == nil { + if st, _, err := updatestate.Load(l.StateFile()); err == nil && st != nil { + named = st.Current + } + } + reason, from, to := parseBootstrapFallback(env, named, os.Getenv(telemetry.EnvBootstrapVersion), buildinfo.Version) + ctx, cancel := context.WithTimeout(context.Background(), deviceProbeTimeout) + defer cancel() + err := telemetry.PostUpdateEvent(ctx, nil, telemetry.UpdateEvent{ + ExecutionID: uuid.NewString(), DeviceID: resolveDeviceID(ctx), InstallChannel: installChannelMSI, + Event: selfupdate.EventPointerInvalid, From: from, To: to, Error: reason, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "[warn] update_pointer_invalid not sent: %v\n", err) + } +} + +// ownLayout is the MSI layout of this binary (updatestate.ResolveLayout). +func ownLayout() (updatestate.Layout, error) { + exe, err := os.Executable() + if err != nil { + return updatestate.Layout{}, err + } + return updatestate.ResolveLayout(exe) +} diff --git a/cmd/stepsecurity-dev-machine-guard/selfupdate_test.go b/cmd/stepsecurity-dev-machine-guard/selfupdate_test.go new file mode 100644 index 00000000..dcb7b272 --- /dev/null +++ b/cmd/stepsecurity-dev-machine-guard/selfupdate_test.go @@ -0,0 +1,155 @@ +package main + +import ( + "context" + "errors" + "github.com/step-security/dev-machine-guard/internal/device" + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/launcher" + "github.com/step-security/dev-machine-guard/internal/telemetry" + "github.com/step-security/dev-machine-guard/internal/updatestate" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func TestRunSelfUpdateArgs(t *testing.T) { + if code := runSelfUpdate(nil); code != 2 { + t.Fatalf("no --system: %d", code) + } + if code := runSelfUpdate([]string{"--user"}); code != 2 { + t.Fatalf("unknown flag: %d", code) + } + if runtime.GOOS != "windows" { + if code := runSelfUpdate([]string{"--system"}); code != 2 { + t.Fatalf("non-Windows --system: %d", code) + } + } +} + +func TestUpdateLogRotates(t *testing.T) { + p := filepath.Join(t.TempDir(), "state", "update.log") + logf := newUpdateLog(p) + logf("hello %d", 1) + b, err := os.ReadFile(p) + if err != nil || !strings.Contains(string(b), "hello 1") { + t.Fatalf("log = %q, %v", b, err) + } + if err := os.WriteFile(p, make([]byte, updateLogMaxBytes+1), 0o644); err != nil { + t.Fatal(err) + } + logf("after") + if _, err := os.Stat(p + ".1"); err != nil { + t.Fatalf("not rotated: %v", err) + } +} + +// STEPSEC_BOOTSTRAP_FALLBACK=[:] (dmg-boot): reason is +// the part before the first ':'; the suffix names the version that was +// missing or failed (update_from; else state.json's current); update_to is +// the version actually run (STEPSEC_BOOTSTRAP_VERSION, else this binary). +func TestParseBootstrapFallback(t *testing.T) { + for _, tc := range []struct{ env, named, ran, self, reason, from, to string }{ + {"current_missing:1.20.0", "1.20.0", "1.19.0", "1.19.0", "current_missing", "1.20.0", "1.19.0"}, + {"start_failed:1.20.0", "1.20.0", "1.18.0", "1.18.0", "start_failed", "1.20.0", "1.18.0"}, + {"state_corrupt", "", "1.18.0", "1.18.0", "state_corrupt", "", "1.18.0"}, + {"state_missing", "1.20.0", "", "1.18.0", "state_missing", "1.20.0", "1.18.0"}, + {"bad:not-a-version", "1.20.0", "x", "1.18.0", "bad", "1.20.0", "1.18.0"}, + } { + reason, from, to := parseBootstrapFallback(tc.env, tc.named, tc.ran, tc.self) + if reason != tc.reason || from != tc.from || to != tc.to { + t.Errorf("%q: got (%q,%q,%q), want (%q,%q,%q)", tc.env, reason, from, to, tc.reason, tc.from, tc.to) + } + } +} + +// B4(d) end to end: the real bootstrap `run` falls back and the env it hands +// the agent parses into the event the spec describes. +func TestBootstrapFallbackEndToEnd(t *testing.T) { + for _, tc := range []struct { + name string + installed bool // versions\1.20.0 present + failStart bool // ...but it fails to start + reason string + }{ + {"current dir missing", false, false, "current_missing"}, + {"current fails to start", true, true, "start_failed"}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + l := updatestate.Layout{InstallRoot: filepath.Join(root, "pf"), DataRoot: filepath.Join(root, "pd")} + writeVer := func(dir string) { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for _, f := range updatestate.VersionFiles { + if err := os.WriteFile(filepath.Join(dir, f), []byte(f), 0o755); err != nil { + t.Fatal(err) + } + } + } + cur, _ := updatestate.ParseVersion("1.20.0") + seed, _ := updatestate.ParseVersion("1.19.0") + writeVer(l.SeedDir(seed)) + if tc.installed { + writeVer(l.VersionDir(cur)) + } + if err := os.MkdirAll(l.StateDir(), 0o755); err != nil { + t.Fatal(err) + } + if err := updatestate.Save(l.StateFile(), &updatestate.State{Schema: 1, Current: "1.20.0"}); err != nil { + t.Fatal(err) + } + var events [][3]string + b := launcher.NewBootstrap(filepath.Join(l.InstallRoot, "stepsecurity-dev-machine-guard-bootstrap.exe"), func() bool { return false }) + b.Layout = l + b.Out = io.Discard + b.Spawn = func(path string, _, env []string, _ time.Duration, onStart func()) launcher.SpawnResult { + if tc.failStart && strings.Contains(path, "1.20.0") { + return launcher.SpawnResult{Err: errors.New("boom")} + } + get := func(k string) string { + for _, kv := range env { + if v, ok := strings.CutPrefix(kv, k+"="); ok { + return v + } + } + return "" + } + onStart() + // The agent side: state.json's current, then the env. + st, _, _ := updatestate.Load(l.StateFile()) + reason, from, to := parseBootstrapFallback(get(telemetry.EnvBootstrapFallback), st.Current, get(telemetry.EnvBootstrapVersion), "9.9.9") + events = append(events, [3]string{reason, from, to}) + return launcher.SpawnResult{Started: true} + } + if code := b.Run([]string{"send-telemetry"}); code != 0 { + t.Fatalf("bootstrap run = %d", code) + } + if len(events) != 1 || events[0] != [3]string{tc.reason, "1.20.0", "1.19.0"} { + t.Fatalf("agent saw %v, want [%s 1.20.0 1.19.0]", events, tc.reason) + } + }) + } +} + +// D23/R-C2-13: the SYSTEM self-check resolves its id like the updater +// (device.SystemID): with no shared device-id file and a failed hardware +// probe it must not write one (the old root path minted a UUID or +// MachineGuid there, flipping user scans' identity). +func TestSystemSelfCheckNeverWritesSharedID(t *testing.T) { + device.ResetIDCache() + t.Cleanup(device.ResetIDCache) + m := executor.NewMock() + m.SetGOOS("windows") + m.SetIsRoot(true) // SYSTEM; no wmic/powershell answers: the probe fails + cache := filepath.Join(t.TempDir(), "state", "device-id") + _ = selfCheckDeviceID(context.Background(), m, true, cache) + if b, err := m.ReadFile(`C:\ProgramData\StepSecurity\device-id`); err == nil && len(b) > 0 { + t.Fatalf("SYSTEM self-check wrote the shared device-id: %q", b) + } +} diff --git a/cmd/stepsecurity-dev-machine-guard/watchdog_windows.go b/cmd/stepsecurity-dev-machine-guard/watchdog_windows.go index 5d1b49ca..212b7cc4 100644 --- a/cmd/stepsecurity-dev-machine-guard/watchdog_windows.go +++ b/cmd/stepsecurity-dev-machine-guard/watchdog_windows.go @@ -7,6 +7,7 @@ import ( "os" "time" + "github.com/step-security/dev-machine-guard/internal/awakeclock" "github.com/step-security/dev-machine-guard/internal/progress" "github.com/step-security/dev-machine-guard/internal/telemetry" ) @@ -23,10 +24,13 @@ func armExecutionWatchdog(d time.Duration, log *progress.Logger) { if d <= 0 { return } + start := awakeclock.Begin(awakeclock.System) go func() { - time.Sleep(d) + // Awake time: Go's monotonic clock on Windows includes sleep, so a + // laptop closed mid-scan would otherwise trip this on resume. + start.Wait(d, nil, time.Minute) log.Warn("execution-watchdog: max duration %s exceeded — exiting", d) telemetry.ReportFatal(fmt.Sprintf("execution-watchdog: max duration %s exceeded", d), 10*time.Second) - os.Exit(2) + os.Exit(2) // a real hang: d of awake time (sleep excluded) }() } diff --git a/docs/deploying-via-sccm.md b/docs/deploying-via-sccm.md index f076ed75..8e3f3f08 100644 --- a/docs/deploying-via-sccm.md +++ b/docs/deploying-via-sccm.md @@ -22,7 +22,7 @@ network calls via EDR. Our MSI install/upgrade/uninstall flows **never spawn PowerShell**. The chain is: ``` -SCCM → msiexec.exe → stepsecurity-dev-machine-guard.exe → schtasks.exe +SCCM → msiexec.exe → stepsecurity-dev-machine-guard[-bootstrap].exe → schtasks.exe ``` Custom actions run inside `msiexec`'s process tree as **deferred** actions @@ -47,7 +47,7 @@ install path touches `powershell.exe`. Use this in the SCCM Application's **Installation program** field: ```cmd -msiexec /i "stepsecurity-dev-machine-guard--x64.msi" /qn ^ +msiexec /i "stepsecurity-dev-machine-guard--x64.msi" /qn /norestart ^ CUSTOMERID="acme-corp" ^ APIENDPOINT="https://agent.api.stepsecurity.io" ^ APIKEY="sk_live_xxxxxxxxxxxxxxxx" ^ @@ -87,7 +87,7 @@ Contents: that path: ```cmd -msiexec /i "stepsecurity-dev-machine-guard--x64.msi" /qn ^ +msiexec /i "stepsecurity-dev-machine-guard--x64.msi" /qn /norestart ^ BOOTSTRAPFILE="C:\ProgramData\StepSecurity\bootstrap.json" ^ /l*v "C:\Windows\Temp\dmg-install.log" ``` @@ -97,6 +97,12 @@ of `AppEnforce.log` even with verbose logging enabled. The bootstrap file can be ACL-restricted to SYSTEM + Administrators if you want defense-in-depth. +Keep the bootstrap file in place after the install: every later MSI +upgrade that passes `BOOTSTRAPFILE` reads it again, and fails (1603, +rolled back) if it's missing. Keys in the file override the device's +existing settings; settings not in the file (such as the self-update +opt-out) are kept. + ### A note on the persisted `config.json` and multi-user machines Either deployment path above writes the resolved config — including @@ -132,16 +138,21 @@ tenant API key. If that's not acceptable for your environment: 2. **Manually specify the application information**: - Name: `StepSecurity Dev Machine Guard` - Publisher: `StepSecurity` - - Software version: matches the MSI you're deploying (e.g. `1.11.2`) + - Software version: matches the MSI you're deploying (e.g. `1.18.0`) 3. **Add Deployment Type** → **Windows Installer (`*.msi`)** 4. **Content**: point at the `.msi` file on a share that the Distribution Points can pull from 5. **Programs** tab: - **Installation program**: see Option A or B above - - **Uninstall program**: SCCM auto-fills from the MSI `ProductCode`, - usually `msiexec /x {PRODUCT-CODE} /qn` + - **Uninstall program**: SCCM auto-fills from the MSI `ProductCode`; + use `msiexec /x {PRODUCT-CODE} /qn /norestart` + - **Repair program**: `msiexec /i "stepsecurity-dev-machine-guard--x64.msi" REINSTALL=ALL REINSTALLMODE=omus /qn /norestart` + (see [Repairs](#repairs)) 6. **Detection method**: SCCM offers to use the MSI's product code by - default — **accept it**. No custom script needed. + default — **accept it**. No custom script needed. A registry rule on + `HKLM\SOFTWARE\StepSecurity`, `AgentVersion` equals the MSI version, + works too. Never detect on `RunningVersion` or a file version: both + change when the device updates itself. 7. **User Experience** tab: - **Installation behavior**: Install for system - **Logon requirement**: Whether or not a user is logged on @@ -157,54 +168,97 @@ tenant API key. If that's not acceptable for your environment: After SCCM reports the install as complete, on a target endpoint: ```cmd -:: 1. The binary is on disk -dir "C:\Program Files\StepSecurity\stepsecurity-dev-machine-guard.exe" +:: 1. The bootstrap is on disk +dir "C:\Program Files\StepSecurity\stepsecurity-dev-machine-guard-bootstrap.exe" -:: 2. The scheduled task is registered +:: 2. The scheduled tasks are registered (scan, logon scan, updater) schtasks /query /tn "StepSecurity Dev Machine Guard" +schtasks /query /tn "StepSecurity Dev Machine Guard (Logon)" +schtasks /query /tn "StepSecurity Dev Machine Guard (Update)" :: 3. The config landed where the scanner can read it type "C:\ProgramData\StepSecurity\config.json" :: 4. (Optional) trigger an immediate scan to confirm end-to-end -"C:\Program Files\StepSecurity\stepsecurity-dev-machine-guard.exe" send-telemetry +schtasks /run /tn "StepSecurity Dev Machine Guard" ``` The configured tenant should see the endpoint in the StepSecurity dashboard within a few minutes. +## Self-update + +From 1.18.0, MSI installs **update themselves**: a SYSTEM scheduled task +downloads, verifies and switches to the version StepSecurity releases for +your tenant, without user prompts or reboots, and rolls back a version +that fails. Devices on an older MSI need one final push of the 1.18.0 (or +newer) MSI; after that you don't need a new Application per release. + +Network and proxy requirements, the `AUTOUPDATE=0` opt-out, how to verify +a device and what each update event in the console means are covered in +the [Windows self-update guide for IT admins](windows-self-update-admin.md). + +## Repairs + +Use this repair command (it's also what Software Center's **Repair** +runs when you set it as the deployment type's repair program): + +```cmd +msiexec /i "stepsecurity-dev-machine-guard--x64.msi" REINSTALL=ALL REINSTALLMODE=omus /qn /norestart +``` + +Don't use `msiexec /f...`: it ignores every command-line property, +including `/norestart` and `AUTOUPDATE`. MSIs up to 1.17.0 could restart +the machine without a prompt when a `/f` repair ran during a scan; from +1.18.0 the MSI suppresses restarts itself and stops Dev Machine Guard +before replacing files. A repair re-creates the scheduled tasks and never +moves a self-updated device back to the MSI's version. + ## Upgrades -When a new version ships, **create a new Application** in SCCM with the -new MSI and mark it as **superseding** the previous Application: +With self-update you only need a new MSI when you want one (for example +so that new devices start on a recent version). To roll one out, +**create a new Application** in SCCM with the new MSI and mark it as +**superseding** the previous Application: 1. New Application → **Supersedence** tab → **Add** → pick the old - Application -2. Choose **Uninstall** for the old app (the new MSI's MajorUpgrade will - do it atomically — but SCCM needs the supersedence link to track - which endpoints to push the upgrade to) -3. Deploy the new Application to the same collection + Application. Leave **Uninstall** cleared: the new MSI's major upgrade + replaces the old version itself and keeps the device's self-update + state. +2. Deploy the new Application to the same collection, with **Automatically + upgrade any superseded versions of this application** selected. On each endpoint: - SCCM pushes the new MSI on its next policy cycle (default 60 min) - Windows Installer recognizes the upgrade (same `UpgradeCode`, higher - `Version`) → atomically uninstalls the old version (removes the - scheduled task via our `uninstall` custom action), installs the new - one (re-registers the task with the new binary) + `Version`) and replaces the old version in one transaction. The old + version's uninstall action is skipped during an upgrade, and the new + version re-registers the scheduled tasks +- If the device already runs a newer self-updated version than the new + MSI, it keeps it; the MSI's version becomes the fallback - The per-tenant config at `C:\ProgramData\StepSecurity\config.json` is **preserved across upgrades** — tenant stays configured +- Once the new Application is deployed, SCCM reports the superseded + Application's deployment as **Not Applicable** on those devices + +An MSI never downgrades a device: an MSI older than the installed one, +deployed outside the supersedence chain, fails with 1603 ("A newer +version of StepSecurity Dev Machine Guard is already installed"). ## Uninstall SCCM uninstall fires the `msiexec /x {ProductCode}` command. Our custom -action runs **before** file removal and calls -`stepsecurity-dev-machine-guard.exe uninstall`, which removes the -scheduled task via `schtasks /delete`. Then MSI removes the .exe and -empties `C:\Program Files\StepSecurity\`. +action runs **before** file removal and calls the agent's `uninstall`, +which removes the three scheduled tasks via `schtasks /delete`, stops +running Dev Machine Guard processes, and deletes the self-update +`versions\` and `state\` folders and the `RunningVersion` registry value. +Then MSI removes the remaining files and empties +`C:\Program Files\StepSecurity\`. The config at `C:\ProgramData\StepSecurity\config.json` is **not -removed** by MSI (it lives outside the install scope). If you want a -clean uninstall: +removed** by MSI (it lives outside the install scope), so a reinstall +keeps the tenant configuration, the device identity and the self-update +opt-out. If you want a clean uninstall: ```cmd rmdir /s /q "C:\ProgramData\StepSecurity" @@ -216,7 +270,10 @@ rmdir /s /q "C:\ProgramData\StepSecurity" | Symptom | Likely cause | Where to look | |---------|-------------|---------------| -| MSI exit code 1603 | Custom action failed (bad creds, schtasks denied) | `C:\Windows\Temp\dmg-install.log` (msiexec verbose log) | +| MSI exit code 1603 | Custom action failed (bad creds, missing bootstrap file, schtasks denied) | `C:\Windows\Temp\dmg-install.log` (msiexec verbose log) | +| 1603 "A newer version … is already installed" | A newer DMG MSI is installed, or a failed SCCM install left a phantom "advertised" registration | [Phantom MSI registrations](windows-self-update-admin.md#1603-a-newer-version--is-already-installed-and-phantom-msi-registrations) | +| Exit code 3010 | A file was in use; replaced at the next restart (the MSI never restarts by itself) | Nothing to do; SCCM follows your client restart settings | +| Device not updating / update errors in the console | Network, proxy, opt-out | [Windows self-update guide](windows-self-update-admin.md#troubleshooting-by-console-event) | | Scheduled task missing | `install` custom action skipped or failed | Same log; search for `RunInstallScheduledTask` | | Endpoint not reporting to dashboard | Wrong API key / endpoint | `type C:\ProgramData\StepSecurity\config.json` | | Endpoint config still under `%USERPROFILE%\.stepsecurity\` | MSI ran without elevation (shouldn't happen via SCCM) | Verify SCCM Application is set to "Install for system" | @@ -225,8 +282,10 @@ When opening a support case, attach: ``` C:\Windows\Temp\dmg-install.log (msiexec verbose log) +C:\Windows\CCM\Logs\AppEnforce.log (SCCM command line and exit code) C:\ProgramData\StepSecurity\agent.log (scanner output) C:\ProgramData\StepSecurity\agent.error.log +C:\Program Files\StepSecurity\state\update.log (self-update log) ``` ## Signature verification @@ -245,9 +304,9 @@ cosign verify-blob \ stepsecurity-dev-machine-guard--x64.msi ``` -> **Authenticode (Windows publisher signature)**: not yet applied. The -> Sigstore bundle above is build-provenance signing, which is a different -> system from Authenticode (the certificate-based signature Windows checks -> in UAC / SmartScreen / WDAC). If your environment requires -> Authenticode-signed installers as a deployment precondition, please open -> an issue — it's a tracked roadmap item. +> **Authenticode (Windows publisher signature)**: the MSIs and the Windows +> `.exe`s are Authenticode-signed with an RFC 3161 timestamp, in addition +> to the Sigstore bundle above (build provenance, a separate system). The +> self-updater also requires every version it installs to carry a valid, +> timestamped Authenticode signature from the same publisher as the +> version already installed. diff --git a/docs/windows-self-update-admin.md b/docs/windows-self-update-admin.md new file mode 100644 index 00000000..f88ddceb --- /dev/null +++ b/docs/windows-self-update-admin.md @@ -0,0 +1,598 @@ +# Windows self-update: guide for IT admins (SCCM / Intune) + +This guide is for IT teams that deploy Dev Machine Guard to Windows +endpoints with **Microsoft Configuration Manager** (SCCM / MECM) or +**Intune**. It covers what changes with self-update, what the network +and endpoints need, how to set up the deployment, how to control and +verify updates, and how to troubleshoot. + +How the updater works internally (state files, verification steps, +probation rules) is described in +[`windows-self-update.md`](windows-self-update.md). The general SCCM +setup (credentials, `config.json` permissions, signature verification) +is in [`deploying-via-sccm.md`](deploying-via-sccm.md). + +## What changes in 1.18.0 + +- **One last MSI push.** Devices on an MSI older than 1.18.0 can't update + themselves. Deploy the 1.18.0 (or newer) MSI once, the same way you + deploy any upgrade. It replaces the older version in place and keeps + the tenant configuration and the device identity, so each device keeps + its existing record in the StepSecurity console. +- **Then the devices update themselves.** A SYSTEM scheduled task + downloads, verifies and switches to the version StepSecurity releases + for your tenant. You don't need a new SCCM Application or Intune app + for every release. +- **No user prompts.** Updates run in the background as SYSTEM. Users + need no admin rights and see no windows, UAC prompts or notifications. +- **No reboots.** Neither an update nor the MSI (install, upgrade, repair + or uninstall) restarts the machine. A version switch waits until no + scan is running. + +## How it works + +1. The MSI installs a small, stable **bootstrap**, a **seed** copy of the + agent (the MSI's own version) and three scheduled tasks. +2. The scan tasks run as the logged-in user and start, through the + bootstrap, whichever version is current. +3. The **Update** task runs as SYSTEM every hour. About every 6 hours it + asks the StepSecurity API which version your tenant should run. +4. It downloads that version into its own `versions\\` folder, + verifies the signatures, self-tests it, and then makes it current. +5. A new version that crashes or hangs in its first scans is rolled back + automatically and never retried on that device. Every outcome is + reported to the console. + +## Requirements + +### Network endpoints + +The Update task runs as **SYSTEM**, so these must be reachable for the +machine account, not just for logged-in users (HTTPS, port 443): + +| Endpoint | Used for | +|---|---| +| `` (the host in your `APIENDPOINT`) | The target version check (about every 6 hours) and update reports. Scans already use this host. | +| `github.com` | Release downloads, from `https://github.com/step-security/dev-machine-guard/releases/download/v/...` | +| `release-assets.githubusercontent.com` | GitHub redirects release downloads to this host | +| Your mirror, if configured | Only when the GitHub download fails (see [Download mirror](#download-mirror-optional)) | + +Each update downloads three files: the agent, the launcher and the Linux +agent used for WSL scans (about 30 MB together). + +### TLS inspection + +The updater checks server certificates against the Windows certificate +store. If a proxy or firewall inspects TLS, its root certificate must be +in the **Local Computer** Trusted Root store; the SYSTEM account doesn't +see per-user certificate stores. + +Inspection doesn't weaken the update's integrity: every file is checked +against StepSecurity's signed checksum and its Authenticode signature, +whatever the network path. A proxy that blocks, rewrites or "sanitizes" +executable downloads makes every update fail (`update_download_failed` +or `update_verify_failed`). Let the release downloads through unchanged. + +Signature checks don't need extra network access: the updater does not +make online certificate revocation checks. + +### Proxy + +The updater picks a proxy per request, in this order: + +1. **Environment variables**, set machine-wide (the SYSTEM account doesn't + read user variables): `HTTPS_PROXY` for HTTPS requests, `HTTP_PROXY` + for HTTP, and `NO_PROXY` for hosts that go direct. If `HTTPS_PROXY` + is set, it alone decides how HTTPS requests go. + + ```cmd + setx HTTPS_PROXY "http://proxy.example.com:8080" /M + setx NO_PROXY "localhost,127.0.0.1,.corp.example.com" /M + ``` + + `NO_PROXY` accepts host names (a name also matches its subdomains, + with or without a leading dot), IP addresses, CIDR ranges and `*`. + +2. Otherwise, the **WinHTTP machine proxy**, with its bypass list + (`` and `*` wildcards are honoured): + + ```cmd + netsh winhttp set proxy proxy-server="proxy.example.com:8080" bypass-list=";*.corp.example.com" + netsh winhttp show proxy + ``` + +3. Otherwise, a direct connection. + +PAC files and WPAD auto-detection are not used. Proxies that require +user authentication (NTLM, Kerberos) are not supported for the updater: +allow the endpoints above for the machine without authentication. + +### Disk space + +Before it downloads, the updater needs free space of three times the +current version's size plus 50 MB (about 140 MB today) on the system +drive. It keeps only the versions it may need: the current one, the +previous one (for rollback), a copy of the MSI's seed version and, for +up to a day, a downloaded version waiting to be switched to. Older +version folders are deleted automatically. + +### Download mirror (optional) + +If devices can't reach GitHub, host the release files on an HTTPS server +you control and add `update_mirror_url` to the bootstrap file (see +below): + +```json +"update_mirror_url": "https://mirror.example.com/dmg" +``` + +The updater requests `//` +(no `v` before the version), for example +`https://mirror.example.com/dmg/1.19.0/stepsecurity-dev-machine-guard-1.19.0-windows_amd64.exe`. +Mirror the three files for each version and architecture you run: +`stepsecurity-dev-machine-guard--windows_.exe`, +`stepsecurity-dev-machine-guard-task--windows_.exe` and +`stepsecurity-dev-machine-guard--linux_`, where `` +is `amd64` or `arm64`. The URL must be `https://`, with no user name, +password, query or fragment. The mirror is tried only when the GitHub +download fails, and its files go through the same signature checks, so +the mirror itself needs no special trust. + +The bootstrap file is applied on install and on each MSI upgrade, so a +mirror added later reaches existing devices with their next MSI upgrade. + +## SCCM, step by step + +### 1. Prepare the bootstrap file + +Pass the tenant credentials in a **bootstrap file** (`BOOTSTRAPFILE`) +rather than inline (`APIKEY=...`). SCCM stores the deployment type's +command line in the site database, and `AppEnforce.log` on every device +logs it verbatim, so an inline API key ends up in both. + +Deploy this file to every endpoint (Group Policy Preferences, a +configuration item, or any file distribution you already use), for +example at `C:\ProgramData\StepSecurity\bootstrap.json`: + +```json +{ + "customer_id": "", + "api_endpoint": "https://", + "api_key": "", + "scan_frequency_hours": "4" +} +``` + +- Restrict the file to SYSTEM and Administrators. +- **Keep the file in place.** Every MSI install and upgrade that passes + `BOOTSTRAPFILE` reads it; if it's missing, the install fails (1603) and + is rolled back. Keys in the file override the device's existing + settings; settings that aren't in the file (such as the auto-update + setting) are kept. + +### 2. Create the Application + +1. **Software Library → Application Management → Applications → Create + Application**. +2. Choose **Windows Installer (\*.msi file)** and point at + `stepsecurity-dev-machine-guard--x64.msi` (or `-arm64.msi`) + on a share your distribution points can read. SCCM reads the + ProductCode from the MSI. +3. Software version: the MSI version, e.g. `1.18.0`. + +### 3. Deployment type: programs + +**Installation program:** + +```cmd +msiexec /i "stepsecurity-dev-machine-guard--x64.msi" /qn /norestart BOOTSTRAPFILE="C:\ProgramData\StepSecurity\bootstrap.json" /l*v "C:\Windows\Temp\dmg-install.log" +``` + +Add `AUTOUPDATE=0` to opt these devices out of self-update (see +[Controlling updates](#controlling-updates)). + +**Uninstall program** (SCCM fills in the ProductCode): + +```cmd +msiexec /x {PRODUCT-CODE} /qn /norestart /l*v "C:\Windows\Temp\dmg-uninstall.log" +``` + +**Repair program:** use this exact form: + +```cmd +msiexec /i "stepsecurity-dev-machine-guard--x64.msi" REINSTALL=ALL REINSTALLMODE=omus /qn /norestart /l*v "C:\Windows\Temp\dmg-repair.log" +``` + +Don't use `msiexec /f...` for repairs. A `/f` repair ignores every +command-line property, including `/norestart` and `AUTOUPDATE`. From +1.18.0 the MSI itself suppresses restarts and stops Dev Machine Guard +before it replaces files, so even a `/f` repair no longer reboots. But +MSIs up to 1.17.0 could restart the machine without a prompt when a `/f` +repair ran during a scan, and `/f` can't change the auto-update setting. +The repair re-creates the three scheduled tasks and restores the seed if +needed. It never moves a self-updated device back to the MSI's version. + +### 4. Detection method + +Either rule works and stays stable while the device updates itself: + +- **The MSI ProductCode** (SCCM's default for an MSI deployment type). + Accept it. +- Or a **registry** rule: `HKEY_LOCAL_MACHINE\SOFTWARE\StepSecurity`, + value `AgentVersion`, String, **Equals** the MSI version (e.g. + `1.18.0`), 64-bit. + +`AgentVersion` always names the installed MSI. The version the device is +actually running is in `RunningVersion`. Never detect on `RunningVersion` +or on an executable's file version: both change with every self-update, +SCCM would then see the app as missing and reinstall it over and over. + +### 5. User experience and deployment + +- **Installation behavior:** Install for system. **Logon requirement:** + Whether or not a user is logged on. **Visibility:** Hidden. +- **Required** is recommended for the fleet: SCCM installs at the + deadline and reinstalls the app if it's removed. **Available** lets + users install from Software Center (no admin rights needed); use it for + pilots. +- Deploy to a pilot collection first, then expand. + +### 6. Later MSIs: supersedence + +You don't need a new Application for each release. Ship a newer MSI only +when you want to (for example, so that new devices start on a recent +version, or to move devices onto a newer seed). When you do: + +1. Create a new Application for the new MSI, with the same programs and + detection as above. +2. **Supersedence** tab → **Add** → pick the previous Application's + deployment type. Leave **Uninstall** cleared: the new MSI replaces the + old one itself (a Windows Installer major upgrade) and keeps the + device's self-update state. +3. Deploy the new Application to the same collection, Required, with + **Automatically upgrade any superseded versions of this application** + selected. + +Once the new Application is deployed, SCCM shows the superseded +Application's own deployment as **Not Applicable** on those devices and +no longer runs it. That's expected. + +If a device has already updated itself past the new MSI's version, the +upgrade still succeeds and the device keeps its newer version; the MSI's +version only becomes the fallback. + +### 7. Pushing an older MSI + +An MSI never downgrades a device: + +- An MSI older than the **installed** MSI, deployed as a separate + Application (not in a supersedence chain), fails with **1603**: "A + newer version of StepSecurity Dev Machine Guard is already installed." +- An MSI that is older than the version the device **runs**, but not + older than the installed MSI, installs or repairs normally, and the + device keeps its newer self-updated version. + +To move devices to an older release, contact StepSecurity support (see +[Controlling updates](#controlling-updates)). + +## Intune + +The Intune package (`.intunewin`) wraps the same MSI with `install.cmd` +and `uninstall.cmd`, which already pass `/qn /norestart` and log to +`C:\ProgramData\StepSecurity\install.log` / `uninstall.log`. Packaging +details are in [`packaging/windows/README.md`](../packaging/windows/README.md). + +- **Install command:** `install.cmd BOOTSTRAPFILE="C:\ProgramData\StepSecurity\bootstrap.json"` + (add `AUTOUPDATE=0` to opt out). **Uninstall command:** `uninstall.cmd`. + **Install behavior:** System. +- **Detection rule:** Registry, `HKEY_LOCAL_MACHINE\SOFTWARE\StepSecurity`, + value `AgentVersion`, String comparison **Equals** the MSI version, + not associated with a 32-bit app. Don't use `RunningVersion` or the + file version (see [Detection method](#4-detection-method)). +- **Supersedence** for a newer MSI: set **Uninstall previous version** to + **No**, for the same reason as SCCM. The new MSI upgrades the old one + in place. + +## Controlling updates + +The `AUTOUPDATE` MSI property sets the device's auto-update setting. It +has no default: + +| `AUTOUPDATE` | Effect (install, upgrade, repair with `/i ... REINSTALL=ALL`) | +|---|---| +| `0` (or `false`) | Self-update off | +| `1` (or `true`) | Self-update on | +| not set | The current setting is kept; a new install starts with self-update on | + +**Opting out** (`AUTOUPDATE=0`): + +- The device stays on the version it runs at that moment, which may be a + self-updated version newer than the MSI's. It doesn't go back to the + MSI's version. +- The Update task keeps running: health checks and automatic rollback of + a bad version still apply. +- The device reports `update_disabled` ("Auto update off" in the console) + with the version it is not taking, at most once a day per version, so + you can see how far behind it is. + +**Re-enabling:** redeploy with `AUTOUPDATE=1`, or repair in place: + +```cmd +msiexec /i "stepsecurity-dev-machine-guard--x64.msi" REINSTALL=ALL REINSTALLMODE=omus AUTOUPDATE=1 /qn /norestart +``` + +**The opt-out is sticky.** The setting lives in +`C:\ProgramData\StepSecurity\config.json`, which upgrades and uninstalls +keep. So an opted-out device stays opted out across MSI upgrades +(including a failed, rolled-back upgrade), repairs, and even an uninstall +followed by a reinstall, unless that install passes `AUTOUPDATE=1`. To +reset a device to the default (on), either pass `AUTOUPDATE=1` on its +next install, or delete `C:\ProgramData\StepSecurity\config.json` after +uninstalling and before reinstalling. + +**Release pacing, pausing and rollback** are controlled by StepSecurity +for your tenant: which version your devices take, how fast a new release +reaches them, pausing a release, and moving the fleet back to an earlier +release. Contact StepSecurity support to change them. Devices pick up a +change at their next check, within about 6 hours. A device never takes a +version older than 1.18.0, the first self-updating release. + +## Verify a device + +### Scheduled tasks + +| Task | Runs as | What it does | +|---|---|---| +| `StepSecurity Dev Machine Guard` | Logged-in user | The periodic scan (every `SCANFREQUENCY` hours), started through the bootstrap | +| `StepSecurity Dev Machine Guard (Logon)` | Logged-in user | The same scan at logon | +| `StepSecurity Dev Machine Guard (Update)` | SYSTEM | The updater: hourly (with up to 10 min random delay) and 15 min after boot; 30 min time limit; also runs on battery | + +```cmd +schtasks /query /tn "StepSecurity Dev Machine Guard" +schtasks /query /tn "StepSecurity Dev Machine Guard (Logon)" +schtasks /query /tn "StepSecurity Dev Machine Guard (Update)" +``` + +To run an update check now instead of waiting for the next hour, run the +Update task: + +```cmd +schtasks /run /tn "StepSecurity Dev Machine Guard (Update)" +``` + +### Folders + +``` +C:\Program Files\StepSecurity\ + stepsecurity-dev-machine-guard-bootstrap.exe the bootstrap the tasks start (from the MSI) + seed\\ the MSI's own copy of the agent: the fallback + versions\\ versions the updater installed, one folder each + state\state.json which version is current + state\update.log the updater's log +C:\ProgramData\StepSecurity\ + config.json tenant config and the auto-update setting + runs\.json a record of each scan launch per version +``` + +`versions\` and `state\` are owned by SYSTEM; users can read but not +change them. The updater re-applies these permissions on every run. + +### Registry + +```powershell +Get-ItemProperty -Path 'HKLM:\Software\StepSecurity' | Select-Object AgentVersion, RunningVersion +``` + +- `AgentVersion`: the installed **MSI** version (what SCCM and Intune + detect). +- `RunningVersion`: the version the device currently **runs**. After a + self-update it is higher than `AgentVersion`; that's expected. + +### state.json and the update log + +`C:\Program Files\StepSecurity\state\state.json` (readable without admin +rights) names the `current` version and, after an update, the `previous` +one that a rollback would return to. While a new version is being +proven, a `probation` entry names it. A version that failed on this +device is listed under `quarantine` and is never installed there again. + +`C:\Program Files\StepSecurity\state\update.log` has one line per +updater step, with timestamps (it rotates to `update.log.1` past 1 MB). + +### In the StepSecurity console + +- The device page has a **Self-update** card: the running version, the + latest update event with its reason, and a timeline of recent update + events (newest first). +- In the device list, a cue next to the agent version shows a device's + update outcome from the last 24 hours: a red **⚠** label (for example + "⚠ Rolled back") for events that need attention, or **↑ Updated** after + a successful update. +- A successful update shows up as **Updated** (`update_ok`), then a scan + on the new version, then **Update confirmed** (`update_confirmed`). +- A version change made by an **MSI** (an upgrade that installs a newer + version than the device ran) has no update event; it shows as the new + agent version on the device's next scan. + +## Troubleshooting by console event + +Each update outcome is reported with an event name, the versions it went +from and to, and a reason. The console shows a label; the raw event name +appears on hover. + +| Console label (event) | What it means | What to do | +|---|---|---| +| Updated (`update_ok`) | The device switched to the new version. With reason `seed_adopted`, it moved to a newer version from an MSI. | Nothing. Expect "Update confirmed" after the next successful scan. | +| Update confirmed (`update_confirmed`) | The new version completed a scan and is kept. Reason `smoke_only`: no scan ran in the probation window, so it was confirmed by a self-test. | Nothing. | +| Rolled back (`update_rolled_back`) | The new version failed on this device and the device went back to the version in "to". It won't take that version again. Reasons: `bad_runs` (crashed, hung or couldn't start twice), `smoke_failed` (failed its self-test), `pointer_invalid` (its files were damaged), `inconclusive` (no successful scan in 72 hours; retried after 7 days). | One device: check for software that kills or blocks processes in `C:\Program Files\StepSecurity\versions\`. Many devices: contact StepSecurity support. | +| Download failed (`update_download_failed`) | The release files couldn't be downloaded from GitHub or the mirror. | Allow `github.com` and `release-assets.githubusercontent.com` for the machine (see [Requirements](#requirements)), or configure a mirror. Retried at the next check. | +| Verification failed (`update_verify_failed`) | A downloaded file failed a signature, checksum or publisher check. Nothing was installed. | One device: look for a proxy, TLS inspection or security tool that alters downloads. Many devices: contact StepSecurity support. | +| Staging failed (`update_stage_failed`) | The new version couldn't be written to disk (space, permissions). Reason `av_deleted`: antivirus deleted the running version's files three times in a day; re-installing it is paused for 24 hours. | Free disk space (see [Disk space](#disk-space)). For `av_deleted`, exclude `C:\Program Files\StepSecurity\` from file quarantine. | +| Health check failed (`update_health_failed`) | The new version failed its self-test before the switch. The device stays on its version and won't take that one again. | Check that the device's configuration is complete (`config.json`). Many devices: contact StepSecurity support. | +| Switch failed (`update_switch_failed`) | The new version was ready but the switch couldn't be saved. | Check disk health and free space. Retried automatically. | +| Switch deferred (`update_switch_deferred`) | A scan was running at four update runs in a row, so the switch kept waiting. | Usually nothing: it switches at the first update run with no scan running. If it repeats, check for scans that never finish. | +| Update check failed (`update_check_failed`) | The device couldn't get its target version from the StepSecurity API. | Check the machine's access to ``, the proxy settings and the API key in `config.json`. Reported at most once a day. | +| Below version floor (`update_refused_floor`) | The device was offered a version older than 1.18.0 and refused it. | Contact StepSecurity support. | +| Auto update off (`update_disabled`) | Self-update is off on this device (`AUTOUPDATE=0`); the event names the version it isn't taking. | Expected for opted-out devices. To re-enable, see [Controlling updates](#controlling-updates). | +| Version pointer invalid (`update_pointer_invalid`) | A scan couldn't start the current version and ran a fallback (the previous version or the MSI's seed). The reason names the cause, e.g. `state_missing` or `current_missing:`. | Usually fixed by the next update run. If it repeats, check the Update task exists and runs; repair the MSI. | +| Permissions repaired (`update_acl_drift`) | Something changed the permissions on `versions\` or `state\`; the updater restored them. | If it repeats, find the tool or Group Policy changing permissions under `C:\Program Files\StepSecurity\`. | +| Update state corrupt (`update_state_corrupt`) | `state.json` was missing or unreadable and was rebuilt. | Once: nothing. Repeated: check disk health and tools that delete files under `C:\Program Files\StepSecurity\state\`. | +| Updater failing (`update_updater_failed`) | The updater in the current version failed three times in a row, and the MSI's copy took over. | Contact StepSecurity support with the device's `update.log`. | + +**No update events at all** from a device usually means it still runs an +MSI older than 1.18.0 (it needs the final MSI push), the Update task is +missing (check with `schtasks /query`, then repair the MSI), or the +device can't reach ``. + +**The last event is "Updated" and nothing since:** the new version is on +probation. The device makes no update checks until a scan on the new +version succeeds (then "Update confirmed" follows at the next update +run) or the probation ends. Check that scans are running. + +## Troubleshooting install issues + +### 1603 "A newer version ... is already installed" and phantom MSI registrations + +This message means Windows Installer found a newer Dev Machine Guard +product registered on the device. Usually that's real (see +[Pushing an older MSI](#7-pushing-an-older-msi)). But a **failed** MSI +install or upgrade run by SCCM can leave a **phantom** registration +behind: the product is registered as "advertised" without being +installed. It isn't in Programs and Features, yet it blocks any install +of a lower version. + +Detect it in an elevated PowerShell. The GUID is the x64 MSI's +UpgradeCode; for the arm64 MSI use `{99C4A108-6A71-4006-8AA7-F3D14DA045A9}`: + +```powershell +$wi = New-Object -ComObject WindowsInstaller.Installer +foreach ($pc in @($wi.RelatedProducts('{65AE0FC0-2070-4F40-B0CA-413637F94121}'))) { + "$pc ProductState=$($wi.ProductState($pc))" +} +``` + +- `ProductState=5`: installed. A healthy device has exactly one. +- `ProductState=1`: advertised only, a **phantom**. + +Remove each phantom by its ProductCode, then redeploy: + +```cmd +msiexec /x {PHANTOM-PRODUCT-CODE} /qn /norestart +``` + +If `msiexec /x` fails, contact StepSecurity support with the output +above and the MSI log. + +### Other 1603 failures + +The MSI log names the failing step. Common causes: the `BOOTSTRAPFILE` +path doesn't exist or isn't readable by SYSTEM (search the log for +`--from-file`), or the credentials are incomplete (`api_key is +required`). A failed install or upgrade is rolled back completely: the +device keeps the version, the tasks and the settings it had. + +### 3010 (restart required) + +3010 means the install succeeded but a file was in use and Windows will +finish replacing it at the next restart. The MSI never restarts the +machine itself, and Dev Machine Guard doesn't need that restart to run. +It can happen on the upgrade from an older MSI that doesn't yet stop +Dev Machine Guard before replacing files. SCCM and Intune treat 3010 as +a soft reboot and follow your client restart settings. + +### Logs to collect for a support case + +| Log | Where | +|---|---| +| MSI verbose log | The `/l*v` path in your command line (e.g. `C:\Windows\Temp\dmg-install.log`); Intune: `C:\ProgramData\StepSecurity\install.log` | +| SCCM client logs | `C:\Windows\CCM\Logs\AppEnforce.log` (the command line and exit code), `AppDiscovery.log` (detection), `AppIntentEval.log` (applicability, supersedence) | +| Updater log | `C:\Program Files\StepSecurity\state\update.log` and `update.log.1` | +| Updater state | `C:\Program Files\StepSecurity\state\state.json` | +| Versions | `Get-ItemProperty -Path 'HKLM:\Software\StepSecurity'` output | + +Plus the device id from the console. `AppEnforce.log` contains the full +install command line: another reason to keep the API key in the +bootstrap file. + +## Uninstall + +Uninstalling (the SCCM uninstall deployment, `msiexec /x {PRODUCT-CODE}` +or Intune's `uninstall.cmd`) stops all Dev Machine Guard processes and +removes: + +- the three scheduled tasks; +- everything under `C:\Program Files\StepSecurity\`: the bootstrap, the + seed, all `versions\` and `state\` (including `update.log`); +- the `AgentVersion` and `RunningVersion` registry values. + +It keeps `C:\ProgramData\StepSecurity\`: `config.json` (the tenant +configuration, including the API key and the auto-update setting), +`device-id` (so a reinstalled device keeps its record in the console), +the scan records in `runs\` and the scan logs. That's what lets an +uninstall and reinstall pick up where it left off. To remove everything, +for example when decommissioning a device, delete the folder after the +uninstall: + +```cmd +rmdir /s /q "C:\ProgramData\StepSecurity" +``` + +## FAQ + +**Does self-update ever reboot the machine?** +No. Updates switch versions between scans, and the MSI sets +`REBOOT=ReallySuppress` and stops Dev Machine Guard before it touches +files. Use the repair command above (not `/f`), which also matters for +devices still on 1.17.0 or older. + +**Will users see prompts?** +No. The Update task runs as SYSTEM in the background with no window, +no UAC prompt and no notification. + +**Do users need admin rights?** +No. Updates run as SYSTEM. Scans run as the logged-in user, as before. + +**How much bandwidth does it use?** +One small API request about every 6 hours, and about 30 MB per new +version, downloaded once per device. Devices don't download versions +they already have. + +**Can a device be downgraded?** +An MSI never downgrades a device. StepSecurity can move your tenant back +to an earlier release (1.18.0 or newer) if a release has a problem; +devices then switch down at their next check, through the same verified +path. A version that crashed or failed its self-test on a device is +never installed on it again; a fix ships as a new version. + +**What if SCCM repairs the app during an update or a scan?** +The repair stops Dev Machine Guard first, then finishes normally, without +a reboot, and keeps the current version. An interrupted update continues +at the next hourly run. A scan that was running is stopped and runs again +at its next scheduled time; in the console that one scan shows as started +without a finished status. + +**What about offline devices?** +They keep running and scanning on their current version. The update +check and any pending reports are retried when the device is back online +(reports are kept on the device until they are delivered). One case to +know: a device that goes offline right after switching to a new version +can't prove that version. If it has six or more scans in 72 hours and +none reaches the backend, it goes back to the previous version (reason +`inconclusive`) and tries the new one again after 7 days. + +**What about machines with several users?** +Self-update is per machine: one installed version serves every user. The +scan tasks run as whichever user is logged on. A version switch waits +until no scan is running, and if scans keep running it retries every +hour. + +**What if a release is bad?** +A version that crashes, hangs or fails its self-test is rolled back on +each device after at most two failed scans, reported as "Rolled back", +and never retried there. StepSecurity can also pause a release or move +tenants back. + +**Can I keep using my SCCM/Intune app for a while?** +Yes. Keep the 1.18.0 (or newer) app deployed so new devices get it. +Detection stays satisfied on self-updated devices, so SCCM and Intune +don't reinstall anything. diff --git a/docs/windows-self-update.md b/docs/windows-self-update.md new file mode 100644 index 00000000..b6adb566 --- /dev/null +++ b/docs/windows-self-update.md @@ -0,0 +1,185 @@ +# Windows self-update (MSI installs) + +MSI installs (Intune, SCCM) keep themselves on the release your tenant +selects, without IT pushing a new MSI for every version. A small, stable +bootstrap launches whichever version is current. A separate SYSTEM task +downloads, verifies and switches versions, and rolls back a version that +crashes. + +Packaging details (custom actions, the `AUTOUPDATE` property, upgrades and +the Intune detection rule) are in +[`packaging/windows/README.md`](../packaging/windows/README.md). For IT +admins (SCCM/Intune setup, network and proxy requirements, verifying a +device, and what each update event means), see the +[admin guide](windows-self-update-admin.md). + +## Layout + +``` +C:\Program Files\StepSecurity\ + stepsecurity-dev-machine-guard-bootstrap.exe MSI-owned, stable + seed\\ MSI-owned fallback payload + versions\\ updater-owned, immutable once in place + stepsecurity-dev-machine-guard.exe + stepsecurity-dev-machine-guard-task.exe + dmg-linux + manifest.json sha256 of every file + state\state.json, update.lock, update.log updater-owned +C:\ProgramData\StepSecurity\runs\.json one record per scan launch +HKLM\Software\StepSecurity\AgentVersion MSI version (Intune detection) +HKLM\Software\StepSecurity\RunningVersion version currently selected +``` + +- `versions\` and `state\` are owned by SYSTEM with a protected ACL: + SYSTEM and Administrators full control, Users read and execute. The + updater re-applies it on every run and reports any drift. +- The updater never touches the bootstrap, the seed or `AgentVersion`. + The MSI touches `versions\` and `state\` only through two custom + actions: on install, upgrade and repair, `adopt-seed` copies the seed + into `versions\` and moves `current` forward to it if the seed is + newer (never back), and a real uninstall removes both directories. A + repair or upgrade therefore can't undo a self-update. The MSI stops + running DMG processes and waits for its files to be free before any + file action, and never reboots on its own (`REBOOT=ReallySuppress`; + `msiexec /f` repairs ignore `/norestart`, see the packaging README). A + failed + upgrade that Windows Installer rolls back also undoes adopt-seed's + change (it snapshots `state.json` first). + +## Tasks + +| Task | Runs as | Action | +|---|---|---| +| Scan (hourly/daily) and logon | Logged-in user | `bootstrap.exe run send-telemetry …` | +| `StepSecurity Dev Machine Guard (Update)` | SYSTEM, hourly + 15 min after boot | `bootstrap.exe update` | + +`bootstrap.exe run` reads `state.json` once and launches `current`. If +that directory is missing or incomplete it falls back to `previous`, then +to the seed. Each launch is recorded in `runs\.json`. A scan that ran +a fallback reports `update_pointer_invalid`. + +`bootstrap.exe update` runs `agent.exe self-update --system` from the +confirmed version. While a new version is on probation it uses the +previous one, and after three consecutive updater failures it uses the +seed, so a broken updater can't freeze a device. + +## One updater run + +Every step can be interrupted at any point; the next run picks up from +the state on disk. + +1. Take `state\update.lock`. Another updater already running → exit. +2. Clean up leftover staging, remove version directories nothing points + at, re-apply ACLs, and refresh the device id from the hardware. +3. Check the version on probation (see below). This may roll back. +4. Ask the backend for the target: at most every 6 hours, or right after + a probation closes. Stop if updates are off, the target is the current + version, quarantined, below the minimum self-update version, or a + probation is still open. +5. Stage the target in `versions\.staging-*`. Verification happens before + anything is renamed into place: + - every checksum's SSH signature is checked against the pinned release + key, before any download; + - sha256 is checked on each download (GitHub first, then the optional + mirror); + - both executables must have a valid, timestamped Authenticode + signature from the same publisher as the running updater. +6. Run `agent.exe self-check` from the staged directory as SYSTEM (in a + job, 60 s limit). A failure quarantines the version. +7. Wait (up to 10 minutes) until no scan is running, then switch `current` + to the new version and start its probation. +8. Report the result. + +## Health, rollback and quarantine + +A new version is on probation until its scans prove it. The bootstrap +numbers every launch, and the probation counts launches numbered after +the switch (so clock changes can't hide runs). Each is classified: + +- **Healthy:** the scan reached the backend (run-status `started`) and + didn't crash or get killed by the watchdog. +- **Bad:** a crash (a Go panic, recovered or not, exits 2), "couldn't + start the agent" (70) or a watchdog kill, whether or not the scan had + posted `started`; or an exit before `started` with any code not listed + as Neutral. The watchdogs count awake time only, so a laptop asleep past + the limit is not killed; a kill means the scan really hung. +- **Neutral:** an exit 0 or 1 before `started` (offline, or skipped + because another scan was running); an exit caused by the session + ending (130, `0xC000013A` STATUS_CONTROL_C_EXIT, `0x40010004` + DBG_TERMINATE_PROCESS); or a launch whose + bootstrap died without recording an outcome (shutdown, logoff, the task + time limit). + +Outcome: + +- One healthy run confirms the version (`update_confirmed`). +- Two bad runs with no healthy one roll back to the previous version + (`update_rolled_back`, reason `bad_runs`). +- The probation window ends after 72 hours, or after 72 hourly update + runs, whichever comes first. If by then there were six or more runs and + none was healthy, the version rolls back (`inconclusive`) with a 7-day + quarantine. With fewer runs, the version is self-checked again: a pass + confirms it (`update_confirmed`, `smoke_only`), a failure rolls it back + (`smoke_failed`). + +Every other rollback, and a failed pre-switch self-check, quarantines the +version permanently on that device, so it is never staged again. A fix +ships as a new version. + +## Turning it off + +Install with `AUTOUPDATE=0` to stop taking new targets. The device stays +on whatever version is current at that moment, which may be a +self-updated version newer than the MSI's. The update task keeps +running (health checks and rollback still apply) and reports +`update_disabled` with the target it isn't taking, so the backend shows +how far behind the device is. + +To go back to the MSI's own version, either re-enable self-update and +let the tenant's target bring it there, or uninstall and reinstall the +MSI (a fresh install starts from the seed). See the packaging README for +the `AUTOUPDATE` values and re-enabling. + +## Version floor + +The updater refuses any target older than the first release that +contains it (`minWindowsSelfUpdateVersion`, currently 1.18.0) and +reports `update_refused_floor`. A version below the floor has no updater +and would leave the device stuck. To put devices on an older release, +deploy that release's MSI instead. + +## Device identity + +Update events are sent under the device id the SYSTEM updater resolves: +the configured `device_id`, else the hardware serial (BIOS registry, +then WMI), else its own cached hardware id, else `MachineGuid`. It never +trusts the user-writable `C:\ProgramData\StepSecurity\device-id` file, +and only a hardware result is written back to it, so a transient probe +failure never changes the id the scans report. + +## Events + +Every outcome is posted to `run-status` with `status: "update"`, the +device id, the sending agent's version, `install_channel: "msi"`, and +`update_event`, plus `update_from` / `update_to` / `error_message` where +they apply: + +`update_ok`, `update_confirmed`, `update_rolled_back`, +`update_download_failed`, `update_verify_failed`, `update_stage_failed`, +`update_health_failed`, `update_switch_failed`, `update_switch_deferred`, +`update_check_failed`, `update_refused_floor`, `update_disabled`, +`update_pointer_invalid`, `update_acl_drift`, `update_state_corrupt`, +`update_updater_failed`. + +Each event is saved in `state.json` in the same write as the change it +reports, then sent. An event the backend rejects as invalid (HTTP 400 or +422) is dropped; any other failure is retried with a backoff of 1 hour, +doubling up to 24 hours. Up to 64 events are kept; beyond that the +oldest are dropped and counted. Repeated failures and refusals for the +same target are reported at most once per day. + +## Existing fleets + +Devices on an MSI older than the first self-updating release can't +update themselves. They need one final MSI push of a release that ships +the bootstrap; from then on they follow the tenant's target. diff --git a/internal/aiagents/cli/hook.go b/internal/aiagents/cli/hook.go index 922409ae..35c0b287 100644 --- a/internal/aiagents/cli/hook.go +++ b/internal/aiagents/cli/hook.go @@ -67,7 +67,7 @@ func RunHook(stdin io.Reader, stdout, stderr io.Writer, args []string) int { rt.Stdin = stdin rt.Stdout = stdout rt.Stderr = stderr - rt.Exec = executor.NewReal() + rt.Exec = newHookExecutor() rt.LogError = AppendError rt.UploadEvent = uploaderFactory() @@ -82,6 +82,12 @@ func RunHook(stdin io.Reader, stdout, stderr io.Writer, args []string) int { return 0 } +// newHookExecutor is the executor RunHook's runtime probes the host with. +// Tests swap in a mock: N concurrent RunHook calls under -race each fork +// host probes, and on macOS a race-built fork child can spin forever +// before exec, orphaned once the test binary dies. +var newHookExecutor = func() executor.Executor { return executor.NewReal() } + // uploaderFactory is the seam RunHook uses to obtain the upload // closure. Production points it at newUploader, which reads // process-wide config and constructs an ingest.Client. Tests override diff --git a/internal/aiagents/cli/main_test.go b/internal/aiagents/cli/main_test.go new file mode 100644 index 00000000..ccdb2b50 --- /dev/null +++ b/internal/aiagents/cli/main_test.go @@ -0,0 +1,28 @@ +package cli + +import ( + "fmt" + "os" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/proctest" +) + +// TestMain keeps the package off the real host: a temp HOME/USERPROFILE so +// nothing reads or writes the developer's ~/.claude, ~/.codex or +// ~/.stepsecurity, and a mock executor so RunHook spawns no host probes. +func TestMain(m *testing.M) { + home, err := os.MkdirTemp("", "aiagents-cli-home-") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + for _, k := range []string{"HOME", "USERPROFILE"} { + _ = os.Setenv(k, home) + } + newHookExecutor = func() executor.Executor { return executor.NewMock() } + code := proctest.Main(m) + _ = os.RemoveAll(home) + os.Exit(code) +} diff --git a/internal/authenticode/authenticode.go b/internal/authenticode/authenticode.go new file mode 100644 index 00000000..f96983ab --- /dev/null +++ b/internal/authenticode/authenticode.go @@ -0,0 +1,125 @@ +// Package authenticode checks Windows code signatures for the self-updater: +// the candidate must carry a valid, timestamped Authenticode signature whose +// leaf signer subject equals the running updater's own signer subject, so +// no publisher is hard-coded and a Trusted Signing certificate rotation +// (same subject, new key) keeps working. +// +// Trust (chain, digest) AND the signer subject come from WinVerifyTrust: the +// subject is read from the leaf of the chain it validated (provider data), +// never from the PE's certificate bag, which lies outside the signed digest +// and can carry look-alike certificates. The bag is still parsed in Go to +// reject an ambiguous signer (two certs with the signer's issuer+serial) and +// to recognise an RFC 3161 timestamp WinVerifyTrust doesn't count. +package authenticode + +import ( + "errors" + "fmt" +) + +var ( + // ErrUnsigned means the file has no Authenticode signature. + ErrUnsigned = errors.New("authenticode: file is not signed") + // ErrNotTimestamped means the signature has no countersignature + // timestamp, so it stops validating when the signing cert expires. + ErrNotTimestamped = errors.New("authenticode: signature is not timestamped") + // ErrUntrusted means WinVerifyTrust rejected the signature. + ErrUntrusted = errors.New("authenticode: signature not trusted") + // ErrPublisherMismatch means the candidate's signer differs from ours. + ErrPublisherMismatch = errors.New("authenticode: publisher differs from the running updater") + // ErrNoPublisher means the running updater has no signer to compare + // against (an unsigned dev build) and no test pin is configured. + ErrNoPublisher = errors.New("authenticode: running updater is unsigned; refusing updates") + // ErrAmbiguousSigner means the certificate bag holds more than one + // certificate matching the signer's issuer+serial (or key id). + ErrAmbiguousSigner = errors.New("authenticode: signer certificate is ambiguous") + // ErrUnsupported is returned off Windows, where WinVerifyTrust is absent. + ErrUnsupported = errors.New("authenticode: verification is only supported on Windows") +) + +// Signature describes a file's primary Authenticode signature. +type Signature struct { + Subject string // leaf signer subject, RFC 2253 form + Timestamped bool +} + +// trustedSigner is what WinVerifyTrust validated: the leaf subject of the +// primary signer's chain and its countersigner (timestamp) count. +type trustedSigner struct { + subject string + counterSigners int +} + +// Verify checks path with WinVerifyTrust (revocation off), takes the +// subject from the validated chain and requires a timestamped signature. +func Verify(path string) (*Signature, error) { + ts, err := verifyTrusted(path) + if err != nil { + return nil, err + } + bag, err := Inspect(path) + if err != nil { + return nil, err // includes ErrAmbiguousSigner + } + // Countersigners are what WinVerifyTrust counted; an RFC 3161 token is + // only recognised in the bag (its validity is WinVerifyTrust's job). + timestamped := ts.counterSigners > 0 || bag.Timestamped + if !timestamped { + return nil, fmt.Errorf("%w: %s", ErrNotTimestamped, path) + } + return &Signature{Subject: ts.subject, Timestamped: true}, nil +} + +// ownSubject is the subject of the running updater's own signature, from +// WinVerifyTrust like any candidate (no timestamp requirement). +func ownSubject(self string) (string, error) { + ts, err := verifyTrusted(self) + if err != nil { + return "", err + } + return ts.subject, nil +} + +// SamePublisher verifies candidate and checks that its leaf subject equals +// the subject of self (the running updater). A selfupdate_testkeys build may +// pin the expected subject instead (DMG_TEST_AUTHENTICODE_SUBJECT). An +// unsigned self with no pin refuses every candidate. +func SamePublisher(candidate, self string) error { + return samePublisher(candidate, self, Verify, ownSubject, testPinnedSubject()) +} + +func samePublisher(candidate, self string, verify func(string) (*Signature, error), own func(string) (string, error), pin string) error { + got, err := verify(candidate) + if err != nil { + return err + } + want := pin + if want == "" { + subject, err := own(self) + switch { + case errors.Is(err, ErrUnsigned): + return ErrNoPublisher + case err != nil: + return fmt.Errorf("authenticode: own signature: %w", err) + } + want = subject + } + if want == "" || got.Subject != want { + return fmt.Errorf("%w: %q, want %q", ErrPublisherMismatch, got.Subject, want) + } + return nil +} + +// SameSeedPublisher is the publisher rule for adopting an MSI seed: as +// SamePublisher, except that an unsigned running binary (a dev or CI +// build, which has no publisher to compare) accepts the seed. The seed was +// installed by the MSI into an admin-only directory; its copy is still +// checked against hashes of the source. +func SameSeedPublisher(candidate, self string) error { + if testPinnedSubject() == "" { + if _, err := ownSubject(self); errors.Is(err, ErrUnsigned) { + return nil + } + } + return SamePublisher(candidate, self) +} diff --git a/internal/authenticode/pkcs7.go b/internal/authenticode/pkcs7.go new file mode 100644 index 00000000..0c92d608 --- /dev/null +++ b/internal/authenticode/pkcs7.go @@ -0,0 +1,200 @@ +package authenticode + +import ( + "bytes" + "crypto/sha1" // #nosec G505 -- key identifiers are SHA-1 by definition (RFC 5280 4.2.1.2) + "crypto/x509" + "debug/pe" + "encoding/asn1" + "encoding/binary" + "errors" + "fmt" + "io" + "math/big" + "os" +) + +const ( + maxSigBlob = 1 << 20 + winCertTypePKCSSigned = 0x0002 + imageDirectoryEntrySec = 4 +) + +var ( + oidSignedData = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 2} + oidCounterSignature = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 6} + oidRFC3161CounterSign = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 311, 3, 3, 1} + errMalformedSignedData = errors.New("authenticode: malformed PKCS#7 signature") +) + +// Inspect reads the primary signature of a PE file without checking trust: +// the bag's signer subject and whether it carries a timestamp. It is not a +// publisher decision (the bag is outside the signed digest); Verify uses it +// only to reject an ambiguous signer and to spot RFC 3161 timestamps. +func Inspect(path string) (*Signature, error) { + blob, err := peSignature(path) + if err != nil { + return nil, err + } + return parseSignedData(blob) +} + +// peSignature returns the PKCS#7 SignedData of the first WIN_CERTIFICATE in +// the PE security directory. +func peSignature(path string) ([]byte, error) { + // #nosec G304 -- the updater's own staged or running executable. + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + pf, err := pe.NewFile(f) + if err != nil { + return nil, fmt.Errorf("authenticode: %s is not a PE file: %w", path, err) + } + var dir pe.DataDirectory + switch oh := pf.OptionalHeader.(type) { + case *pe.OptionalHeader32: + if oh.NumberOfRvaAndSizes > imageDirectoryEntrySec { + dir = oh.DataDirectory[imageDirectoryEntrySec] + } + case *pe.OptionalHeader64: + if oh.NumberOfRvaAndSizes > imageDirectoryEntrySec { + dir = oh.DataDirectory[imageDirectoryEntrySec] + } + } + if dir.VirtualAddress == 0 || dir.Size < 8 { + return nil, ErrUnsigned + } + if dir.Size > maxSigBlob { + return nil, errMalformedSignedData + } + buf := make([]byte, dir.Size) + // The security directory's "VirtualAddress" is a file offset. + if _, err := f.ReadAt(buf, int64(dir.VirtualAddress)); err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + length := binary.LittleEndian.Uint32(buf[0:4]) + certType := binary.LittleEndian.Uint16(buf[6:8]) + if certType != winCertTypePKCSSigned || length < 8 || length > dir.Size { + return nil, errMalformedSignedData + } + return buf[8:length], nil +} + +type contentInfo struct { + ContentType asn1.ObjectIdentifier + Content asn1.RawValue `asn1:"explicit,optional,tag:0"` +} + +type signedData struct { + Version int + DigestAlgorithms asn1.RawValue + ContentInfo asn1.RawValue + Certificates asn1.RawValue `asn1:"optional,tag:0"` + CRLs asn1.RawValue `asn1:"optional,tag:1"` + SignerInfos asn1.RawValue +} + +type signerInfo struct { + Version int + SID asn1.RawValue + DigestAlgorithm asn1.RawValue + SignedAttrs asn1.RawValue `asn1:"optional,tag:0"` + SignatureAlgorithm asn1.RawValue + Signature []byte + UnsignedAttrs asn1.RawValue `asn1:"optional,tag:1"` +} + +type issuerAndSerial struct { + Issuer asn1.RawValue + Serial *big.Int +} + +type attribute struct { + Type asn1.ObjectIdentifier + Values asn1.RawValue +} + +// parseSignedData extracts the first signer's certificate subject and the +// presence of a (legacy or RFC 3161) countersignature timestamp. +func parseSignedData(der []byte) (*Signature, error) { + var ci contentInfo + if rest, err := asn1.Unmarshal(der, &ci); err != nil || len(bytes.TrimRight(rest, "\x00")) != 0 { + return nil, errMalformedSignedData + } + if !ci.ContentType.Equal(oidSignedData) { + return nil, errMalformedSignedData + } + var sd signedData + if _, err := asn1.Unmarshal(ci.Content.Bytes, &sd); err != nil { + return nil, errMalformedSignedData + } + certs, err := x509.ParseCertificates(sd.Certificates.Bytes) + if err != nil || len(certs) == 0 { + return nil, errMalformedSignedData + } + var si signerInfo + if _, err := asn1.Unmarshal(sd.SignerInfos.Bytes, &si); err != nil { + return nil, errMalformedSignedData + } + matches := findSigner(certs, si.SID) + switch { + case len(matches) == 0: + return nil, fmt.Errorf("%w: signer certificate not embedded", errMalformedSignedData) + case len(matches) > 1: + return nil, fmt.Errorf("%w: %d certificates match the signer", ErrAmbiguousSigner, len(matches)) + } + leaf := matches[0] + sig := &Signature{Subject: leaf.Subject.String()} + rest := si.UnsignedAttrs.Bytes + for len(rest) > 0 { + var a attribute + var err error + if rest, err = asn1.Unmarshal(rest, &a); err != nil { + return nil, errMalformedSignedData + } + if a.Type.Equal(oidCounterSignature) || a.Type.Equal(oidRFC3161CounterSign) { + sig.Timestamped = true + } + } + return sig, nil +} + +// findSigner returns every certificate matching the signer id: by +// issuer+serial, or for a key-id SID by the SKI extension or (as Windows +// does) the SHA-1 of the public key, so a look-alike can't hide. +func findSigner(certs []*x509.Certificate, sid asn1.RawValue) []*x509.Certificate { + var out []*x509.Certificate + if sid.Class == asn1.ClassContextSpecific && sid.Tag == 0 { + for _, c := range certs { + keyID := sha1.Sum(publicKeyBits(c)) // #nosec G401 -- RFC 5280 key identifier lookup, not a security hash + if bytes.Equal(c.SubjectKeyId, sid.Bytes) || bytes.Equal(keyID[:], sid.Bytes) { + out = append(out, c) + } + } + return out + } + var ias issuerAndSerial + if _, err := asn1.Unmarshal(sid.FullBytes, &ias); err != nil || ias.Serial == nil { + return nil + } + for _, c := range certs { + if bytes.Equal(c.RawIssuer, ias.Issuer.FullBytes) && c.SerialNumber.Cmp(ias.Serial) == 0 { + out = append(out, c) + } + } + return out +} + +// publicKeyBits is the subjectPublicKey BIT STRING content of c. +func publicKeyBits(c *x509.Certificate) []byte { + var spki struct { + Algorithm asn1.RawValue + PublicKey asn1.BitString + } + if _, err := asn1.Unmarshal(c.RawSubjectPublicKeyInfo, &spki); err != nil { + return nil + } + return spki.PublicKey.Bytes +} diff --git a/internal/authenticode/pkcs7_test.go b/internal/authenticode/pkcs7_test.go new file mode 100644 index 00000000..ce83bb93 --- /dev/null +++ b/internal/authenticode/pkcs7_test.go @@ -0,0 +1,249 @@ +package authenticode + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/binary" + "errors" + "math/big" + "os" + "path/filepath" + "testing" + "time" +) + +func makeCert(t *testing.T, cn string, serial int64) *x509.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(serial), + Subject: pkix.Name{CommonName: cn, Organization: []string{"StepSecurity Test"}}, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + c, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return c +} + +func rawSet(t *testing.T, elems ...[]byte) asn1.RawValue { + t.Helper() + var b []byte + for _, e := range elems { + b = append(b, e...) + } + return asn1.RawValue{Class: asn1.ClassUniversal, Tag: asn1.TagSet, IsCompound: true, Bytes: b} +} + +func mustMarshal(t *testing.T, v any) []byte { + t.Helper() + b, err := asn1.Marshal(v) + if err != nil { + t.Fatal(err) + } + return b +} + +// buildSignedData returns a PKCS#7 ContentInfo whose first signer is signer +// (by issuer+serial), embedding certs, optionally with a timestamp attribute. +func buildSignedData(t *testing.T, signer *x509.Certificate, certs []*x509.Certificate, tsOID asn1.ObjectIdentifier) []byte { + t.Helper() + sid := mustMarshal(t, issuerAndSerial{Issuer: asn1.RawValue{FullBytes: signer.RawIssuer}, Serial: signer.SerialNumber}) + algo := mustMarshal(t, pkix.AlgorithmIdentifier{Algorithm: asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1}}) + si := signerInfo{ + Version: 1, + SID: asn1.RawValue{FullBytes: sid}, + DigestAlgorithm: asn1.RawValue{FullBytes: algo}, + SignatureAlgorithm: asn1.RawValue{FullBytes: algo}, + Signature: []byte{1, 2, 3}, + } + if tsOID != nil { + attr := mustMarshal(t, attribute{Type: tsOID, Values: rawSet(t, mustMarshal(t, 1))}) + si.UnsignedAttrs = asn1.RawValue{Class: asn1.ClassContextSpecific, Tag: 1, IsCompound: true, Bytes: attr} + } + var certBytes []byte + for _, c := range certs { + certBytes = append(certBytes, c.Raw...) + } + sd := signedData{ + Version: 1, + DigestAlgorithms: rawSet(t, algo), + ContentInfo: asn1.RawValue{FullBytes: mustMarshal(t, contentInfo{ContentType: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 311, 2, 1, 4}})}, + Certificates: asn1.RawValue{Class: asn1.ClassContextSpecific, Tag: 0, IsCompound: true, Bytes: certBytes}, + SignerInfos: rawSet(t, mustMarshal(t, si)), + } + sdDER := mustMarshal(t, sd) + return mustMarshal(t, contentInfo{ContentType: oidSignedData, Content: asn1.RawValue{FullBytes: wrapExplicit0(sdDER)}}) +} + +func wrapExplicit0(inner []byte) []byte { + b, _ := asn1.Marshal(asn1.RawValue{Class: asn1.ClassContextSpecific, Tag: 0, IsCompound: true, Bytes: inner}) + return b +} + +// writePE writes a minimal PE32+ image, with sig (if non-nil) as its +// WIN_CERTIFICATE in the security directory. +func writePE(t *testing.T, sig []byte) string { + t.Helper() + const lfanew = 0x40 + img := make([]byte, lfanew+4+20+240) + copy(img, "MZ") + binary.LittleEndian.PutUint32(img[0x3c:], lfanew) + copy(img[lfanew:], "PE\x00\x00") + coff := img[lfanew+4:] + binary.LittleEndian.PutUint16(coff[0:], 0x8664) // Machine + binary.LittleEndian.PutUint16(coff[16:], 240) // SizeOfOptionalHeader + opt := img[lfanew+24:] + binary.LittleEndian.PutUint16(opt[0:], 0x20b) // PE32+ + binary.LittleEndian.PutUint32(opt[108:], 16) // NumberOfRvaAndSizes + if sig != nil { + off := len(img) + wc := make([]byte, 8+len(sig)) + binary.LittleEndian.PutUint32(wc[0:], uint32(len(wc))) + binary.LittleEndian.PutUint16(wc[4:], 0x0200) + binary.LittleEndian.PutUint16(wc[6:], winCertTypePKCSSigned) + copy(wc[8:], sig) + for len(wc)%8 != 0 { + wc = append(wc, 0) + } + dd := opt[112+imageDirectoryEntrySec*8:] + binary.LittleEndian.PutUint32(dd[0:], uint32(off)) + binary.LittleEndian.PutUint32(dd[4:], uint32(len(wc))) + img = append(img, wc...) + } + p := filepath.Join(t.TempDir(), "x.exe") + if err := os.WriteFile(p, img, 0o755); err != nil { + t.Fatal(err) + } + return p +} + +func TestInspectSubjectAndTimestamp(t *testing.T) { + leaf := makeCert(t, "StepSecurity Inc", 7) + other := makeCert(t, "Intermediate", 8) + for _, tc := range []struct { + name string + oid asn1.ObjectIdentifier + ts bool + }{ + {"rfc3161", oidRFC3161CounterSign, true}, + {"legacy countersignature", oidCounterSignature, true}, + {"no timestamp", nil, false}, + {"unrelated unsigned attr", asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 311, 2, 4, 1}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + p := writePE(t, buildSignedData(t, leaf, []*x509.Certificate{other, leaf}, tc.oid)) + sig, err := Inspect(p) + if err != nil { + t.Fatal(err) + } + if sig.Subject != leaf.Subject.String() || sig.Timestamped != tc.ts { + t.Fatalf("sig = %+v, want subject %q ts=%v", sig, leaf.Subject.String(), tc.ts) + } + }) + } +} + +func TestInspectUnsignedAndMalformed(t *testing.T) { + if _, err := Inspect(writePE(t, nil)); !errors.Is(err, ErrUnsigned) { + t.Fatalf("unsigned PE: %v", err) + } + if _, err := Inspect(writePE(t, []byte("garbage"))); err == nil { + t.Fatal("garbage signature accepted") + } + leaf := makeCert(t, "Leaf", 1) + notEmbedded := makeCert(t, "Other", 2) + if _, err := Inspect(writePE(t, buildSignedData(t, leaf, []*x509.Certificate{notEmbedded}, nil))); err == nil { + t.Fatal("signer missing from certificates accepted") + } + txt := filepath.Join(t.TempDir(), "a.txt") + _ = os.WriteFile(txt, []byte("not a PE"), 0o644) + if _, err := Inspect(txt); err == nil { + t.Fatal("non-PE accepted") + } +} + +func TestSamePublisher(t *testing.T) { + sig := func(subject string, err error) func(string) (*Signature, error) { + return func(string) (*Signature, error) { + if err != nil { + return nil, err + } + return &Signature{Subject: subject, Timestamped: true}, nil + } + } + own := func(subject string, err error) func(string) (string, error) { + return func(string) (string, error) { return subject, err } + } + a := "CN=StepSecurity Inc" + if err := samePublisher("c", "s", sig(a, nil), own(a, nil), ""); err != nil { + t.Fatalf("same subject: %v", err) + } + if err := samePublisher("c", "s", sig("CN=Evil", nil), own(a, nil), ""); !errors.Is(err, ErrPublisherMismatch) { + t.Fatalf("other subject: %v", err) + } + if err := samePublisher("c", "s", sig(a, nil), own("", ErrUnsigned), ""); !errors.Is(err, ErrNoPublisher) { + t.Fatalf("unsigned self: %v", err) + } + if err := samePublisher("c", "s", sig(a, nil), own("", ErrUnsigned), a); err != nil { + t.Fatalf("unsigned self with pin: %v", err) + } + if err := samePublisher("c", "s", sig(a, nil), own(a, nil), "CN=Pinned"); !errors.Is(err, ErrPublisherMismatch) { + t.Fatalf("pin must win over own subject: %v", err) + } + if err := samePublisher("c", "s", sig("", ErrNotTimestamped), own(a, nil), ""); !errors.Is(err, ErrNotTimestamped) { + t.Fatalf("candidate error not propagated: %v", err) + } +} + +// makeLookalike is a certificate with leaf's issuer, serial and public key +// but another subject, signed by an unrelated key (so it never chains). +func makeLookalike(t *testing.T, leaf *x509.Certificate, cn string) *x509.Certificate { + t.Helper() + other, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + parent := &x509.Certificate{RawSubject: leaf.RawIssuer, PublicKey: &other.PublicKey, SerialNumber: big.NewInt(1)} + tmpl := &x509.Certificate{ + SerialNumber: leaf.SerialNumber, + Subject: pkix.Name{CommonName: cn}, + NotBefore: leaf.NotBefore, NotAfter: leaf.NotAfter, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, parent, leaf.PublicKey, other) + if err != nil { + t.Fatal(err) + } + c, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return c +} + +// R-C1-1 (belt and braces): a bag with a look-alike of the signer, before +// or after the real one, is rejected rather than resolved by bag order. +func TestInspectRejectsAmbiguousSigner(t *testing.T) { + leaf := makeCert(t, "Evil Corp", 9) + fake := makeLookalike(t, leaf, "StepSecurity Inc") + for _, bag := range [][]*x509.Certificate{{fake, leaf}, {leaf, fake}} { + p := writePE(t, buildSignedData(t, leaf, bag, oidRFC3161CounterSign)) + if sig, err := Inspect(p); !errors.Is(err, ErrAmbiguousSigner) { + t.Fatalf("bag %s,%s: Inspect = %+v, %v; want ErrAmbiguousSigner", bag[0].Subject, bag[1].Subject, sig, err) + } + } +} diff --git a/internal/authenticode/testkeys_off.go b/internal/authenticode/testkeys_off.go new file mode 100644 index 00000000..06ce9047 --- /dev/null +++ b/internal/authenticode/testkeys_off.go @@ -0,0 +1,5 @@ +//go:build !selfupdate_testkeys + +package authenticode + +func testPinnedSubject() string { return "" } diff --git a/internal/authenticode/testkeys_on.go b/internal/authenticode/testkeys_on.go new file mode 100644 index 00000000..ea22ce06 --- /dev/null +++ b/internal/authenticode/testkeys_on.go @@ -0,0 +1,14 @@ +//go:build selfupdate_testkeys + +package authenticode + +import ( + "os" + "strings" +) + +// testPinnedSubject is the expected publisher subject for test-signed +// fixtures (spec Contracts: test keys). +func testPinnedSubject() string { + return strings.TrimSpace(os.Getenv("DMG_TEST_AUTHENTICODE_SUBJECT")) +} diff --git a/internal/authenticode/verify_other.go b/internal/authenticode/verify_other.go new file mode 100644 index 00000000..fe2f10ba --- /dev/null +++ b/internal/authenticode/verify_other.go @@ -0,0 +1,5 @@ +//go:build !windows + +package authenticode + +func verifyTrusted(string) (*trustedSigner, error) { return nil, ErrUnsupported } diff --git a/internal/authenticode/verify_other_test.go b/internal/authenticode/verify_other_test.go new file mode 100644 index 00000000..0326bb33 --- /dev/null +++ b/internal/authenticode/verify_other_test.go @@ -0,0 +1,17 @@ +//go:build !windows + +package authenticode + +import ( + "errors" + "testing" +) + +func TestVerifyUnsupportedOffWindows(t *testing.T) { + if _, err := Verify("/bin/sh"); !errors.Is(err, ErrUnsupported) { + t.Fatalf("Verify = %v, want ErrUnsupported", err) + } + if err := SamePublisher("/bin/sh", "/bin/sh"); !errors.Is(err, ErrUnsupported) { + t.Fatalf("SamePublisher = %v, want ErrUnsupported", err) + } +} diff --git a/internal/authenticode/verify_windows.go b/internal/authenticode/verify_windows.go new file mode 100644 index 00000000..93ef3051 --- /dev/null +++ b/internal/authenticode/verify_windows.go @@ -0,0 +1,121 @@ +//go:build windows + +package authenticode + +import ( + "bytes" + "crypto/x509" + "errors" + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + trustENoSignature = 0x800B0100 // TRUST_E_NOSIGNATURE + trustESubjectFormUnk = 0x800B0003 // TRUST_E_SUBJECT_FORM_UNKNOWN +) + +// newTrustData builds the WINTRUST_DATA for a file: no UI, no revocation. +// For GENERIC_VERIFY_V2 revocation is off only with +// WTD_REVOCATION_CHECK_NONE in dwProvFlags (fdwRevocationChecks=WTD_REVOKE_NONE +// alone applies to HTTPSPROV_ACTION), so both are set. +func newTrustData(file *windows.WinTrustFileInfo) *windows.WinTrustData { + data := &windows.WinTrustData{ + UIChoice: windows.WTD_UI_NONE, + RevocationChecks: windows.WTD_REVOKE_NONE, + UnionChoice: windows.WTD_CHOICE_FILE, + StateAction: windows.WTD_STATEACTION_VERIFY, + ProvFlags: windows.WTD_REVOCATION_CHECK_NONE, + FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(file), + } + data.Size = uint32(unsafe.Sizeof(*data)) + return data +} + +var ( + modwintrust = windows.NewLazySystemDLL("wintrust.dll") + procWTHelperProvDataFromStateData = modwintrust.NewProc("WTHelperProvDataFromStateData") + procWTHelperGetProvSignerFromChain = modwintrust.NewProc("WTHelperGetProvSignerFromChain") +) + +// cryptProviderCert is the head of CRYPT_PROVIDER_CERT. +type cryptProviderCert struct { + _ uint32 // cbStruct + pCert *windows.CertContext +} + +// cryptProviderSgnr is CRYPT_PROVIDER_SGNR (wintrust.h). +type cryptProviderSgnr struct { + _ uint32 // cbStruct + _ windows.Filetime // sftVerifyAsOf + csCertChain uint32 + pasCertChain *cryptProviderCert + _ uint32 // dwSignerType + _ unsafe.Pointer // psSigner + _ uint32 // dwError + csCounterSigners uint32 + _ unsafe.Pointer // pasCounterSigners + _ unsafe.Pointer // pChainContext +} + +// verifyTrusted runs WINTRUST_ACTION_GENERIC_VERIFY_V2 on path (no UI, no +// revocation) and, while the state data is still open, reads the primary +// signer's validated chain: the leaf certificate's subject and the +// countersigner count. Chain building may still fetch missing roots through +// Windows root auto-update. +func verifyTrusted(path string) (*trustedSigner, error) { + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + file := &windows.WinTrustFileInfo{FilePath: p} + file.Size = uint32(unsafe.Sizeof(*file)) + data := newTrustData(file) + verr := windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, data) + defer func() { + data.StateAction = windows.WTD_STATEACTION_CLOSE + _ = windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, data) + }() + if verr != nil { + var errno windows.Errno + if errors.As(verr, &errno) && (uint32(errno) == trustENoSignature || uint32(errno) == trustESubjectFormUnk) { + return nil, fmt.Errorf("%w: %s", ErrUnsigned, path) + } + return nil, fmt.Errorf("%w: %s: %w", ErrUntrusted, path, verr) + } + return validatedSigner(data.StateData, path) +} + +func validatedSigner(state windows.Handle, path string) (*trustedSigner, error) { + if err := procWTHelperProvDataFromStateData.Find(); err != nil { + return nil, err + } + provData, _, _ := procWTHelperProvDataFromStateData.Call(uintptr(state)) + if provData == 0 { + return nil, fmt.Errorf("%w: %s: no provider data", ErrUntrusted, path) + } + sg, _, _ := procWTHelperGetProvSignerFromChain.Call(provData, 0, 0, 0) + if sg == 0 { + return nil, fmt.Errorf("%w: %s: no signer in provider data", ErrUntrusted, path) + } + // Pointer returned by wintrust, valid until WTD_STATEACTION_CLOSE. + sgnr := *(**cryptProviderSgnr)(unsafe.Pointer(&sg)) + if sgnr.csCertChain == 0 || sgnr.pasCertChain == nil || sgnr.pasCertChain.pCert == nil { + return nil, fmt.Errorf("%w: %s: signer has no certificate chain", ErrUntrusted, path) + } + leaf := sgnr.pasCertChain.pCert + der := bytes.Clone(unsafe.Slice(leaf.EncodedCert, leaf.Length)) + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, fmt.Errorf("%w: %s: leaf certificate: %w", ErrUntrusted, path, err) + } + return &trustedSigner{subject: cert.Subject.String(), counterSigners: int(sgnr.csCounterSigners)}, nil +} + +// winVerifyTrust reports only whether path verifies. +func winVerifyTrust(path string) error { + _, err := verifyTrusted(path) + return err +} diff --git a/internal/authenticode/verify_windows_test.go b/internal/authenticode/verify_windows_test.go new file mode 100644 index 00000000..47c4d737 --- /dev/null +++ b/internal/authenticode/verify_windows_test.go @@ -0,0 +1,360 @@ +//go:build windows + +package authenticode + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/binary" + "errors" + "fmt" + "io" + "math/big" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +// testCert is a self-signed code-signing cert in LocalMachine\My, trusted +// through LocalMachine\Root and TrustedPublisher for the test's duration. +type testCert struct { + thumb string + subject string +} + +// psCommand runs Windows PowerShell without the caller's PSModulePath: on +// GitHub runners it points at PowerShell 7 modules, which hides the Cert: +// drive from Windows PowerShell 5.1. +func psCommand(script string) *exec.Cmd { + cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script) + for _, kv := range os.Environ() { + if !strings.HasPrefix(strings.ToUpper(kv), "PSMODULEPATH=") { + cmd.Env = append(cmd.Env, kv) + } + } + return cmd +} + +func powershell(t *testing.T, script string) string { + t.Helper() + out, err := psCommand("$ErrorActionPreference='Stop'; " + script).CombinedOutput() + if err != nil { + t.Fatalf("powershell: %v\n%s\nscript: %s", err, out, script) + } + return strings.TrimSpace(string(out)) +} + +func newTestCert(t *testing.T, cn string) *testCert { + t.Helper() + subject := fmt.Sprintf("CN=%s %d", cn, time.Now().UnixNano()) + thumb := powershell(t, fmt.Sprintf(` +$c = New-SelfSignedCertificate -Type CodeSigningCert -Subject '%s' -CertStoreLocation Cert:\LocalMachine\My -NotAfter (Get-Date).AddDays(2) +$f = Join-Path $env:TEMP ($c.Thumbprint + '.cer') +Export-Certificate -Cert $c -FilePath $f | Out-Null +Import-Certificate -FilePath $f -CertStoreLocation Cert:\LocalMachine\Root | Out-Null +Import-Certificate -FilePath $f -CertStoreLocation Cert:\LocalMachine\TrustedPublisher | Out-Null +Remove-Item $f +$c.Thumbprint`, subject)) + t.Cleanup(func() { + for _, store := range []string{"My", "Root", "TrustedPublisher"} { + _ = psCommand(fmt.Sprintf(`Remove-Item -Force -ErrorAction SilentlyContinue Cert:\LocalMachine\%s\%s`, store, thumb)).Run() + } + }) + return &testCert{thumb: thumb, subject: subject} +} + +// sign signs path; tsa empty means no timestamp. It reports false when the +// timestamp server could not be reached. +func (c *testCert) sign(t *testing.T, path, tsa string) bool { + t.Helper() + ts := "" + if tsa != "" { + ts = "-TimestampServer '" + tsa + "'" + } + out, err := psCommand(fmt.Sprintf(` +$c = Get-Item Cert:\LocalMachine\My\%s +$r = Set-AuthenticodeSignature -FilePath '%s' -Certificate $c -HashAlgorithm SHA256 %s +$r.Status`, c.thumb, path, ts)).CombinedOutput() + status := strings.TrimSpace(string(out)) + if err != nil || status != "Valid" { + if tsa != "" { + t.Logf("timestamped signing unavailable (%v): %s", err, status) + return false + } + t.Fatalf("signing %s: %v: %s", path, err, status) + } + return true +} + +func copyExe(t *testing.T) string { + t.Helper() + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + in, err := os.Open(self) + if err != nil { + t.Fatal(err) + } + defer in.Close() + p := filepath.Join(t.TempDir(), "candidate.exe") + out, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + if _, err := io.Copy(out, in); err != nil { + t.Fatal(err) + } + if err := out.Close(); err != nil { + t.Fatal(err) + } + return p +} + +func requireElevated(t *testing.T) { + t.Helper() + if !windows.GetCurrentProcessToken().IsElevated() { + t.Skip("needs an elevated token to trust a test certificate machine-wide") + } +} + +func TestVerifyUnsignedWindows(t *testing.T) { + if _, err := Verify(copyExe(t)); !errors.Is(err, ErrUnsigned) { + t.Fatalf("unsigned: %v, want ErrUnsigned", err) + } + // The test binary is unsigned, so without a pin no update is allowed. + if testPinnedSubject() == "" { + self, _ := os.Executable() + if err := samePublisher(copyExe(t), self, func(string) (*Signature, error) { return &Signature{Subject: "CN=x"}, nil }, ownSubject, ""); !errors.Is(err, ErrNoPublisher) { + t.Fatalf("unsigned self: %v, want ErrNoPublisher", err) + } + } +} + +func TestVerifySignedWindows(t *testing.T) { + requireElevated(t) + a := newTestCert(t, "DMG Test Publisher A") + + noTS := copyExe(t) + a.sign(t, noTS, "") + if _, err := Verify(noTS); !errors.Is(err, ErrNotTimestamped) { + t.Fatalf("untimestamped: %v, want ErrNotTimestamped", err) + } + if sig, err := Inspect(noTS); err != nil || sig.Subject != a.subject { + t.Fatalf("Inspect = %+v, %v; want subject %q", sig, err, a.subject) + } + + // A flipped byte after signing breaks the digest. + tampered := copyExe(t) + a.sign(t, tampered, "") + f, err := os.OpenFile(tampered, os.O_RDWR, 0) + if err != nil { + t.Fatal(err) + } + b := make([]byte, 1) + _, _ = f.ReadAt(b, 4096) + b[0] ^= 0xff + _, _ = f.WriteAt(b, 4096) + _ = f.Close() + if err := winVerifyTrust(tampered); !errors.Is(err, ErrUntrusted) { + t.Fatalf("tampered: %v, want ErrUntrusted", err) + } + + const tsa = "http://timestamp.digicert.com" + good := copyExe(t) + if !a.sign(t, good, tsa) { + t.Skip("timestamp server unreachable; timestamped cases not run") + } + sig, err := Verify(good) + if err != nil { + t.Fatalf("timestamped signed: %v", err) + } + if sig.Subject != a.subject || !sig.Timestamped { + t.Fatalf("sig = %+v", sig) + } + self := copyExe(t) + a.sign(t, self, tsa) + if err := SamePublisher(good, self); err != nil && testPinnedSubject() == "" { + t.Fatalf("same publisher: %v", err) + } + + bCert := newTestCert(t, "DMG Test Publisher B") + wrong := copyExe(t) + if !bCert.sign(t, wrong, tsa) { + t.Skip("timestamp server unreachable") + } + if err := SamePublisher(wrong, self); !errors.Is(err, ErrPublisherMismatch) { + t.Fatalf("wrong publisher: %v, want ErrPublisherMismatch", err) + } + if err := samePublisher(good, copyExe(t), Verify, ownSubject, a.subject); err != nil { + t.Fatalf("pinned subject: %v", err) + } +} + +// R-C1-2: for GENERIC_VERIFY_V2, revocation is only off with +// WTD_REVOCATION_CHECK_NONE in the provider flags. +func TestTrustDataDisablesRevocation(t *testing.T) { + file := &windows.WinTrustFileInfo{} + d := newTrustData(file) + if d.ProvFlags&windows.WTD_REVOCATION_CHECK_NONE == 0 || d.RevocationChecks != windows.WTD_REVOKE_NONE { + t.Fatalf("ProvFlags=%#x RevocationChecks=%d", d.ProvFlags, d.RevocationChecks) + } + if d.UIChoice != windows.WTD_UI_NONE || d.StateAction != windows.WTD_STATEACTION_VERIFY { + t.Fatalf("data = %+v", d) + } +} + +// rewriteBag inserts extra into the PKCS#7 certificate bag of a signed PE +// (before or after the existing certs). The bag is outside the Authenticode +// digest, so the signature itself still verifies. +func rewriteBag(t *testing.T, path string, extra *x509.Certificate, before bool) *x509.Certificate { + t.Helper() + img, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + lfanew := binary.LittleEndian.Uint32(img[0x3c:]) + opt := int(lfanew) + 24 + ddOff := opt + 96 + if binary.LittleEndian.Uint16(img[opt:]) == 0x20b { + ddOff = opt + 112 + } + entry := ddOff + imageDirectoryEntrySec*8 + off := binary.LittleEndian.Uint32(img[entry:]) + blob, err := peSignature(path) + if err != nil { + t.Fatal(err) + } + var ci contentInfo + if _, err := asn1.Unmarshal(blob, &ci); err != nil { + t.Fatal(err) + } + var sd signedData + if _, err := asn1.Unmarshal(ci.Content.Bytes, &sd); err != nil { + t.Fatal(err) + } + certs, err := x509.ParseCertificates(sd.Certificates.Bytes) + if err != nil { + t.Fatal(err) + } + var si signerInfo + if _, err := asn1.Unmarshal(sd.SignerInfos.Bytes, &si); err != nil { + t.Fatal(err) + } + leaf := findSigner(certs, si.SID)[0] + if extra == nil { + return leaf + } + bag := sd.Certificates.Bytes + if before { + bag = append(append([]byte{}, extra.Raw...), bag...) + } else { + bag = append(append([]byte{}, bag...), extra.Raw...) + } + sd.Certificates = asn1.RawValue{Class: asn1.ClassContextSpecific, Tag: 0, IsCompound: true, Bytes: bag} + sdDER, err := asn1.Marshal(sd) + if err != nil { + t.Fatal(err) + } + inner, _ := asn1.Marshal(asn1.RawValue{Class: asn1.ClassContextSpecific, Tag: 0, IsCompound: true, Bytes: sdDER}) + der, err := asn1.Marshal(contentInfo{ContentType: oidSignedData, Content: asn1.RawValue{FullBytes: inner}}) + if err != nil { + t.Fatal(err) + } + wc := make([]byte, 8+len(der)) + binary.LittleEndian.PutUint32(wc[0:], uint32(len(wc))) + binary.LittleEndian.PutUint16(wc[4:], 0x0200) + binary.LittleEndian.PutUint16(wc[6:], winCertTypePKCSSigned) + copy(wc[8:], der) + for len(wc)%8 != 0 { + wc = append(wc, 0) + } + out := append(img[:off:off], wc...) + binary.LittleEndian.PutUint32(out[entry+4:], uint32(len(wc))) + if err := os.WriteFile(path, out, 0o755); err != nil { + t.Fatal(err) + } + return leaf +} + +func makeLookalikeWin(t *testing.T, leaf *x509.Certificate, cn string) *x509.Certificate { + t.Helper() + other, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + parent := &x509.Certificate{RawSubject: leaf.RawIssuer, PublicKey: &other.PublicKey, SerialNumber: big.NewInt(1)} + tmpl := &x509.Certificate{SerialNumber: leaf.SerialNumber, Subject: pkix.Name{CommonName: cn}, + NotBefore: leaf.NotBefore, NotAfter: leaf.NotAfter, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}} + der, err := x509.CreateCertificate(rand.Reader, tmpl, parent, leaf.PublicKey, other) + if err != nil { + t.Fatal(err) + } + c, _ := x509.ParseCertificate(der) + return c +} + +// R-C1-1: a timestamped binary signed by publisher B whose bag also carries +// a forged look-alike of B's cert with A's subject must fail a pin on A's +// subject, wherever the forgery sits; and the subject Verify reports for a +// clean file comes from the chain WinVerifyTrust validated. +func TestForgedBagFailsPublisherPin(t *testing.T) { + requireElevated(t) + const tsa = "http://timestamp.digicert.com" + a := newTestCert(t, "DMG Test Publisher A") + b := newTestCert(t, "DMG Test Publisher B") + clean := copyExe(t) + if !b.sign(t, clean, tsa) { + t.Skip("timestamp server unreachable") + } + ts, err := verifyTrusted(clean) + if err != nil { + t.Fatal(err) + } + t.Logf("validated subject %q, countersigners %d", ts.subject, ts.counterSigners) + if ts.subject != b.subject { + t.Fatalf("provider subject %q, want %q", ts.subject, b.subject) + } + for _, before := range []bool{true, false} { + f := copyExe(t) + b.sign(t, f, tsa) + leaf := rewriteBag(t, f, nil, false) + rewriteBag(t, f, makeLookalikeWin(t, leaf, strings.TrimPrefix(a.subject, "CN=")), before) + if tsF, err := verifyTrusted(f); err == nil && tsF.subject != b.subject { + t.Fatalf("before=%v: WinVerifyTrust validated %q, the forged subject", before, tsF.subject) + } + if err := samePublisher(f, "", Verify, ownSubject, a.subject); err == nil { + t.Fatalf("before=%v: forged bag passed the publisher pin", before) + } else { + t.Logf("before=%v: refused: %v", before, err) + } + } +} + +// FR1-8: seed adoption follows the publisher rule; only an unsigned (dev) +// running binary, which has nothing to compare, accepts any seed. +func TestSameSeedPublisher(t *testing.T) { + if testPinnedSubject() != "" { + t.Skip("pinned subject build") + } + unsignedSelf, unsignedSeed := copyExe(t), copyExe(t) + if err := SameSeedPublisher(unsignedSeed, unsignedSelf); err != nil { + t.Fatalf("unsigned self: %v", err) + } + requireElevated(t) + a := newTestCert(t, "DMG Test Publisher A") + signedSelf := copyExe(t) + a.sign(t, signedSelf, "") + if err := SameSeedPublisher(unsignedSeed, signedSelf); err == nil { + t.Fatal("signed self accepted an unsigned seed") + } +} diff --git a/internal/awakeclock/awakeclock.go b/internal/awakeclock/awakeclock.go new file mode 100644 index 00000000..44393c2c --- /dev/null +++ b/internal/awakeclock/awakeclock.go @@ -0,0 +1,49 @@ +// Package awakeclock measures time the system was awake. Watchdogs use it +// so a laptop asleep past a deadline isn't treated as a hung process. +package awakeclock + +import "time" + +// SleptThreshold is how far wall time may run ahead of awake time before a +// wait counts as having spanned a sleep. +const SleptThreshold = 5 * time.Minute + +// Clock reads awake time (excluding system sleep) and wall time. +type Clock interface { + Awake() time.Duration + Wall() time.Time +} + +// System is the real clock. +var System Clock = systemClock{} + +type systemClock struct{} + +func (systemClock) Wall() time.Time { return time.Now() } + +// Deadline is a start point on a Clock. +type Deadline struct { + clk Clock + a0 time.Duration + w0 time.Time +} + +// Begin starts a deadline now. +func Begin(clk Clock) Deadline { return Deadline{clk: clk, a0: clk.Awake(), w0: clk.Wall()} } + +// Wait blocks until limit of awake time has passed since Begin (fired) or +// done is closed, polling every step. slept reports that wall time ran +// ahead of awake time by more than SleptThreshold meanwhile. +func (d Deadline) Wait(limit time.Duration, done <-chan struct{}, step time.Duration) (fired, slept bool) { + for { + elapsed := d.clk.Awake() - d.a0 + if elapsed >= limit { + return true, d.clk.Wall().Sub(d.w0)-elapsed > SleptThreshold + } + select { + case <-done: + return false, false + case <-time.After(min(step, limit-elapsed)): + } + } +} diff --git a/internal/awakeclock/awakeclock_other.go b/internal/awakeclock/awakeclock_other.go new file mode 100644 index 00000000..7627f8ff --- /dev/null +++ b/internal/awakeclock/awakeclock_other.go @@ -0,0 +1,10 @@ +//go:build !windows + +package awakeclock + +import "time" + +var processStart = time.Now() + +// Awake is the monotonic clock: it excludes suspend on Linux and macOS. +func (systemClock) Awake() time.Duration { return time.Since(processStart) } diff --git a/internal/awakeclock/awakeclock_test.go b/internal/awakeclock/awakeclock_test.go new file mode 100644 index 00000000..f4720c8d --- /dev/null +++ b/internal/awakeclock/awakeclock_test.go @@ -0,0 +1,80 @@ +package awakeclock + +import ( + "sync" + "testing" + "time" +) + +type fakeClock struct { + mu sync.Mutex + awake time.Duration + wall time.Time +} + +func (f *fakeClock) Awake() time.Duration { f.mu.Lock(); defer f.mu.Unlock(); return f.awake } +func (f *fakeClock) Wall() time.Time { f.mu.Lock(); defer f.mu.Unlock(); return f.wall } +func (f *fakeClock) set(awake, wall time.Duration) { + f.mu.Lock() + defer f.mu.Unlock() + f.awake, f.wall = awake, time.Unix(0, 0).Add(wall) +} + +type result struct{ fired, slept bool } + +func start(c *fakeClock, limit time.Duration, done chan struct{}) <-chan result { + out := make(chan result, 1) + d := Begin(c) + go func() { + f, s := d.Wait(limit, done, time.Millisecond) + out <- result{f, s} + }() + return out +} + +// FR1-3: the deadline counts awake time only; a laptop asleep longer than +// the limit doesn't fire it, and a firing after a sleep says so. +func TestWaitCountsAwakeTimeAndReportsSleep(t *testing.T) { + c := &fakeClock{} + c.set(0, 0) + res := start(c, 4*time.Hour, nil) + c.set(3*time.Hour, 5*time.Hour) // 2 h asleep: 5 h of wall, 3 h awake + select { + case r := <-res: + t.Fatalf("fired after 3 h awake (5 h wall): %+v", r) + case <-time.After(50 * time.Millisecond): + } + c.set(4*time.Hour, 6*time.Hour) + select { + case r := <-res: + if !r.fired || !r.slept { + t.Fatalf("got %+v, want fired after a sleep", r) + } + case <-time.After(5 * time.Second): + t.Fatal("did not fire at 4 h awake") + } +} + +func TestWaitWithoutSleep(t *testing.T) { + c := &fakeClock{} + c.set(0, 0) + res := start(c, time.Hour, nil) + c.set(time.Hour, time.Hour+time.Minute) + if r := <-res; !r.fired || r.slept { + t.Fatalf("got %+v, want fired, not slept", r) + } + done := make(chan struct{}) + res = start(c, 10*time.Hour, done) + close(done) + if r := <-res; r.fired { + t.Fatalf("fired after done: %+v", r) + } +} + +func TestSystemClockAdvances(t *testing.T) { + a := System.Awake() + time.Sleep(20 * time.Millisecond) + if System.Awake() <= a { + t.Fatal("awake clock did not advance") + } +} diff --git a/internal/awakeclock/awakeclock_windows.go b/internal/awakeclock/awakeclock_windows.go new file mode 100644 index 00000000..2eb0ddc0 --- /dev/null +++ b/internal/awakeclock/awakeclock_windows.go @@ -0,0 +1,23 @@ +//go:build windows + +package awakeclock + +import ( + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +var procQueryUnbiasedInterruptTime = windows.NewLazySystemDLL("kernel32.dll").NewProc("QueryUnbiasedInterruptTime") + +// Awake is QueryUnbiasedInterruptTime (100 ns units since boot, excluding +// sleep and hibernation). Go's own monotonic clock on Windows includes +// sleep, so it can't be used for watchdog deadlines. +func (systemClock) Awake() time.Duration { + var t uint64 + if r, _, _ := procQueryUnbiasedInterruptTime.Call(uintptr(unsafe.Pointer(&t))); r == 0 { + return time.Duration(time.Now().UnixNano()) // unavailable: fall back to wall time + } + return time.Duration(t) * 100 // #nosec G115 -- interrupt time fits int64 ns for centuries +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 224bc960..dd65711a 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "slices" + "strconv" "strings" "github.com/step-security/dev-machine-guard/internal/buildinfo" @@ -107,6 +108,7 @@ type Config struct { ConfigAPIEndpoint string // --api-endpoint ConfigAPIKey string // --api-key (also accepts env var DMG_API_KEY) ConfigScanFrequency string // --scan-frequency (hours) + ConfigAutoUpdate *bool // --auto-update=true|false (Windows self-update) // IgnoreTelemetryError opts the `install` subcommand into treating an // initial-telemetry POST failure as a warning rather than a fatal exit. @@ -342,6 +344,12 @@ func Parse(args []string) (*Config, error) { cfg.ConfigScanFrequency = args[i] case strings.HasPrefix(arg, "--scan-frequency="): cfg.ConfigScanFrequency = strings.TrimPrefix(arg, "--scan-frequency=") + case strings.HasPrefix(arg, "--auto-update="): + v, err := strconv.ParseBool(strings.TrimPrefix(arg, "--auto-update=")) + if err != nil { + return nil, fmt.Errorf("--auto-update must be true or false (also 1 or 0)") + } + cfg.ConfigAutoUpdate = &v case arg == "--verbose": cfg.Verbose = true case arg == "--override-gate": @@ -639,6 +647,7 @@ Non-interactive configure (for MSI / SCCM / Intune deployments): --api-endpoint URL StepSecurity backend URL --api-key KEY Authentication key (or set DMG_API_KEY env var) --scan-frequency HOURS Scheduled scan frequency + --auto-update=BOOL Windows MSI self-update on (default) or off --ignore-telemetry-error On 'install', treat a failed initial telemetry POST as a warning instead of a fatal exit (use in MSI custom actions to avoid rollback on transient network) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 3bee2c4f..77da39e1 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -447,3 +447,16 @@ func TestParseHooks_AcceptsInstallDir(t *testing.T) { // See internal/aiagents/cli/hook_test.go for handler-level tests and // cmd/stepsecurity-dev-machine-guard/main_test.go for the integration // test that asserts the binary always exits 0 on `_hook` invocations. + +func TestParse_ConfigureAutoUpdate(t *testing.T) { + cfg, err := Parse([]string{"configure", "--non-interactive", "--auto-update=false"}) + if err != nil { + t.Fatal(err) + } + if cfg.ConfigAutoUpdate == nil || *cfg.ConfigAutoUpdate { + t.Fatalf("ConfigAutoUpdate = %v", cfg.ConfigAutoUpdate) + } + if _, err := Parse([]string{"configure", "--auto-update=maybe"}); err == nil { + t.Fatal("invalid --auto-update accepted") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 893fda9e..abaf5abd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "runtime" "strings" "sync" @@ -73,6 +74,18 @@ var ( // telemetry.ExecutionDeadline. var MaxExecutionDuration string +// Windows self-update settings (internal/selfupdate), read from the +// machine config the SYSTEM updater trusts. AutoUpdate nil means the +// default (on for MSI installs); `configure --auto-update=false` (the MSI +// AUTOUPDATE=0 property) turns it off. The other three mirror the loader's +// update-policy knobs and optional HTTPS packages mirror. +var ( + AutoUpdate *bool + UpdateMirrorURL string + UpdateLagBehind int + UpdateCooldownHours int +) + // ConfigFile is the JSON structure persisted to ~/.stepsecurity/config.json. type ConfigFile struct { DeviceID string `json:"device_id,omitempty"` @@ -94,6 +107,72 @@ type ConfigFile struct { MaxExecutionDuration string `json:"max_execution_duration,omitempty"` UseLegacyNodeScan *bool `json:"use_legacy_node_scan,omitempty"` UseLegacyPythonScan *bool `json:"use_legacy_python_scan,omitempty"` + AutoUpdate *bool `json:"auto_update,omitempty"` + UpdateMirrorURL string `json:"update_mirror_url,omitempty"` + UpdateLagBehind int `json:"update_lag_behind,omitempty"` + UpdateCooldownHours int `json:"update_cooldown_hours,omitempty"` + + // extra keeps keys this build doesn't know, so a save never drops what + // a newer agent or the admin wrote. + extra map[string]json.RawMessage +} + +// knownConfigKeys are ConfigFile's JSON keys. +var knownConfigKeys = func() map[string]bool { + keys := map[string]bool{} + t := reflect.TypeOf(ConfigFile{}) + for i := 0; i < t.NumField(); i++ { + if name, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ","); name != "" && name != "-" { + keys[name] = true + } + } + return keys +}() + +// retiredConfigKeys are keys earlier builds wrote that are dropped on load. +var retiredConfigKeys = map[string]bool{"use_legacy_package_scan": true} + +// UnmarshalJSON decodes into c field by field: keys absent from data leave +// c's values as they are (configure --from-file relies on this to merge), +// and unknown keys are kept for MarshalJSON. +func (c *ConfigFile) UnmarshalJSON(data []byte) error { + type plain ConfigFile + if err := json.Unmarshal(data, (*plain)(c)); err != nil { + return err + } + var all map[string]json.RawMessage + if err := json.Unmarshal(data, &all); err != nil { + return err + } + for k, v := range all { + if knownConfigKeys[k] || retiredConfigKeys[k] { + continue + } + if c.extra == nil { + c.extra = map[string]json.RawMessage{} + } + c.extra[k] = v + } + return nil +} + +// MarshalJSON writes the known fields plus any unknown keys read earlier. +func (c ConfigFile) MarshalJSON() ([]byte, error) { + type plain ConfigFile + b, err := json.Marshal(plain(c)) + if err != nil || len(c.extra) == 0 { + return b, err + } + var m map[string]json.RawMessage + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + for k, v := range c.extra { + if _, ok := m[k]; !ok { + m[k] = v + } + } + return json.Marshal(m) } // userConfigDir returns ~/.stepsecurity — the per-user config location. @@ -272,6 +351,18 @@ func Load() { if cfg.UseLegacyPythonScan != nil { UseLegacyPythonScan = *cfg.UseLegacyPythonScan } + if cfg.AutoUpdate != nil && AutoUpdate == nil { + AutoUpdate = cfg.AutoUpdate + } + if cfg.UpdateMirrorURL != "" && UpdateMirrorURL == "" { + UpdateMirrorURL = cfg.UpdateMirrorURL + } + if cfg.UpdateLagBehind > 0 && UpdateLagBehind == 0 { + UpdateLagBehind = cfg.UpdateLagBehind + } + if cfg.UpdateCooldownHours > 0 && UpdateCooldownHours == 0 { + UpdateCooldownHours = cfg.UpdateCooldownHours + } } // IsEnterpriseMode returns true if valid enterprise credentials are configured. @@ -793,6 +884,7 @@ type NonInteractiveOptions struct { APIKey string // --api-key (or DMG_API_KEY env) ScanFrequency string // --scan-frequency (hours, as string) SearchDirs []string // --search-dirs (re-used from scan flag; empty = unchanged) + AutoUpdate *bool // --auto-update=true|false (nil = unchanged) } // HasAny reports whether the caller supplied any inline value. Used by @@ -805,7 +897,8 @@ func (o NonInteractiveOptions) HasAny() bool { o.APIEndpoint != "" || o.APIKey != "" || o.ScanFrequency != "" || - len(o.SearchDirs) > 0 + len(o.SearchDirs) > 0 || + o.AutoUpdate != nil } // RunConfigureNonInteractive applies the supplied options to the existing @@ -817,9 +910,11 @@ func (o NonInteractiveOptions) HasAny() bool { func RunConfigureNonInteractive(opts NonInteractiveOptions) error { existing := loadForConfigure() - // --from-file: replace base. Inline flags still override individual - // fields below, so customers can ship a base config via the file and - // inject the per-tenant API key via an env var on the command line. + // --from-file merges: keys the file carries override the existing + // config (credential rotation), keys it omits are kept (an AUTOUPDATE=0 + // opt-out, unknown keys). Inline flags still override individual fields + // below, so customers can ship a base config via the file and inject + // the per-tenant API key via an env var on the command line. if opts.FromFile != "" { data, err := os.ReadFile(opts.FromFile) if err != nil { @@ -829,7 +924,9 @@ func RunConfigureNonInteractive(opts NonInteractiveOptions) error { if err := json.Unmarshal(data, &fromFile); err != nil { return fmt.Errorf("parsing --from-file %q: %w", opts.FromFile, err) } - existing = &fromFile + if err := json.Unmarshal(data, existing); err != nil { + return fmt.Errorf("parsing --from-file %q: %w", opts.FromFile, err) + } } if opts.CustomerID != "" { @@ -847,6 +944,9 @@ func RunConfigureNonInteractive(opts NonInteractiveOptions) error { if len(opts.SearchDirs) > 0 { existing.SearchDirs = opts.SearchDirs } + if opts.AutoUpdate != nil { + existing.AutoUpdate = opts.AutoUpdate + } // Validation: an MSI deploy with no creds is almost certainly a bug. // Fail loud so the MSI transaction rolls back instead of silently diff --git a/internal/config/config_nonint_test.go b/internal/config/config_nonint_test.go index 51f164e8..d013df78 100644 --- a/internal/config/config_nonint_test.go +++ b/internal/config/config_nonint_test.go @@ -154,3 +154,51 @@ func TestNonInteractiveOptions_HasAny(t *testing.T) { t.Error("FromFile alone should be enough") } } + +// SCCM e2e B1: an MSI upgrade's --from-file must merge into the machine +// config, not replace it: an AUTOUPDATE=0 opt-out and unknown keys the +// bootstrap file doesn't mention survive, while keys it does carry +// (credential rotation) win. +func TestRunConfigureNonInteractive_FromFileMerges(t *testing.T) { + optOut := false + withHome(t) + if err := RunConfigureNonInteractive(NonInteractiveOptions{ + CustomerID: "cust", APIEndpoint: "https://old.example.com", APIKey: "sk-old", + AutoUpdate: &optOut, + }); err != nil { + t.Fatal(err) + } + path := ConfigFilePath() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + m["future_key"] = map[string]any{"x": 1.0} + raw, _ = json.Marshal(m) + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + + srcPath := filepath.Join(t.TempDir(), "bootstrap.json") + if err := os.WriteFile(srcPath, []byte(`{"customer_id":"cust","api_endpoint":"https://new.example.com","api_key":"sk-rotated"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := RunConfigureNonInteractive(NonInteractiveOptions{FromFile: srcPath}); err != nil { + t.Fatal(err) + } + cfg := loadExisting() + if cfg.AutoUpdate == nil || *cfg.AutoUpdate { + t.Fatalf("auto_update opt-out lost: %v", cfg.AutoUpdate) + } + if cfg.APIKey != "sk-rotated" || cfg.APIEndpoint != "https://new.example.com" { + t.Fatalf("bootstrap file keys not applied: %+v", cfg) + } + raw, _ = os.ReadFile(path) + if !strings.Contains(string(raw), `"future_key"`) { + t.Fatalf("unknown key dropped:\n%s", raw) + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4ebbc673..ec965ac5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -268,3 +268,17 @@ func TestDeviceIDConfig(t *testing.T) { }) } } + +func TestAutoUpdateConfigRoundTrip(t *testing.T) { + var cf ConfigFile + if err := json.Unmarshal([]byte(`{"auto_update":false,"update_mirror_url":"https://m","update_lag_behind":1,"update_cooldown_hours":2}`), &cf); err != nil { + t.Fatal(err) + } + if cf.AutoUpdate == nil || *cf.AutoUpdate || cf.UpdateMirrorURL != "https://m" || cf.UpdateLagBehind != 1 || cf.UpdateCooldownHours != 2 { + t.Fatalf("decoded %+v", cf) + } + off := false + if !(NonInteractiveOptions{AutoUpdate: &off}).HasAny() { + t.Fatal("--auto-update alone is not a non-interactive configure") + } +} diff --git a/internal/device/system_id.go b/internal/device/system_id.go new file mode 100644 index 00000000..1b5f61dd --- /dev/null +++ b/internal/device/system_id.go @@ -0,0 +1,76 @@ +package device + +import ( + "context" + "encoding/json" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/config" + "github.com/step-security/dev-machine-guard/internal/executor" +) + +// SystemProbeTimeout bounds the hardware probe (the WMI subprocess) for the +// update task, which isn't latency-sensitive. +const SystemProbeTimeout = 60 * time.Second + +// systemProbe is probeSerial; tests stub it. +var systemProbe = probeSerial + +// systemIDCache is the SYSTEM-only identity cache (state\device-id). +type systemIDCache struct { + DeviceID string `json:"device_id"` + Source string `json:"source"` // "hardware" | "configured" +} + +// SystemID is the device identity for the SYSTEM self-updater. probeTimeout +// bounds the hardware probe (the self-check passes a short one so its +// smoke test can't time out on a wedged WMI). It never +// reads the shared ProgramData device-id file, which users can write (M-9). +// Order: the configured device_id; the hardware probe (BIOS serial from the +// registry, then WMI); cachePath (state\device-id: protected, written only +// by SYSTEM, only from a hardware or configured result); MachineGuid. Only +// a hardware or configured result is cached, and only a hardware result +// rewrites the shared file, so a probe that fell back to MachineGuid never +// changes the identity user scans read (U4). "" when nothing is usable. +func SystemID(ctx context.Context, exec executor.Executor, cachePath string, probeTimeout time.Duration) string { + w, canWrite := exec.(idWriter) + cache := func(id, source string) { + if canWrite && cachePath != "" { + if b, err := json.Marshal(systemIDCache{DeviceID: id, Source: source}); err == nil { + _ = w.WriteFileAtomic(cachePath, b, 0o644) // best-effort + } + } + } + if id := strings.TrimSpace(config.DeviceID); id != "" { + cache(id, "configured") + return id + } + if probeTimeout <= 0 { + probeTimeout = SystemProbeTimeout + } + ctx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + id, hardware := systemProbe(ctx, exec) + if hardware && usableSerial(id) { + cache(id, "hardware") + if canWrite { + _ = w.WriteFileAtomic(machineIDFile(exec.GOOS()), []byte(id+"\n"), 0o644) // best-effort + } + return id + } + if cachePath != "" { + if data, err := exec.ReadFile(cachePath); err == nil && len(data) <= 4096 { + var c systemIDCache + // A configured id is only valid while the config still has it + // (config.DeviceID is empty here, so it was removed). + if json.Unmarshal(data, &c) == nil && usableSerial(c.DeviceID) && c.Source == "hardware" { + return c.DeviceID + } + } + } + if usableSerial(id) { + return id // MachineGuid: reported, never persisted + } + return "" +} diff --git a/internal/device/system_id_test.go b/internal/device/system_id_test.go new file mode 100644 index 00000000..aa6f5c16 --- /dev/null +++ b/internal/device/system_id_test.go @@ -0,0 +1,134 @@ +//go:build !windows + +package device + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/config" + "github.com/step-security/dev-machine-guard/internal/executor" +) + +const winIDFile = `C:\ProgramData\StepSecurity\device-id` + +func systemMock() *executor.Mock { + m := executor.NewMock() + m.SetGOOS("windows") + m.SetIsRoot(true) + m.SetFile(winIDFile, []byte("PLANTED-BY-USER\n")) + return m +} + +func stubProbe(t *testing.T, id string, hardware bool) { + t.Helper() + old := systemProbe + systemProbe = func(context.Context, executor.Executor) (string, bool) { return id, hardware } + t.Cleanup(func() { systemProbe = old }) +} + +func noConfiguredID(t *testing.T) { + old := config.DeviceID + config.DeviceID = "" + t.Cleanup(func() { config.DeviceID = old }) +} + +// R-C2-2: the SYSTEM id never comes from the user-writable shared file; a +// hardware result is written to both the SYSTEM cache and the shared file. +func TestSystemIDHardwareWritesBoth(t *testing.T) { + noConfiguredID(t) + cache := filepath.Join(`C:\Program Files\StepSecurity\state`, "device-id") + m := systemMock() + stubProbe(t, "HW-SERIAL-1", true) + if got := SystemID(context.Background(), m, cache, SystemProbeTimeout); got != "HW-SERIAL-1" { + t.Fatalf("SystemID = %q", got) + } + if b, _ := m.ReadFile(winIDFile); string(b) != "HW-SERIAL-1\n" { + t.Fatalf("shared file = %q", b) + } + if b, _ := m.ReadFile(cache); len(b) == 0 { + t.Fatal("SYSTEM cache not written") + } +} + +// R-C2-10: a failed hardware probe uses the SYSTEM cache (from an earlier +// hardware result) and leaves the shared file alone. +func TestSystemIDProbeFailureUsesCache(t *testing.T) { + noConfiguredID(t) + cache := `C:\Program Files\StepSecurity\state\device-id` + m := systemMock() + m.SetFile(cache, []byte(`{"device_id":"HW-CACHED","source":"hardware"}`)) + stubProbe(t, "guid-1", false) // WMI failed: only MachineGuid + if got := SystemID(context.Background(), m, cache, SystemProbeTimeout); got != "HW-CACHED" { + t.Fatalf("SystemID = %q, want the cached hardware id", got) + } + if b, _ := m.ReadFile(winIDFile); string(b) != "PLANTED-BY-USER\n" { + t.Fatalf("shared file rewritten from a fallback: %q", b) + } +} + +// R-C2-10: a first run that only reaches MachineGuid reports it but writes +// neither the shared file nor the cache. +func TestSystemIDMachineGuidWritesNothing(t *testing.T) { + noConfiguredID(t) + cache := `C:\Program Files\StepSecurity\state\device-id` + m := systemMock() + m.SetFile(winIDFile, nil) + stubProbe(t, "guid-1", false) + if got := SystemID(context.Background(), m, cache, SystemProbeTimeout); got != "guid-1" { + t.Fatalf("SystemID = %q", got) + } + if b, _ := m.ReadFile(winIDFile); len(b) != 0 { + t.Fatalf("shared file written from MachineGuid: %q", b) + } + if b, err := m.ReadFile(cache); err == nil && len(b) != 0 { + t.Fatalf("cache written from MachineGuid: %q", b) + } +} + +func TestSystemIDConfiguredWins(t *testing.T) { + old := config.DeviceID + config.DeviceID = "configured-1" + t.Cleanup(func() { config.DeviceID = old }) + stubProbe(t, "HW-1", true) + if got := SystemID(context.Background(), systemMock(), `C:\x\device-id`, SystemProbeTimeout); got != "configured-1" { + t.Fatalf("SystemID = %q", got) + } +} + +// FR1-1: a wedged WMI probe is bounded by the caller's timeout, then the +// SYSTEM cache answers (the self-check passes a short one). +func TestSystemIDProbeTimeoutFallsBackToCache(t *testing.T) { + noConfiguredID(t) + cache := `C:\Program Files\StepSecurity\state\device-id` + m := systemMock() + m.SetFile(cache, []byte(`{"device_id":"HW-CACHED","source":"hardware"}`)) + old := systemProbe + systemProbe = func(ctx context.Context, _ executor.Executor) (string, bool) { + <-ctx.Done() // WMI never answers + return "guid-1", false + } + t.Cleanup(func() { systemProbe = old }) + start := time.Now() + if got := SystemID(context.Background(), m, cache, 200*time.Millisecond); got != "HW-CACHED" { + t.Fatalf("SystemID = %q", got) + } + if d := time.Since(start); d > 5*time.Second { + t.Fatalf("probe not bounded by the caller's timeout: %s", d) + } +} + +// FR1-4: a configured id cached earlier is not used once the config no +// longer carries it. +func TestSystemIDDropsStaleConfiguredCache(t *testing.T) { + noConfiguredID(t) + cache := `C:\Program Files\StepSecurity\state\device-id` + m := systemMock() + m.SetFile(cache, []byte(`{"device_id":"OLD-CONFIGURED","source":"configured"}`)) + stubProbe(t, "guid-1", false) + if got := SystemID(context.Background(), m, cache, SystemProbeTimeout); got != "guid-1" { + t.Fatalf("SystemID = %q, want the chain past the stale configured id", got) + } +} diff --git a/internal/faultinject/diskfull_test.go b/internal/faultinject/diskfull_test.go new file mode 100644 index 00000000..88d7a02f --- /dev/null +++ b/internal/faultinject/diskfull_test.go @@ -0,0 +1,26 @@ +//go:build selfupdate_faults + +package faultinject + +import ( + "errors" + "runtime" + "syscall" + "testing" +) + +func TestDiskFullErrno(t *testing.T) { + t.Setenv("DMG_FAULT", "S4.manifest:diskfull") + err := Point("S4.manifest") + var errno syscall.Errno + if !errors.As(err, &errno) { + t.Fatalf("diskfull = %v, want a wrapped errno", err) + } + want := syscall.ENOSPC + if runtime.GOOS == "windows" { + want = syscall.Errno(112) // ERROR_DISK_FULL + } + if errno != want { + t.Fatalf("errno = %d, want %d", errno, want) + } +} diff --git a/internal/faultinject/faultinject.go b/internal/faultinject/faultinject.go new file mode 100644 index 00000000..4c2aea1c --- /dev/null +++ b/internal/faultinject/faultinject.go @@ -0,0 +1,20 @@ +// Package faultinject provides named checkpoints for self-update fault tests. +// +// Release builds compile Point to an inlinable no-op. Builds tagged +// selfupdate_faults read DMG_FAULT=[@user|@system]:[,...] +// (merged with the link-time Baked value) and act at the matching checkpoint. +// Actions: kill (os.Exit(137), no cleanup), err (return ErrInjected), hang +// (block until killed), diskfull (return an error wrapping the OS disk-full +// errno), panic (a real Go panic, so recovery paths run), sleep +// (block that long, Go duration syntax up to 10m, then continue), exit +// (os.Exit(N)). S6.mutex fires with the scan instance mutex held. With +// DMG_FAULT_TRACE=, every reached checkpoint name is appended to file. +// +// Checkpoint names may contain ':' (S4.downloaded:); the action is the +// text after the last ':'. +package faultinject + +import "errors" + +// ErrInjected is wrapped by every error Point returns for an injected fault. +var ErrInjected = errors.New("faultinject: injected fault") diff --git a/internal/faultinject/main_test.go b/internal/faultinject/main_test.go new file mode 100644 index 00000000..d16aa1c2 --- /dev/null +++ b/internal/faultinject/main_test.go @@ -0,0 +1,10 @@ +package faultinject + +import ( + "os" + "testing" + + "github.com/step-security/dev-machine-guard/internal/proctest" +) + +func TestMain(m *testing.M) { os.Exit(proctest.Main(m)) } diff --git a/internal/faultinject/os_other.go b/internal/faultinject/os_other.go new file mode 100644 index 00000000..9950a0b6 --- /dev/null +++ b/internal/faultinject/os_other.go @@ -0,0 +1,12 @@ +//go:build selfupdate_faults && !windows + +package faultinject + +import ( + "os" + "syscall" +) + +var errDiskFull error = syscall.ENOSPC + +func runningAsSystem() bool { return os.Geteuid() == 0 } diff --git a/internal/faultinject/os_windows.go b/internal/faultinject/os_windows.go new file mode 100644 index 00000000..d76b2083 --- /dev/null +++ b/internal/faultinject/os_windows.go @@ -0,0 +1,15 @@ +//go:build selfupdate_faults && windows + +package faultinject + +import "golang.org/x/sys/windows" + +var errDiskFull error = windows.ERROR_DISK_FULL + +func runningAsSystem() bool { + u, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return false + } + return u.User.Sid.IsWellKnown(windows.WinLocalSystemSid) +} diff --git a/internal/faultinject/point_off.go b/internal/faultinject/point_off.go new file mode 100644 index 00000000..7e8a6c6d --- /dev/null +++ b/internal/faultinject/point_off.go @@ -0,0 +1,9 @@ +//go:build !selfupdate_faults + +package faultinject + +// Enabled reports whether fault injection is compiled in. +const Enabled = false + +// Point is a no-op without the selfupdate_faults build tag. +func Point(string) error { return nil } diff --git a/internal/faultinject/point_off_test.go b/internal/faultinject/point_off_test.go new file mode 100644 index 00000000..60bdd6b4 --- /dev/null +++ b/internal/faultinject/point_off_test.go @@ -0,0 +1,16 @@ +//go:build !selfupdate_faults + +package faultinject + +import "testing" + +func TestPointIsNoOpWithoutTag(t *testing.T) { + t.Setenv("DMG_FAULT", "S0.locked:err,S0.locked:kill") + t.Setenv("DMG_FAULT_TRACE", t.TempDir()+"/trace") + if Enabled { + t.Fatal("Enabled = true without the selfupdate_faults tag") + } + if err := Point("S0.locked"); err != nil { + t.Fatalf("Point = %v, want nil", err) + } +} diff --git a/internal/faultinject/point_on.go b/internal/faultinject/point_on.go new file mode 100644 index 00000000..7b2e584c --- /dev/null +++ b/internal/faultinject/point_on.go @@ -0,0 +1,102 @@ +//go:build selfupdate_faults + +package faultinject + +import ( + "fmt" + "io" + "os" + "strconv" + "strings" + "time" +) + +// Enabled reports whether fault injection is compiled in. +const Enabled = true + +// Baked holds faults set at link time +// (-ldflags "-X github.com/step-security/dev-machine-guard/internal/faultinject.Baked="). +// It is merged with DMG_FAULT, Baked first. +var Baked string + +const ( + envFault = "DMG_FAULT" + envTrace = "DMG_FAULT_TRACE" +) + +// exit and isSystem are swapped by tests. +var ( + exit = os.Exit + isSystem = runningAsSystem + stderr io.Writer = os.Stderr +) + +// Point acts on the first fault configured for name, if any. A malformed +// DMG_FAULT or Baked spec ends the process with MalformedExitCode and the +// MalformedMarker on stderr: a typo must never let a fault test pass without +// injecting anything, nor pass for a crash (exit 2 is a Bad run, D5). +// Drivers call Validate before launching, so this is the last line. +func Point(name string) error { + trace(name) + rules, err := parse(Baked + "," + os.Getenv(envFault)) + if err != nil { + fmt.Fprintf(stderr, "%s %s/Baked: %v\n", MalformedMarker, envFault, err) + exit(MalformedExitCode) + return fmt.Errorf("%w: %v", ErrInjected, err) // only reached when exit is stubbed + } + for _, r := range rules { + if r.checkpoint != name { + continue + } + if r.principal != "" && (r.principal == "system") != isSystem() { + continue + } + return act(name, r.action) + } + return nil +} + +func act(name, action string) error { + switch action { + case "kill": + exit(137) + case "err": + return fmt.Errorf("%w: err at %s", ErrInjected, name) + case "diskfull": + return fmt.Errorf("%w: diskfull at %s: %w", ErrInjected, name, errDiskFull) + case "panic": + // A real Go panic, so recover() paths run as in a crash. + panic(fmt.Sprintf("faultinject: panic at %s", name)) + case "hang": + for { + time.Sleep(time.Hour) + } + default: + if d, ok := sleepAction(action); ok { + time.Sleep(d) // then continue normally + return nil + } + if n, ok := strings.CutPrefix(action, "exit"); ok { + if code, err := strconv.Atoi(n); err == nil && code >= 0 { + exit(code) + return nil // only reached when exit is stubbed + } + } + return fmt.Errorf("%w: unknown action %q at %s", ErrInjected, action, name) + } + return nil // only reached when exit is stubbed +} + +func trace(name string) { + path := os.Getenv(envTrace) + if path == "" { + return + } + // #nosec G304 -- test-only trace file chosen by the harness. + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return + } + _, _ = f.WriteString(name + "\n") + _ = f.Close() +} diff --git a/internal/faultinject/point_on_test.go b/internal/faultinject/point_on_test.go new file mode 100644 index 00000000..c637dee2 --- /dev/null +++ b/internal/faultinject/point_on_test.go @@ -0,0 +1,244 @@ +//go:build selfupdate_faults + +package faultinject + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/proctest" +) + +func stubExit(t *testing.T) *int { + t.Helper() + code := -1 + old := exit + exit = func(c int) { code = c } + t.Cleanup(func() { exit = old }) + return &code +} + +func stubSystem(t *testing.T, system bool) { + t.Helper() + old := isSystem + isSystem = func() bool { return system } + t.Cleanup(func() { isSystem = old }) +} + +func TestPointActions(t *testing.T) { + t.Setenv("DMG_FAULT", "S0.locked:err, S4.downloaded:agent.exe:diskfull,S5.smoked:exit3,S1.swept:kill,S2.evaluated:exit2") + if !Enabled { + t.Fatal("Enabled = false with the tag") + } + if err := Point("S3.target"); err != nil { + t.Fatalf("unconfigured checkpoint: %v", err) + } + if err := Point("S0.locked"); !errors.Is(err, ErrInjected) || !strings.Contains(err.Error(), "S0.locked") { + t.Fatalf("err action = %v", err) + } + err := Point("S4.downloaded:agent.exe") + if !errors.Is(err, ErrInjected) || !errors.Is(err, errDiskFull) { + t.Fatalf("diskfull action = %v", err) + } + if err := Point("S4.downloaded:task.exe"); err != nil { + t.Fatalf("other asset matched: %v", err) + } + for name, want := range map[string]int{"S5.smoked": 3, "S1.swept": 137, "S2.evaluated": 2} { + code := stubExit(t) + if err := Point(name); err != nil || *code != want { + t.Fatalf("%s: code %d err %v, want %d", name, *code, err, want) + } + } +} + +// R-F1-4/R-F1-7: a malformed spec fails loudly at first use with its own +// exit code and marker, never as a silent no-op and never as exit 2. +func TestPointMalformedSpecExits64(t *testing.T) { + for _, spec := range badSpecs { + t.Run(spec, func(t *testing.T) { + t.Setenv("DMG_FAULT", spec) + code := stubExit(t) + var buf strings.Builder + old := stderr + stderr = &buf + t.Cleanup(func() { stderr = old }) + if err := Point("S0.locked"); !errors.Is(err, ErrInjected) { + t.Fatalf("Point = %v", err) + } + if *code != MalformedExitCode || !strings.HasPrefix(buf.String(), MalformedMarker) { + t.Fatalf("exit %d, stderr %q", *code, buf.String()) + } + }) + } + old := Baked + t.Cleanup(func() { Baked = old }) + Baked = "S0.locked:typo" + t.Setenv("DMG_FAULT", "") + code := stubExit(t) + stderr = io.Discard + t.Cleanup(func() { stderr = os.Stderr }) + _ = Point("S0.locked") + if *code != MalformedExitCode { + t.Fatalf("malformed Baked: exit %d", *code) + } +} + +// A real process with a malformed spec exits 64 with the marker (not 2). +func TestMalformedSpecProcessExit(t *testing.T) { + cmd := proctest.Command(t, os.Args[0], "-test.run=^TestHelperProcess$") + cmd.Env = append(cmd.Env, "FAULTINJECT_HELPER=1", "FAULTINJECT_MARKER="+filepath.Join(t.TempDir(), "m"), "DMG_FAULT=S6.state-temp@sytem:kill") + out, _ := cmd.CombinedOutput() + if code := cmd.ProcessState.ExitCode(); code != MalformedExitCode || !strings.Contains(string(out), MalformedMarker) { + t.Fatalf("exit %d, output %q", code, out) + } +} + +func TestPointColonCheckpoint(t *testing.T) { + t.Setenv("DMG_FAULT", "S4.downloaded:agent.exe:err") + if err := Point("S4.downloaded:agent.exe"); !errors.Is(err, ErrInjected) { + t.Fatalf("Point = %v", err) + } + if err := Point("S4.downloaded:task.exe"); err != nil { + t.Fatalf("other asset fired: %v", err) + } +} + +func TestPointPrincipalQualifier(t *testing.T) { + t.Setenv("DMG_FAULT", "boot.resolved@system:err,boot.launched@user:err") + for _, system := range []bool{false, true} { + stubSystem(t, system) + if got := Point("boot.resolved") != nil; got != system { + t.Errorf("system=%v: @system fired=%v", system, got) + } + if got := Point("boot.launched") != nil; got == system { + t.Errorf("system=%v: @user fired=%v", system, got) + } + } +} + +func TestPointMergesBaked(t *testing.T) { + old := Baked + t.Cleanup(func() { Baked = old }) + Baked = "agent.prestart:err" + t.Setenv("DMG_FAULT", "S7.reported:err") + if Point("agent.prestart") == nil || Point("S7.reported") == nil { + t.Fatal("Baked and DMG_FAULT must both apply") + } + t.Setenv("DMG_FAULT", "") + if Point("agent.prestart") == nil { + t.Fatal("Baked must apply without DMG_FAULT") + } +} + +func TestPointTrace(t *testing.T) { + trace := filepath.Join(t.TempDir(), "trace") + t.Setenv("DMG_FAULT_TRACE", trace) + t.Setenv("DMG_FAULT", "S6.mutex:err") + for _, n := range []string{"S0.locked", "S4.downloaded:agent.exe", "S6.mutex"} { + _ = Point(n) + } + got, err := os.ReadFile(trace) + if err != nil { + t.Fatal(err) + } + if want := "S0.locked\nS4.downloaded:agent.exe\nS6.mutex\n"; string(got) != want { + t.Fatalf("trace = %q, want %q", got, want) + } + t.Setenv("DMG_FAULT_TRACE", filepath.Join(t.TempDir(), "missing", "trace")) + if err := Point("S0.locked"); err != nil { + t.Fatalf("unwritable trace must not fail Point: %v", err) + } +} + +// TestHelperProcess is the subprocess body for the kill and hang tests. +func TestHelperProcess(t *testing.T) { + if os.Getenv("FAULTINJECT_HELPER") != "1" { + t.Skip("helper") + } + proctest.Guard() + os.Stdout.WriteString("ready\n") + _ = Point("S6.state-temp") + // Reached only if Point returned: stands in for caller cleanup. + _ = os.WriteFile(os.Getenv("FAULTINJECT_MARKER"), []byte("returned"), 0o600) + os.Exit(0) +} + +func helper(t *testing.T, fault string) (*exec.Cmd, *bufio.Reader, string) { + t.Helper() + if err := Validate(fault); err != nil { + t.Fatalf("bad fault spec %q: %v", fault, err) + } + marker := filepath.Join(t.TempDir(), "marker") + cmd := proctest.Command(t, os.Args[0], "-test.run=^TestHelperProcess$") + cmd.Env = append(cmd.Env, "FAULTINJECT_HELPER=1", "FAULTINJECT_MARKER="+marker, "DMG_FAULT="+fault) + out, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + return cmd, bufio.NewReader(out), marker +} + +func TestKillAndExitSkipCleanup(t *testing.T) { + for action, want := range map[string]int{"kill": 137, "exit2": 2, "err": 0} { + cmd, out, marker := helper(t, "S6.state-temp:"+action) + _, _ = out.ReadString('\n') + err := cmd.Wait() + if got := cmd.ProcessState.ExitCode(); got != want { + t.Fatalf("%s: wait = %v, exit %d, want %d", action, err, got, want) + } + _, statErr := os.Stat(marker) + if returned := statErr == nil; returned != (want == 0) { + t.Fatalf("%s: code after Point ran = %v", action, returned) + } + } +} + +func TestHangBlocksUntilKilled(t *testing.T) { + cmd, out, _ := helper(t, "S6.state-temp:hang") + if line, _ := out.ReadString('\n'); line != "ready\n" { + t.Fatalf("helper said %q", line) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + t.Fatalf("hang returned: %v", err) + case <-time.After(500 * time.Millisecond): + } + _ = cmd.Process.Kill() + <-done +} + +func TestPointPanicAction(t *testing.T) { + t.Setenv("DMG_FAULT", "S0.locked:panic") + defer func() { + if r := recover(); r == nil || !strings.Contains(fmt.Sprint(r), "S0.locked") { + t.Fatalf("recovered %v, want a faultinject panic", r) + } + }() + _ = Point("S0.locked") + t.Fatal("no panic") +} + +// D28: sleep blocks at the checkpoint, then continues normally. +func TestPointSleepAction(t *testing.T) { + t.Setenv("DMG_FAULT", "S6.mutex:sleep300ms") + start := time.Now() + if err := Point("S6.mutex"); err != nil { + t.Fatalf("Point = %v, want nil after the sleep", err) + } + if d := time.Since(start); d < 300*time.Millisecond { + t.Fatalf("returned after %s", d) + } +} diff --git a/internal/faultinject/spec.go b/internal/faultinject/spec.go new file mode 100644 index 00000000..2eb77536 --- /dev/null +++ b/internal/faultinject/spec.go @@ -0,0 +1,95 @@ +package faultinject + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// MalformedExitCode is how a fault build exits on a malformed DMG_FAULT or +// Baked spec: distinct from every fault action and from a Go panic (2), so +// it can't pass for a crash. +const MalformedExitCode = 64 + +// MalformedMarker starts the stderr line written before that exit; drivers +// fail the test when they see it. +const MalformedMarker = "faultinject: malformed spec:" + +// Validate checks a DMG_FAULT / Baked spec. Test drivers and harnesses +// call it before launching a process with one, so a typo fails at setup. +// It is available in every build and reads no environment. +func Validate(spec string) error { + _, err := parse(spec) + return err +} + +type rule struct { + checkpoint string + principal string // "", "user" or "system" + action string +} + +// parse splits a spec list. Every entry must be [@user|@system]: +// with a known action; the action is split off the last ':' because +// checkpoint names contain ':' (S4.downloaded:). +func parse(spec string) ([]rule, error) { + var rules []rule + for _, entry := range strings.Split(spec, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + i := strings.LastIndexByte(entry, ':') + if i <= 0 || i == len(entry)-1 { + return nil, fmt.Errorf("entry %q: want :", entry) + } + r := rule{checkpoint: entry[:i], action: entry[i+1:]} + if !validAction(r.action) { + return nil, fmt.Errorf("entry %q: unknown action %q", entry, r.action) + } + if j := strings.LastIndexByte(r.checkpoint, '@'); j >= 0 { + switch p := r.checkpoint[j+1:]; p { + case "user", "system": + r.principal, r.checkpoint = p, r.checkpoint[:j] + default: + return nil, fmt.Errorf("entry %q: unknown principal qualifier %q (want @user or @system)", entry, p) + } + } + if r.checkpoint == "" { + return nil, fmt.Errorf("entry %q: empty checkpoint", entry) + } + rules = append(rules, r) + } + return rules, nil +} + +func validAction(a string) bool { + switch a { + case "kill", "err", "hang", "diskfull", "panic": + return true + } + if _, ok := sleepAction(a); ok { + return true + } + n, ok := strings.CutPrefix(a, "exit") + code, err := strconv.Atoi(n) + // MalformedExitCode is reserved: a fault may not imitate a bad spec. + return ok && err == nil && code >= 0 && code != MalformedExitCode +} + +// MaxSleep bounds the sleep action. +const MaxSleep = 10 * time.Minute + +// sleepAction parses sleep (Go syntax, 0 < d <= MaxSleep). +func sleepAction(a string) (time.Duration, bool) { + v, ok := strings.CutPrefix(a, "sleep") + if !ok { + return 0, false + } + d, err := time.ParseDuration(v) + if err != nil || d <= 0 || d > MaxSleep { + return 0, false + } + return d, true +} diff --git a/internal/faultinject/spec_test.go b/internal/faultinject/spec_test.go new file mode 100644 index 00000000..0c7c9f2b --- /dev/null +++ b/internal/faultinject/spec_test.go @@ -0,0 +1,25 @@ +package faultinject + +import "testing" + +var badSpecs = []string{ + "S0.locked:explode", "S0.locked:exit", "S0.locked:exit-1", "nocolon", "S0.locked:", ":kill", + "S0.locked@sytem:err", "S0.locked@SYSTEM:err", "S4.downloaded:agent.exe", "S0.locked@:err", + "unrelated:kill,S0.locked:bogus", + "S0.locked:exit64", // 64 is reserved for the malformed-spec exit + "S6.mutex:sleep", "S6.mutex:sleep0s", "S6.mutex:sleep-1s", "S6.mutex:sleep11m", "S6.mutex:sleepforever", +} + +// Validate works in every build, so drivers can reject typos at setup. +func TestValidate(t *testing.T) { + for _, spec := range badSpecs { + if Validate(spec) == nil { + t.Errorf("Validate(%q) = nil", spec) + } + } + for _, spec := range []string{"", "S0.locked:kill", "S4.downloaded:agent.exe:err", "agent.prestart@system:exit3, boot.launched@user:hang", "S6.mutex:diskfull", "S6.mutex:sleep45s", "S6.mutex:sleep10m", "S6.mutex:sleep250ms"} { + if err := Validate(spec); err != nil { + t.Errorf("Validate(%q) = %v", spec, err) + } + } +} diff --git a/internal/launcher/adopt_rollback_test.go b/internal/launcher/adopt_rollback_test.go new file mode 100644 index 00000000..72a825b9 --- /dev/null +++ b/internal/launcher/adopt_rollback_test.go @@ -0,0 +1,163 @@ +package launcher + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/updatestate" + "github.com/step-security/dev-machine-guard/internal/winproc" +) + +func (f *fixture) adoptFlag(flag string) { + f.t.Helper() + if code := f.b.AdoptSeed([]string{flag}); code != 0 { + f.t.Fatalf("adopt-seed %s exit = %d", flag, code) + } +} + +func exists(p string) bool { _, err := os.Lstat(p); return err == nil } + +// DMG-I5-2: a failed major upgrade rolls adopt-seed back: the pre-MSI +// current returns, the seed dir adopt-seed created goes, the snapshot goes. +func TestAdoptSeedRollback_RestoresUpgrade(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.4.0","future":"kept"}`) + f.version("1.4.0") + var running []string + f.b.SetRunningVersion = func(v string) error { running = append(running, v); return nil } + f.adopt(f.seed("1.5.0")) + if f.load().Current != "1.5.0" { + t.Fatal("seed not adopted") + } + // The MSI fails later and rolls back. + if err := os.RemoveAll(filepath.Join(f.layout.InstallRoot, "seed", "1.5.0")); err != nil { + t.Fatal(err) + } + f.adoptFlag("--rollback") + st := f.load() + if st.Current != "1.4.0" || st.Previous != "" || st.Extra["future"] == nil { + t.Fatalf("state after rollback = %+v", st) + } + if exists(f.layout.VersionDir(updatestate.Version{Major: 1, Minor: 5})) { + t.Fatal("versions\\1.5.0 created by adopt-seed was kept") + } + if exists(f.layout.AdoptSnapshotFile()) { + t.Fatal("snapshot kept") + } + if running[len(running)-1] != "1.4.0" { + t.Fatalf("RunningVersion = %v", running) + } +} + +// A first install that fails: no state existed, so none remains. +func TestAdoptSeedRollback_FreshInstall(t *testing.T) { + f := newFixture(t) + f.adopt(f.seed("1.5.0")) + f.adoptFlag("--rollback") + if exists(f.layout.StateFile()) || exists(f.layout.VersionDir(updatestate.Version{Major: 1, Minor: 5})) { + t.Fatal("fresh install not rolled back") + } +} + +// A versions dir that existed before adopt-seed (e.g. self-updated earlier) +// is never removed by the rollback. +func TestAdoptSeedRollback_KeepsPreexistingDir(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.4.0"}`) + f.version("1.4.0") + f.adopt(f.seed("1.5.0")) // copies versions\1.5.0 (created) + f.adoptFlag("--commit") + if exists(f.layout.AdoptSnapshotFile()) { + t.Fatal("commit kept the snapshot") + } + // A repair later: versions\1.5.0 already there, current already 1.5.0. + f.adopt(f.seed("1.5.0")) + f.adoptFlag("--rollback") + if f.load().Current != "1.5.0" || !exists(f.layout.VersionDir(updatestate.Version{Major: 1, Minor: 5})) { + t.Fatalf("repair rollback changed a pre-existing install: %+v", f.load()) + } +} + +// A state written by someone else after adopt-seed (an updater tick) is +// not overwritten by the rollback. +func TestAdoptSeedRollback_SkipsIfStateMovedOn(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.4.0"}`) + f.version("1.4.0") + f.adopt(f.seed("1.5.0")) + st := f.load() + st.Current, st.Previous = "1.6.0", "1.5.0" + f.version("1.6.0") + if err := updatestate.Save(f.layout.StateFile(), st); err != nil { + t.Fatal(err) + } + f.adoptFlag("--rollback") + if got := f.load(); got.Current != "1.6.0" { + t.Fatalf("rollback clobbered a newer state: %+v", got) + } + if !exists(f.layout.VersionDir(updatestate.Version{Major: 1, Minor: 5})) { + t.Fatal("removed a version the newer state points at") + } +} + +// A stale snapshot (an MSI that ended without commit or rollback) is +// never applied, and is swept. +func TestAdoptSeedRollback_IgnoresStaleSnapshot(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.4.0"}`) + f.version("1.4.0") + f.adopt(f.seed("1.5.0")) + old := time.Now().Add(-2 * time.Hour) + if err := os.Chtimes(f.layout.AdoptSnapshotFile(), old, old); err != nil { + t.Fatal(err) + } + f.adoptFlag("--rollback") + if f.load().Current != "1.5.0" || exists(f.layout.AdoptSnapshotFile()) { + t.Fatalf("stale snapshot applied or kept: %+v", f.load()) + } +} + +// msi-smoke 39915353203: a scan started by the failed install still held +// versions\ when the rollback ran, the rename failed and the dir was +// silently kept. The rollback stops what runs from it, retries for a +// bounded time, and logs loudly if it must give up. +func TestAdoptSeedRollback_RetriesHeldVersionDir(t *testing.T) { + for name, fails := range map[string]int{"released": 3, "never released": 1 << 30} { + t.Run(name, func(t *testing.T) { + f := newFixture(t) + f.adopt(f.seed("1.5.0")) + dir := f.layout.VersionDir(updatestate.Version{Major: 1, Minor: 5}) + var stopped []string + n := 0 + renameDir = func(from, to string) error { + if from == dir && n < fails { + n++ + return os.ErrPermission + } + return os.Rename(from, to) + } + stopUnder = func(root string) ([]string, error) { stopped = append(stopped, root); return nil, nil } + t.Cleanup(func() { renameDir, stopUnder = os.Rename, winproc.StopUnder }) + f.b.Sleep = func(time.Duration) {} + f.adoptFlag("--rollback") + if len(stopped) == 0 || stopped[0] != dir { + t.Fatalf("processes under %s not stopped: %v", dir, stopped) + } + if fails < 10 { + if exists(dir) { + t.Fatal("held versions dir kept after it was released") + } + return + } + if !exists(dir) { + t.Fatal("dir gone though the rename never succeeded") + } + if !strings.Contains(f.out.String(), "could not remove "+dir) { + t.Fatalf("giving up was not logged:\n%s", f.out.String()) + } + }) + } +} diff --git a/internal/launcher/bootstrap.go b/internal/launcher/bootstrap.go new file mode 100644 index 00000000..f6f2da00 --- /dev/null +++ b/internal/launcher/bootstrap.go @@ -0,0 +1,939 @@ +package launcher + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/step-security/dev-machine-guard/internal/authenticode" + "github.com/step-security/dev-machine-guard/internal/winproc" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/step-security/dev-machine-guard/internal/faultinject" + "github.com/step-security/dev-machine-guard/internal/updatestate" +) + +// Bootstrap mode (spec §2, D2). The MSI installs this launcher a second time +// as BootstrapBinary in the install root; under that name it resolves the +// state.json pointer and launches the versioned binaries. It must stay tiny +// and frozen-safe: no network and no crypto, never crash, and read +// state.json only through the frozen pointer contract. +const ( + BootstrapBinary = "stepsecurity-dev-machine-guard-bootstrap.exe" + + // ExitCannotStart is returned when no child could be started (D5). It is + // outside {0, 1}, so a run record carrying it classifies as bad. + ExitCannotStart = 70 + // ExitKilled is the exit status given to a child tree the bootstrap + // terminated at its deadline. + ExitKilled = 124 + // exitUsage is returned for a malformed bootstrap command line. + exitUsage = 64 + + EnvRunRecord = "STEPSEC_RUN_RECORD" + EnvRunID = "STEPSEC_RUN_ID" + EnvBootstrapVersion = "STEPSEC_BOOTSTRAP_VERSION" + EnvBootstrapFallback = "STEPSEC_BOOTSTRAP_FALLBACK" + + // envMaxExecution mirrors telemetry.EnvMaxExecutionDuration; the + // bootstrap reads it (and config.json) to bound a scan it launched. + envMaxExecution = "STEPSEC_MAX_EXECUTION_DURATION" + + // UpdaterFailureThreshold consecutive failures of one updater version + // make `update` fall back to the seed's updater. + UpdaterFailureThreshold = 3 +) + +// Fallback reasons passed in EnvBootstrapFallback. Reasons about a named +// version carry it after a colon (current_missing:1.20.0) so the agent can +// report update_pointer_invalid with update_from = that version. +const ( + ReasonStateMissing = updatestate.FallbackStateMissing + ReasonStateCorrupt = updatestate.FallbackStateCorrupt + ReasonStateUnreadable = updatestate.FallbackStateUnreadable + ReasonCurrentMissing = updatestate.FallbackCurrentMissing + ReasonCurrentIncomplete = updatestate.FallbackCurrentIncomplete + ReasonStartFailed = updatestate.FallbackStartFailed + ReasonUpdaterFailed = updatestate.FallbackUpdaterFailed + ReasonNoUpdater = updatestate.FallbackNoUpdater +) + +const ( + defaultExecutionDeadline = 4 * time.Hour + // runDeadlineSlack lets the agent's own watchdog fire first; the scan + // task's ExecutionTimeLimit is the watchdog + 30 min. + runDeadlineSlack = 10 * time.Minute + // defaultUpdateDeadline stays under the update task's 30 min + // ExecutionTimeLimit so a hung updater is recorded as a failure. + defaultUpdateDeadline = 25 * time.Minute + // defaultRunLockWait bounds the wait for a run-record lock; past it the + // bookkeeping is skipped and the scan still runs. + defaultRunLockWait = 5 * time.Second + maxConfigSize = 1 << 20 + maxLogSize = 1 << 20 +) + +// IsBootstrapBinary reports whether exePath is the bootstrap's file name. +func IsBootstrapBinary(exePath string) bool { + base := exePath + if i := strings.LastIndexAny(base, `\/`); i >= 0 { + base = base[i+1:] + } + return strings.EqualFold(base, BootstrapBinary) +} + +// Bootstrap runs the bootstrap verbs. All fields are injectable for tests; +// NewBootstrap fills production values. +type Bootstrap struct { + Layout updatestate.Layout + Spawn Spawner + IsSystem func() bool + Now func() time.Time + Sleep func(time.Duration) + Getenv func(string) string + Environ func() []string + Out io.Writer + NewRunID func() string + // SetRunningVersion records the version adopt-seed made current + // (HKLM RunningVersion on Windows); nil skips it. + SetRunningVersion func(string) error + // SelfExe is this bootstrap's own image; adopt-seed copies it to + // Layout.MSIHelperFile for the MSI's stop-for-msi action. "" skips it. + SelfExe string + // VerifySeed checks a seed's executables before adopt-seed copies them + // (authenticode.SameSeedPublisher in production); nil skips it. + VerifySeed func(path string) error + + AdoptLockWait time.Duration // adopt-seed: bounded wait for the update lock + RecordLockWait time.Duration // update: wait to record the updater result + LockPoll time.Duration + RunLockWait time.Duration // run-record lock; 0 means defaultRunLockWait + UpdateDeadline time.Duration + + log io.Writer +} + +// NewBootstrap returns a production bootstrap rooted at the directory of +// exePath, with runs under the ProgramData data root. +func NewBootstrap(exePath string, isSystem func() bool) *Bootstrap { + return &Bootstrap{ + Layout: updatestate.Layout{InstallRoot: filepath.Dir(exePath), DataRoot: updatestate.DataRoot()}, + Spawn: SpawnHidden, + IsSystem: isSystem, + Now: time.Now, + Sleep: time.Sleep, + Getenv: os.Getenv, + Environ: os.Environ, + Out: os.Stdout, + NewRunID: uuid.NewString, + SetRunningVersion: SetRunningVersion, + VerifySeed: func(p string) error { return authenticode.SameSeedPublisher(p, exePath) }, + SelfExe: exePath, + AdoptLockWait: 60 * time.Second, + RecordLockWait: 30 * time.Second, + LockPoll: 500 * time.Millisecond, + UpdateDeadline: defaultUpdateDeadline, + } +} + +// Main dispatches a bootstrap command line (argv without the program name). +func (b *Bootstrap) Main(argv []string) int { + if len(argv) == 0 { + b.logf("usage: %s run | update | adopt-seed --seed-dir ", BootstrapBinary) + return exitUsage + } + switch argv[0] { + case "run": + return b.Run(argv[1:]) + case "update": + return b.Update() + case "adopt-seed": + return b.AdoptSeed(argv[1:]) + case "stop-for-msi": + return b.StopForMSI(argv[1:]) + default: + b.logf("unknown bootstrap verb %q", argv[0]) + return exitUsage + } +} + +func (b *Bootstrap) logf(format string, args ...any) { + line := b.Now().UTC().Format(time.RFC3339) + " bootstrap: " + fmt.Sprintf(format, args...) + "\n" + if b.Out != nil { + _, _ = io.WriteString(b.Out, line) + } + if b.log != nil { + _, _ = io.WriteString(b.log, line) + } +} + +// openUpdateLog appends SYSTEM-verb logs to state\update.log (best effort, +// restarted when it grows past maxLogSize). +func (b *Bootstrap) openUpdateLog() func() { + path := b.Layout.LogFile() + flags := os.O_CREATE | os.O_WRONLY | os.O_APPEND + if fi, err := os.Stat(path); err == nil && fi.Size() > maxLogSize { + flags |= os.O_TRUNC + } + f, err := os.OpenFile(path, flags, 0o600) // #nosec G304 -- fixed path in the protected state dir + if err != nil { + return func() {} + } + b.log = f + return func() { b.log = nil; _ = f.Close() } +} + +// installDirArg returns the --install-dir value in args, if any. +func installDirArg(args []string) string { + for i, a := range args { + switch { + case strings.HasPrefix(a, "--install-dir="): + return strings.Trim(strings.TrimPrefix(a, "--install-dir="), `"`) + case a == "--install-dir" && i+1 < len(args): + return strings.Trim(args[i+1], `"`) + } + } + return "" +} + +// childEnv is the bootstrap's environment with our variables replaced. +func (b *Bootstrap) childEnv(set map[string]string) []string { + ours := []string{EnvRunRecord, EnvRunID, EnvBootstrapVersion, EnvBootstrapFallback} + var env []string + for _, kv := range b.Environ() { + k, _, _ := strings.Cut(kv, "=") + drop := false + for _, o := range ours { + if strings.EqualFold(k, o) { + drop = true + } + } + if !drop { + env = append(env, kv) + } + } + for _, k := range ours { + if v, ok := set[k]; ok && v != "" { + env = append(env, k+"="+v) + } + } + return env +} + +func parseDeadline(v string) (time.Duration, bool) { + switch v { + case "": + return 0, false + case "0", "off": + return 0, true + } + d, err := time.ParseDuration(v) + if err != nil || d <= 0 { + return 0, false + } + return d, true +} + +// runDeadline mirrors the agent's execution watchdog (env > config.json > +// 4 h) plus slack. It catches a scan that hangs before the agent arms its +// own watchdog. 0 disables it, as for the agent. +func (b *Bootstrap) runDeadline(dataRoot string) time.Duration { + d, ok := parseDeadline(b.Getenv(envMaxExecution)) + if !ok { + var cfg struct { + MaxExecutionDuration string `json:"max_execution_duration"` + } + if data, err := readSmall(filepath.Join(dataRoot, "config.json"), maxConfigSize); err == nil && json.Unmarshal(data, &cfg) == nil { + d, ok = parseDeadline(cfg.MaxExecutionDuration) + } + } + if !ok { + d = defaultExecutionDeadline + } + if d == 0 { + return 0 + } + return d + runDeadlineSlack +} + +func readSmall(path string, limit int64) ([]byte, error) { + f, err := os.Open(path) // #nosec G304 -- the agent's own config path + if err != nil { + return nil, err + } + defer f.Close() + data, err := io.ReadAll(io.LimitReader(f, limit+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, errors.New("too large") + } + return data, nil +} + +func checkpointLogged(b *Bootstrap, name string) { + if err := faultinject.Point(name); err != nil { + b.logf("checkpoint %s: %v (continuing)", name, err) + } +} + +// Run is `run `: launch versions\\task.exe with args from +// the first usable of current → previous → seed, record the run, and return +// the child's exit code. +func (b *Bootstrap) Run(args []string) int { + layout := b.Layout + if d := installDirArg(args); d != "" { + layout.DataRoot = d + } + p, corrupt, err := updatestate.LoadPointers(layout.StateFile()) + if err != nil { + b.logf("reading state: %v", err) + } + cands := updatestate.ResolveRunChain(layout, p, updatestate.LoadReason(p != nil, corrupt, err)) + checkpointLogged(b, "boot.resolved") + if len(cands) == 0 { + b.logf("no launchable version (state, previous and seed all unusable)") + return ExitCannotStart + } + deadline := b.runDeadline(layout.DataRoot) + startReason := "" + for _, c := range cands { + reason := c.Reason + if startReason != "" { + reason = startReason + } + runFile := layout.RunFile(c.Version) + runID := b.NewRunID() + runLock := layout.RunLockFile(c.Version) + if err := b.withRunLock(runLock, func() error { + return updatestate.AppendRun(runFile, updatestate.RunRecord{RunID: runID, Version: c.Version.String()}) + }); err != nil { + // Never block a scan on bookkeeping: launch without a run id, + // so the agent skips the started marker. + b.logf("run record %s: %v; launching without one", runFile, err) + runID = "" + } + env := b.childEnv(map[string]string{ + EnvRunRecord: runFile, + EnvRunID: runID, + EnvBootstrapVersion: c.Version.String(), + EnvBootstrapFallback: reason, + }) + res := b.Spawn(filepath.Join(c.Dir, updatestate.LauncherFile), args, env, deadline, func() { + checkpointLogged(b, "boot.launched") + }) + if !res.Started { + b.logf("starting %s: %v", c.Dir, res.Err) + b.finalize(runLock, runFile, runID, ExitCannotStart, false) + startReason = ReasonStartFailed + ":" + c.Version.String() + continue + } + code, killed := runOutcome(res) + b.finalize(runLock, runFile, runID, code, killed) + return code + } + return ExitCannotStart +} + +// runOutcome is the exit code and watchdog flag a scan launch records. The +// deadline counts awake time only (sleep excluded), so a deadline kill is a +// real hang: a watchdog kill. +func runOutcome(res SpawnResult) (code int, watchdogKilled bool) { + if res.Killed { + return ExitKilled, true + } + return res.ExitCode, false +} + +func (b *Bootstrap) finalize(runLock, runFile, runID string, code int, killed bool) { + if runID == "" { + return + } + if err := b.withRunLock(runLock, func() error { + return updatestate.FinalizeRun(runFile, runID, code, killed, b.Now()) + }); err != nil { + b.logf("finalizing run record %s: %v", runFile, err) + } +} + +// withRunLock runs fn holding the run-record lock (R-B-2), waiting at most +// RunLockWait. It returns an error, without running fn, when the lock can't +// be taken; callers skip the bookkeeping and never block the scan. +func (b *Bootstrap) withRunLock(path string, fn func() error) error { + wait := b.RunLockWait + if wait <= 0 { + wait = defaultRunLockWait + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return err + } + deadline := b.Now().Add(wait) + for { + l, err := updatestate.AcquireLock(path) + if err == nil { + defer l.Release() + return fn() + } + if !errors.Is(err, updatestate.ErrLocked) || !b.Now().Before(deadline) { + return fmt.Errorf("run-record lock %s: %w", path, err) + } + b.Sleep(b.LockPoll) + } +} + +// lockWithWait polls for the update lock for up to wait. +func (b *Bootstrap) lockWithWait(wait time.Duration) (*updatestate.Lock, error) { + deadline := b.Now().Add(wait) + for { + l, err := updatestate.AcquireLock(b.Layout.LockFile()) + if err == nil { + return l, nil + } + if !errors.Is(err, updatestate.ErrLocked) || !b.Now().Before(deadline) { + return nil, err + } + b.Sleep(b.LockPoll) + } +} + +// Update is `update` (SYSTEM): pick the updater binary, run +// `agent.exe self-update --system`, and record its result in state.updater. +func (b *Bootstrap) Update() int { + if !b.IsSystem() { + b.logf("update must run as SYSTEM") + return 1 + } + defer b.openUpdateLog()() + + p, corrupt, err := updatestate.LoadPointers(b.Layout.StateFile()) + if err != nil { + b.logf("reading state: %v", err) + } + var primary *updatestate.RunChoice + reason := updatestate.LoadReason(p != nil, corrupt, err) + if p != nil { + pick := p.Current + if p.Probation != "" && p.Probation == p.Current { + pick = p.Previous + } + if pick == "" { + reason = ReasonNoUpdater + } else if v, why := b.Layout.CheckVersionDir(pick); why != "" { + reason = why + } else if p.UpdaterVersion == pick && p.UpdaterFailures >= UpdaterFailureThreshold { + reason = ReasonUpdaterFailed + ":" + pick + } else { + primary = &updatestate.RunChoice{Version: v, Dir: b.Layout.VersionDir(v)} + } + } + var cands []updatestate.RunChoice + if primary != nil { + cands = append(cands, *primary) + } + if v, ok := b.Layout.FindSeed(); ok { + cands = append(cands, updatestate.RunChoice{Version: v, Dir: b.Layout.SeedDir(v), Reason: reason}) + } + checkpointLogged(b, "boot.resolved") + if len(cands) == 0 { + b.logf("no updater binary available") + return ExitCannotStart + } + for i, c := range cands { + isPrimary := primary != nil && i == 0 + if !isPrimary { + b.logf("updater fallback to seed %s (%s)", c.Version, c.Reason) + } + agent := filepath.Join(c.Dir, updatestate.AgentFile) + res := b.Spawn(agent, []string{"self-update", "--system"}, b.childEnv(nil), b.UpdateDeadline, func() { + checkpointLogged(b, "boot.launched") + }) + code := res.ExitCode + switch { + case !res.Started: + b.logf("starting updater %s: %v", agent, res.Err) + code = ExitCannotStart + case res.Killed: + b.logf("updater %s killed after %s", c.Version, b.UpdateDeadline) + code = ExitKilled + } + b.logf("updater %s exited %d", c.Version, code) + if isPrimary { + b.recordUpdater(c.Version.String(), code) + } + if res.Started { + return code + } + } + return ExitCannotStart +} + +// recordUpdater stores the updater result for the next tick's selection. +func (b *Bootstrap) recordUpdater(version string, code int) { + l, err := b.lockWithWait(b.RecordLockWait) + if err != nil { + b.logf("not recording updater result (lock: %v)", err) + return + } + defer l.Release() + // D18: the same tolerant read as the run path, so values this build's + // Load rejects can't stop the failure count (and the seed fallback). + err = updatestate.SaveUpdaterRecord(b.Layout.StateFile(), "boot", func(v string, failures int) (string, int) { + switch { + case code == 0: + return version, 0 + case v == version: + return version, failures + 1 + default: + return version, 1 + } + }) + if err != nil { + b.logf("not recording updater result: %v", err) + } +} + +// AdoptSeed is `adopt-seed --seed-dir ` (SYSTEM, MSI custom action): +// protect versions\ and state\, copy the seed into versions\ if +// absent, and point current at it when current is unusable or older. It +// always exits 0: it must never fail the MSI. +func (b *Bootstrap) AdoptSeed(args []string) int { + seedDir := "" + for i, a := range args { + switch { + case strings.HasPrefix(a, "--seed-dir="): + seedDir = strings.TrimPrefix(a, "--seed-dir=") + case a == "--seed-dir" && i+1 < len(args): + seedDir = args[i+1] + } + } + for _, a := range args { + switch a { + case "--rollback": + return b.adoptRollback() + case "--commit": + return b.adoptCommit() + } + } + if seedDir == "" { + b.logf("adopt-seed: --seed-dir is required") + return 0 + } + if !b.IsSystem() { + b.logf("adopt-seed must run as SYSTEM") + return 0 + } + seedDir = filepath.Clean(strings.Trim(seedDir, `"`)) + v, err := updatestate.ParseVersion(filepath.Base(seedDir)) + if err != nil { + b.logf("adopt-seed: seed dir %q is not seed\\: %v", seedDir, err) + return 0 + } + if !samePath(seedDir, b.Layout.SeedDir(v)) { + b.logf("adopt-seed: seed dir %q is not %q", seedDir, b.Layout.SeedDir(v)) + return 0 + } + if !seedComplete(seedDir) { + b.logf("adopt-seed: seed %s is incomplete", seedDir) + return 0 + } + for _, d := range []string{b.Layout.VersionsDir(), b.Layout.StateDir()} { + fixed, err := updatestate.EnsureProtectedDir(d) + if err != nil { + b.logf("adopt-seed: protecting %s: %v", d, err) + return 0 + } + if fixed { + b.logf("adopt-seed: re-asserted owner/DACL on %s", d) + } + } + defer b.openUpdateLog()() + + l, err := b.lockWithWait(b.AdoptLockWait) + if err != nil { + b.logf("adopt-seed: update lock not taken (%v); the updater converges on its next tick", err) + return 0 + } + defer l.Release() + + b.refreshMSIHelper() + + // Snapshot before changing anything, for the MSI's rollback action. + snapPath := b.Layout.AdoptSnapshotFile() + updatestate.SweepStaleAdoptSnapshot(snapPath) + snap := b.adoptSnapshot(v) + if err := updatestate.WriteAdoptSnapshot(snapPath, snap); err != nil { + b.logf("adopt-seed: writing rollback snapshot: %v; not adopting", err) + return 0 + } + + if note, err := b.Layout.CopySeedVersion(v, b.Now(), b.VerifySeed); err != nil { + b.logf("adopt-seed: copying seed %s: %v", v, err) + } else if note != "" { + b.logf("adopt-seed: %s", note) + } + if err := updatestate.VerifyVersionDir(b.Layout.VersionDir(v), v); err != nil { + b.logf("adopt-seed: versions\\%s unusable (%v); state left unchanged", v, err) + return 0 + } + if b.adoptState(v) { + if gen, ok := updatestate.StateGeneration(b.Layout.StateFile()); ok { + snap.AfterGeneration = &gen + if err := updatestate.WriteAdoptSnapshot(snapPath, snap); err != nil { + b.logf("adopt-seed: updating rollback snapshot: %v", err) + } + } + } + return 0 +} + +// refreshMSIHelper copies this bootstrap to Layout.MSIHelperFile, which the +// next MSI's stop-for-msi action runs (D29). Best effort. +func (b *Bootstrap) refreshMSIHelper() { + if b.SelfExe == "" { + return + } + dst := b.Layout.MSIHelperFile() + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { // #nosec G301 -- inside the protected state dir + b.logf("adopt-seed: MSI helper: %v", err) + return + } + tmp := dst + ".new" + if err := copyExecutable(b.SelfExe, tmp); err != nil { + b.logf("adopt-seed: MSI helper: %v", err) + _ = os.Remove(tmp) + return + } + // Keep the previous install's helper until the MSI commits, so a + // failed upgrade's rollback can put it back. + prev := dst + msiHelperPrevSuffix + if _, err := os.Lstat(dst); err == nil { + _ = os.Remove(prev) + if err := os.Rename(dst, prev); err != nil { + b.logf("adopt-seed: MSI helper: keeping the previous copy: %v", err) + } + } + if err := os.Rename(tmp, dst); err != nil { + b.logf("adopt-seed: MSI helper: %v", err) + _ = os.Remove(tmp) + _ = os.Rename(prev, dst) // keep a helper in place + } +} + +// msiHelperPrevSuffix names the previous install's MSI helper, kept from +// adopt-seed until the MSI commits or rolls back. +const msiHelperPrevSuffix = ".prev" + +// restoreMSIHelper is the rollback of refreshMSIHelper: the previous +// install's helper replaces the failed build's. +func (b *Bootstrap) restoreMSIHelper() { + dst := b.Layout.MSIHelperFile() + prev := dst + msiHelperPrevSuffix + if _, err := os.Lstat(prev); err != nil { + return + } + if err := os.Rename(prev, dst); err != nil { + b.logf("adopt-seed --rollback: restoring the MSI helper: %v", err) + return + } + b.logf("adopt-seed --rollback: restored the previous MSI helper") +} + +func copyExecutable(src, dst string) error { + in, err := os.Open(src) // #nosec G304 -- this bootstrap's own image + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755) // #nosec G302 G304 -- protected state dir + if err != nil { + return err + } + _, err = io.Copy(out, in) + if err == nil { + err = out.Sync() + } + if cerr := out.Close(); err == nil { + err = cerr + } + return err +} + +// StopForMSI is `stop-for-msi --install-dir `, the MSI's first +// deferred action (D29): it stops every DMG process under root and waits, +// bounded, until no process holds any MSI-owned file, so Windows Installer +// never finds one in use (which needs a reboot). It runs from the helper +// copy in state\msi\, so its own image is not an MSI file. Always exits 0. +func (b *Bootstrap) StopForMSI(args []string) int { + root := "" + for i, a := range args { + switch { + case strings.HasPrefix(a, "--install-dir="): + root = strings.TrimPrefix(a, "--install-dir=") + case a == "--install-dir" && i+1 < len(args): + root = args[i+1] + } + } + if root == "" { + b.logf("stop-for-msi: --install-dir is required") + return 0 + } + root = filepath.Clean(strings.Trim(root, `"`)) + stopped, err := winproc.StopUnder(root) + for _, img := range stopped { + b.logf("stop-for-msi: stopped %s", img) + } + if err != nil { + b.logf("stop-for-msi: %v", err) + } + files := updatestate.Layout{InstallRoot: root}.MSIOwnedFiles() + if held := winproc.WaitReleased(files, msiReleaseWait, 50*time.Millisecond); len(held) > 0 { + b.logf("stop-for-msi: still held after %s: %s", msiReleaseWait, strings.Join(held, ", ")) + return 0 + } + b.logf("stop-for-msi: %d MSI-owned files free", len(files)) + return 0 +} + +// msiReleaseWait bounds stop-for-msi's wait for MSI-owned files. +const msiReleaseWait = 30 * time.Second + +// adoptSnapshot records state.json and whether versions\ exists, before +// adopt-seed touches either. +func (b *Bootstrap) adoptSnapshot(v updatestate.Version) *updatestate.AdoptSnapshot { + snap := &updatestate.AdoptSnapshot{Seed: v.String()} + if data, err := os.ReadFile(b.Layout.StateFile()); err == nil { // #nosec G304 -- protected state dir + snap.StatePresent, snap.State = true, data + } + snap.CreatedDir = updatestate.VerifyVersionDir(b.Layout.VersionDir(v), v) != nil + return snap +} + +// renameDir and stopUnder are os.Rename and winproc.StopUnder; tests +// replace them. +var ( + renameDir = os.Rename + stopUnder = winproc.StopUnder +) + +// rollbackRemoveWait bounds the rollback's retries to remove the version +// dir adopt-seed created; rollbackRemovePoll spaces them. +const ( + rollbackRemoveWait = 30 * time.Second + rollbackRemovePoll = 250 * time.Millisecond +) + +// removeAdoptedDir moves dir to trash and deletes it. A scan the failed +// install started may still run from dir, so it stops what runs from there +// and retries until the rename succeeds or rollbackRemoveWait passes. +func (b *Bootstrap) removeAdoptedDir(dir, trash string) error { + var err error + for waited := time.Duration(0); ; waited += rollbackRemovePoll { + if stopped, serr := stopUnder(dir); serr != nil { + b.logf("adopt-seed --rollback: stopping processes under %s: %v", dir, serr) + } else if len(stopped) > 0 { + b.logf("adopt-seed --rollback: stopped %s", strings.Join(stopped, ", ")) + } + if err = renameDir(dir, trash); err == nil { + _ = os.RemoveAll(trash) + return nil + } + if waited >= rollbackRemoveWait { + return err + } + b.Sleep(rollbackRemovePoll) + } +} + +// adoptRollback is `adopt-seed --rollback`, the MSI's rollback action for +// adopt-seed (DMG-I5-2): when the transaction fails after adopt-seed ran, +// restore the state.json it changed (only if nobody wrote it since) and +// remove the versions\ it created unless the restored state points at +// it. Always exits 0 (Return=ignore). +func (b *Bootstrap) adoptRollback() int { + if !b.IsSystem() { + b.logf("adopt-seed --rollback must run as SYSTEM") + return 0 + } + defer b.openUpdateLog()() + l, err := b.lockWithWait(b.AdoptLockWait) + if err != nil { + b.logf("adopt-seed --rollback: update lock not taken (%v)", err) + return 0 + } + defer l.Release() + b.restoreMSIHelper() + snapPath := b.Layout.AdoptSnapshotFile() + snap, age, err := updatestate.ReadAdoptSnapshot(snapPath) + switch { + case err != nil: + b.logf("adopt-seed --rollback: unreadable snapshot (%v); removed", err) + _ = os.Remove(snapPath) + return 0 + case snap == nil: + b.logf("adopt-seed --rollback: nothing to roll back") + return 0 + case age > updatestate.AdoptSnapshotMaxAge: + b.logf("adopt-seed --rollback: snapshot is %s old (not this MSI's); removed", age.Round(time.Second)) + _ = os.Remove(snapPath) + return 0 + } + defer func() { _ = os.Remove(snapPath) }() + path := b.Layout.StateFile() + if snap.AfterGeneration != nil { + if gen, ok := updatestate.StateGeneration(path); !ok || gen != *snap.AfterGeneration { + b.logf("adopt-seed --rollback: state.json changed since adopt-seed; left as is") + } else { + b.restoreState(path, snap, gen) + } + } + if snap.CreatedDir { + v, err := updatestate.ParseVersion(snap.Seed) + p, _, _ := updatestate.LoadPointers(path) + if err == nil && (p == nil || (p.Current != snap.Seed && p.Previous != snap.Seed)) { + dir := b.Layout.VersionDir(v) + trash := filepath.Join(b.Layout.VersionsDir(), fmt.Sprintf("%s%s-rollback", updatestate.TrashPrefix, snap.Seed)) + if err := b.removeAdoptedDir(dir, trash); err != nil { + b.logf("adopt-seed --rollback: could not remove %s: %v; left in place", dir, err) + } else { + b.logf("adopt-seed --rollback: removed %s", dir) + } + } + } + return 0 +} + +// restoreState puts back the pre-adopt state.json: through the preserving +// Save (generation continues upward) when it parses, else byte-for-byte; +// removed if there was none. +func (b *Bootstrap) restoreState(path string, snap *updatestate.AdoptSnapshot, gen uint64) { + if !snap.StatePresent { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + b.logf("adopt-seed --rollback: removing state.json: %v", err) + return + } + b.logf("adopt-seed --rollback: removed the state.json adopt-seed created") + return + } + tmp := path + ".rollback" + if err := os.WriteFile(tmp, snap.State, 0o600); err != nil { + b.logf("adopt-seed --rollback: %v", err) + return + } + defer os.Remove(tmp) + st, corrupt, err := updatestate.Load(tmp) + if err != nil || corrupt || st == nil { + b.logf("adopt-seed --rollback: pre-adopt state not loadable; left as is") + return + } + st.Generation = gen + if err := updatestate.SaveCheckpointed(path, st, "adopt"); err != nil { + b.logf("adopt-seed --rollback: writing state: %v", err) + return + } + b.logf("adopt-seed --rollback: restored current %s", st.Current) + b.recordRunningVersion(st.Current) +} + +// adoptCommit is `adopt-seed --commit`, the MSI's commit action: the +// transaction succeeded, so the rollback snapshot is no longer needed. +func (b *Bootstrap) adoptCommit() int { + if !b.IsSystem() { + return 0 + } + l, err := b.lockWithWait(b.AdoptLockWait) + if err != nil { + return 0 // swept later (AdoptSnapshotMaxAge) + } + defer l.Release() + _ = os.Remove(b.Layout.AdoptSnapshotFile()) + _ = os.Remove(b.Layout.MSIHelperFile() + msiHelperPrevSuffix) + return 0 +} + +func samePath(a, b string) bool { + a, b = filepath.Clean(a), filepath.Clean(b) + if filepath.Separator == '\\' { + return strings.EqualFold(a, b) + } + return a == b +} + +// seedComplete reports whether dir holds every updatestate.VersionFiles entry. +func seedComplete(dir string) bool { + for _, name := range updatestate.VersionFiles { + fi, err := os.Lstat(filepath.Join(dir, name)) + if err != nil || !fi.Mode().IsRegular() { + return false + } + } + return true +} + +// adoptState sets current = seed when there is no state yet, when current +// is unusable, or when the seed is newer than current (and not quarantined). +// An existing state is never replaced (R-B-1): only current/previous (and +// probation, which belonged to the old current) change, through the +// unknown-field-preserving Save; a state this build can't round-trip +// (unreadable pointers, fields Load rejects, a newer schema) is left +// byte-for-byte alone for the updater to handle. An MSI never downgrades a +// self-updated device. Called with the update lock held. +func (b *Bootstrap) adoptState(seed updatestate.Version) (changed bool) { + path := b.Layout.StateFile() + s := seed.String() + p, corrupt, err := updatestate.LoadPointers(path) + switch { + case err != nil: + b.logf("adopt-seed: reading state: %v; left unchanged", err) + return false + case corrupt: + b.logf("adopt-seed: state pointers unreadable; left unchanged for the updater") + return false + case p == nil: + st := &updatestate.State{Schema: updatestate.SchemaVersion, Current: s} + if err := updatestate.SaveCheckpointed(path, st, "adopt"); err != nil { + b.logf("adopt-seed: writing state: %v", err) + return false + } + b.logf("adopt-seed: no state; current = seed %s", s) + b.recordRunningVersion(s) + return true + case p.Schema > updatestate.SchemaVersion: + b.logf("adopt-seed: state schema %d is newer than this bootstrap; left unchanged", p.Schema) + return false + } + st, corrupt, err := updatestate.Load(path) + if err != nil || corrupt || st == nil { + b.logf("adopt-seed: state has fields this build can't round-trip (%v); left unchanged", err) + return false + } + changed, why := b.Layout.AdoptSeedRule(st, seed, b.Now()) + if why != "" { + b.logf("adopt-seed: %s", why) + } + if !changed { + return false + } + if err := updatestate.SaveCheckpointed(path, st, "adopt"); err != nil { + b.logf("adopt-seed: writing state: %v", err) + return false + } + b.recordRunningVersion(s) + return true +} + +// recordRunningVersion sets RunningVersion after adopt-seed set current +// (R-B-4). Best effort: inventory only. +func (b *Bootstrap) recordRunningVersion(v string) { + if b.SetRunningVersion == nil { + return + } + if err := b.SetRunningVersion(v); err != nil { + b.logf("adopt-seed: setting RunningVersion: %v", err) + } +} diff --git a/internal/launcher/bootstrap_faults_test.go b/internal/launcher/bootstrap_faults_test.go new file mode 100644 index 00000000..ff383ef6 --- /dev/null +++ b/internal/launcher/bootstrap_faults_test.go @@ -0,0 +1,61 @@ +//go:build selfupdate_faults + +package launcher + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAdoptSeed_FaultAtCopiedLeavesNoStagingAndNoPointer(t *testing.T) { + t.Setenv("DMG_FAULT", "adopt.copied:err") + f := newFixture(t) + f.adopt(f.seed("1.5.0")) + entries, _ := os.ReadDir(f.layout.VersionsDir()) + if len(entries) != 0 { + t.Fatalf("versions not empty: %v", entries) + } + if _, err := os.Stat(f.layout.StateFile()); !os.IsNotExist(err) { + t.Fatal("state points at a seed that was never renamed in") + } + // The next adopt converges. + t.Setenv("DMG_FAULT", "") + f.adopt(filepath.Join(f.layout.InstallRoot, "seed", "1.5.0")) + if f.load().Current != "1.5.0" { + t.Fatal("did not converge") + } +} + +func TestAdoptSeed_FaultAtStateTempKeepsOldState(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.4.0"}`) + f.version("1.4.0") + t.Setenv("DMG_FAULT", "adopt.state-temp:err") + f.adopt(f.seed("1.5.0")) + if f.load().Current != "1.4.0" { + t.Fatal("state changed despite the fault") + } + t.Setenv("DMG_FAULT", "") + f.adopt(filepath.Join(f.layout.InstallRoot, "seed", "1.5.0")) + if st := f.load(); st.Current != "1.5.0" || st.Previous != "1.4.0" { + t.Fatalf("did not converge: %+v", st) + } +} + +func TestRun_CheckpointErrorsDoNotBlockLaunch(t *testing.T) { + t.Setenv("DMG_FAULT", "boot.resolved:err,boot.launched:err") + trace := filepath.Join(t.TempDir(), "trace") + t.Setenv("DMG_FAULT_TRACE", trace) + f := newFixture(t) + f.state(`{"schema":1,"current":"1.2.0"}`) + f.version("1.2.0") + if code := f.b.Run(scanArgs(f.layout.DataRoot)); code != 0 || len(f.sp.calls) != 1 { + t.Fatalf("code=%d calls=%d", code, len(f.sp.calls)) + } + got, _ := os.ReadFile(trace) + if !strings.Contains(string(got), "boot.resolved\nboot.launched\n") { + t.Fatalf("trace = %q", got) + } +} diff --git a/internal/launcher/bootstrap_test.go b/internal/launcher/bootstrap_test.go new file mode 100644 index 00000000..21bde99e --- /dev/null +++ b/internal/launcher/bootstrap_test.go @@ -0,0 +1,940 @@ +package launcher + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/updatestate" +) + +type spawnCall struct { + path string + args []string + env []string + deadline time.Duration +} + +type fakeSpawner struct { + calls []spawnCall + results []SpawnResult // consumed in order; default exit 0 + during func(call spawnCall) +} + +func (f *fakeSpawner) spawn(path string, args, env []string, deadline time.Duration, onStart func()) SpawnResult { + c := spawnCall{path, append([]string(nil), args...), env, deadline} + f.calls = append(f.calls, c) + res := SpawnResult{Started: true} + if len(f.results) > 0 { + res, f.results = f.results[0], f.results[1:] + } + if res.Started { + onStart() + if f.during != nil { + f.during(c) + } + } + return res +} + +func (c spawnCall) envValue(key string) (string, bool) { + for _, kv := range c.env { + if k, v, _ := strings.Cut(kv, "="); k == key { + return v, true + } + } + return "", false +} + +type fixture struct { + t *testing.T + b *Bootstrap + sp *fakeSpawner + out *bytes.Buffer + layout updatestate.Layout +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + root := t.TempDir() + l := updatestate.Layout{InstallRoot: filepath.Join(root, "install"), DataRoot: filepath.Join(root, "data")} + for _, d := range []string{l.VersionsDir(), l.StateDir(), l.DataRoot} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + sp := &fakeSpawner{} + out := &bytes.Buffer{} + now := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC) + runSeq := 0 + b := &Bootstrap{ + Layout: l, + Spawn: sp.spawn, + IsSystem: func() bool { return true }, + Now: func() time.Time { return now }, + Sleep: func(d time.Duration) { now = now.Add(d) }, + Getenv: func(string) string { return "" }, + Environ: func() []string { + return []string{"PATH=/bin", "STEPSEC_RUN_RECORD=stale", "STEPSEC_BOOTSTRAP_FALLBACK=stale"} + }, + Out: out, + NewRunID: func() string { + runSeq++ + return fmt.Sprintf("run-%d", runSeq) + }, + AdoptLockWait: 2 * time.Second, + RecordLockWait: time.Second, + LockPoll: 500 * time.Millisecond, + UpdateDeadline: defaultUpdateDeadline, + } + return &fixture{t: t, b: b, sp: sp, out: out, layout: l} +} + +func mkVersionDir(t *testing.T, dir string, files ...string) { + t.Helper() + if len(files) == 0 { + files = updatestate.VersionFiles + } + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for _, f := range files { + if err := os.WriteFile(filepath.Join(dir, f), []byte("bin:"+filepath.Base(dir)+":"+f), 0o755); err != nil { + t.Fatal(err) + } + } +} + +func (f *fixture) version(v string, files ...string) { + mkVersionDir(f.t, filepath.Join(f.layout.VersionsDir(), v), files...) +} + +func (f *fixture) seed(v string) string { + d := filepath.Join(f.layout.InstallRoot, "seed", v) + mkVersionDir(f.t, d) + return d +} + +func (f *fixture) state(body string) { + if err := os.WriteFile(f.layout.StateFile(), []byte(body), 0o600); err != nil { + f.t.Fatal(err) + } +} + +func (f *fixture) load() *updatestate.State { + st, corrupt, err := updatestate.Load(f.layout.StateFile()) + if err != nil || corrupt || st == nil { + f.t.Fatalf("load state: st=%v corrupt=%v err=%v", st, corrupt, err) + } + return st +} + +func scanArgs(dataRoot string) []string { + return []string{"send-telemetry", "--install-dir=" + dataRoot} +} + +// TestRun_BootstrapMatrix: every state.json / directory corruption case +// must still launch something (harness §2 "Bootstrap matrix"). +func TestRun_BootstrapMatrix(t *testing.T) { + huge := `{"schema":1,"current":"1.2.0","pad":"` + strings.Repeat("x", 2<<20) + `"}` + cases := []struct { + name string + state *string // nil = no file + versions map[string][]string + seed bool + wantDir string // "seed" or a version + wantReason string + }{ + {"healthy current", ptr(`{"schema":1,"current":"1.2.0","previous":"1.1.0"}`), map[string][]string{"1.2.0": nil, "1.1.0": nil}, true, "1.2.0", ""}, + {"state missing", nil, map[string][]string{"1.2.0": nil}, true, "seed", ReasonStateMissing}, + {"state empty", ptr(``), map[string][]string{"1.2.0": nil}, true, "seed", ReasonStateCorrupt}, + {"state garbage", ptr(`{{{`), map[string][]string{"1.2.0": nil}, true, "seed", ReasonStateCorrupt}, + {"state unknown old schema", ptr(`{"schema":0,"current":"1.2.0"}`), map[string][]string{"1.2.0": nil}, true, "seed", ReasonStateCorrupt}, + {"schema 2 with extra fields reads current/previous", ptr(`{"schema":2,"current":"1.2.0","previous":"1.1.0","probation":{"weird":true},"quarantine":[1],"updater":"x","new":{"a":[1]}}`), + map[string][]string{"1.1.0": nil}, true, "1.1.0", ReasonCurrentMissing + ":1.2.0"}, + {"state newer schema still read", ptr(`{"schema":9,"current":"1.2.0","previous":"1.1.0","new":{"x":1}}`), map[string][]string{"1.2.0": nil}, true, "1.2.0", ""}, + {"state huge", &huge, map[string][]string{"1.2.0": nil}, true, "seed", ReasonStateCorrupt}, + {"current traversal", ptr(`{"schema":1,"current":"..\\x","previous":"1.1.0"}`), map[string][]string{"1.1.0": nil}, true, "seed", ReasonStateCorrupt}, + {"newer schema traversal", ptr(`{"schema":2,"current":"../x"}`), nil, true, "seed", ReasonStateCorrupt}, + {"current dir missing", ptr(`{"schema":1,"current":"1.2.0","previous":"1.1.0"}`), map[string][]string{"1.1.0": nil}, true, "1.1.0", ReasonCurrentMissing + ":1.2.0"}, + {"current missing task.exe", ptr(`{"schema":1,"current":"1.2.0","previous":"1.1.0"}`), map[string][]string{"1.2.0": {updatestate.AgentFile}, "1.1.0": nil}, true, "1.1.0", ReasonCurrentIncomplete + ":1.2.0"}, + {"current missing agent.exe", ptr(`{"schema":1,"current":"1.2.0","previous":"1.1.0"}`), map[string][]string{"1.2.0": {updatestate.LauncherFile}, "1.1.0": nil}, true, "1.1.0", ReasonCurrentIncomplete + ":1.2.0"}, + {"current and previous missing", ptr(`{"schema":1,"current":"1.2.0","previous":"1.1.0"}`), nil, true, "seed", ReasonCurrentMissing + ":1.2.0"}, + {"no previous", ptr(`{"schema":1,"current":"1.2.0"}`), nil, true, "seed", ReasonCurrentMissing + ":1.2.0"}, + {"seed missing, current fine", ptr(`{"schema":1,"current":"1.2.0"}`), map[string][]string{"1.2.0": nil}, false, "1.2.0", ""}, + {"seed missing, previous used", ptr(`{"schema":1,"current":"1.2.0","previous":"1.1.0"}`), map[string][]string{"1.1.0": nil}, false, "1.1.0", ReasonCurrentMissing + ":1.2.0"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newFixture(t) + if tc.state != nil { + f.state(*tc.state) + } + for v, files := range tc.versions { + f.version(v, files...) + } + seedDir := "" + if tc.seed { + seedDir = f.seed("1.0.0") + } + code := f.b.Run(scanArgs(f.layout.DataRoot)) + if code != 0 { + t.Fatalf("exit = %d; log:\n%s", code, f.out) + } + if len(f.sp.calls) != 1 { + t.Fatalf("spawned %d times", len(f.sp.calls)) + } + c := f.sp.calls[0] + wantDir, wantVer := seedDir, "1.0.0" + if tc.wantDir != "seed" { + wantDir, wantVer = filepath.Join(f.layout.VersionsDir(), tc.wantDir), tc.wantDir + } + if c.path != filepath.Join(wantDir, updatestate.LauncherFile) { + t.Fatalf("launched %s, want under %s", c.path, wantDir) + } + if strings.Join(c.args, " ") != strings.Join(scanArgs(f.layout.DataRoot), " ") { + t.Fatalf("args = %v", c.args) + } + got, ok := c.envValue(EnvBootstrapFallback) + if tc.wantReason == "" && ok { + t.Fatalf("unexpected fallback %q", got) + } + if tc.wantReason != "" && got != tc.wantReason { + t.Fatalf("fallback = %q, want %q", got, tc.wantReason) + } + if v, _ := c.envValue(EnvBootstrapVersion); v != wantVer { + t.Fatalf("version env = %q", v) + } + runFile := filepath.Join(f.layout.DataRoot, "runs", wantVer+".json") + if v, _ := c.envValue(EnvRunRecord); v != runFile { + t.Fatalf("run record env = %q, want %q", v, runFile) + } + for _, kv := range c.env { + if strings.Contains(kv, "stale") { + t.Fatalf("stale env leaked: %v", c.env) + } + } + recs := updatestate.ReadRuns(runFile) + if len(recs) != 1 || recs[0].ExitCode == nil || *recs[0].ExitCode != 0 { + t.Fatalf("run records = %+v", recs) + } + if id, _ := c.envValue(EnvRunID); id == "" || recs[0].RunID != id { + t.Fatalf("run_id env %q vs record %q", id, recs[0].RunID) + } + }) + } +} + +func ptr(s string) *string { return &s } + +func TestRun_NothingLaunchableExits70(t *testing.T) { + f := newFixture(t) + f.state(`garbage`) + if code := f.b.Run(scanArgs(f.layout.DataRoot)); code != ExitCannotStart { + t.Fatalf("exit = %d", code) + } + if len(f.sp.calls) != 0 { + t.Fatal("spawned with nothing launchable") + } +} + +func TestRun_StartFailureFallsThroughAndRecords70(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.2.0"}`) + f.version("1.2.0") + f.seed("1.0.0") + f.sp.results = []SpawnResult{{Err: errors.New("access denied")}, {Started: true, ExitCode: 0}} + if code := f.b.Run(scanArgs(f.layout.DataRoot)); code != 0 { + t.Fatalf("exit = %d", code) + } + recs := updatestate.ReadRuns(f.layout.RunFile(updatestate.Version{Major: 1, Minor: 2})) + if len(recs) != 1 || *recs[0].ExitCode != ExitCannotStart || updatestate.ClassifyRun(recs[0]) != updatestate.RunBad { + t.Fatalf("1.2.0 records = %+v", recs) + } + if r, _ := f.sp.calls[1].envValue(EnvBootstrapFallback); r != ReasonStartFailed+":1.2.0" { + t.Fatalf("fallback = %q", r) + } +} + +func TestRun_AllStartsFailExit70(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.2.0"}`) + f.version("1.2.0") + f.seed("1.0.0") + f.sp.results = []SpawnResult{{Err: errors.New("x")}, {Err: errors.New("y")}} + if code := f.b.Run(scanArgs(f.layout.DataRoot)); code != ExitCannotStart { + t.Fatalf("exit = %d", code) + } +} + +func TestRun_ChildExitAndWatchdogRecorded(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.2.0"}`) + f.version("1.2.0") + runFile := f.layout.RunFile(updatestate.Version{Major: 1, Minor: 2}) + // The agent marks started while it runs. + f.sp.during = func(c spawnCall) { + id, _ := c.envValue(EnvRunID) + if err := updatestate.MarkRunStarted(runFile, id); err != nil { + t.Fatal(err) + } + } + f.sp.results = []SpawnResult{{Started: true, ExitCode: 0}, {Started: true, Killed: true, ExitCode: 1}} + f.b.Run(scanArgs(f.layout.DataRoot)) + if code := f.b.Run(scanArgs(f.layout.DataRoot)); code != ExitKilled { + t.Fatalf("killed exit = %d", code) + } + recs := updatestate.ReadRuns(runFile) + if len(recs) != 2 { + t.Fatalf("records = %+v", recs) + } + if updatestate.ClassifyRun(recs[0]) != updatestate.RunHealthy { + t.Fatalf("first = %+v", recs[0]) + } + if !recs[1].WatchdogKilled || updatestate.ClassifyRun(recs[1]) != updatestate.RunBad { + t.Fatalf("second = %+v", recs[1]) + } +} + +// AppendRun failing (here: runs\ is a file) must not block the scan; the +// child gets no run id, so the agent skips the started marker (D13). +func TestRun_UnwritableRunsDirStillLaunches(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.2.0"}`) + f.version("1.2.0") + // A file where runs\ should be: records can't be written. + if err := os.WriteFile(f.layout.RunsDir(), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + f.sp.results = []SpawnResult{{Started: true, ExitCode: 3}} + if code := f.b.Run(scanArgs(f.layout.DataRoot)); code != 3 { + t.Fatalf("exit = %d", code) + } + c := f.sp.calls[0] + if _, ok := c.envValue(EnvRunID); ok { + t.Fatal("STEPSEC_RUN_ID passed without a run record") + } + if v, _ := c.envValue(EnvBootstrapVersion); v != "1.2.0" { + t.Fatalf("version env = %q", v) + } +} + +func TestRunDeadline(t *testing.T) { + f := newFixture(t) + if d := f.b.runDeadline(f.layout.DataRoot); d != defaultExecutionDeadline+runDeadlineSlack { + t.Fatalf("default = %s", d) + } + if err := os.WriteFile(filepath.Join(f.layout.DataRoot, "config.json"), []byte(`{"max_execution_duration":"2h"}`), 0o600); err != nil { + t.Fatal(err) + } + if d := f.b.runDeadline(f.layout.DataRoot); d != 2*time.Hour+runDeadlineSlack { + t.Fatalf("config = %s", d) + } + f.b.Getenv = func(k string) string { + if k == envMaxExecution { + return "off" + } + return "" + } + if d := f.b.runDeadline(f.layout.DataRoot); d != 0 { + t.Fatalf("off = %s", d) + } + f.b.Getenv = func(k string) string { + if k == envMaxExecution { + return "3s" + } + return "" + } + f.state(`{"schema":1,"current":"1.2.0"}`) + f.version("1.2.0") + f.b.Run(scanArgs(f.layout.DataRoot)) + if got := f.sp.calls[0].deadline; got != 3*time.Second+runDeadlineSlack { + t.Fatalf("spawn deadline = %s", got) + } +} + +func TestMain_Dispatch(t *testing.T) { + f := newFixture(t) + if code := f.b.Main(nil); code != exitUsage { + t.Fatalf("no verb = %d", code) + } + if code := f.b.Main([]string{"bogus"}); code != exitUsage { + t.Fatalf("bogus verb = %d", code) + } +} + +func TestIsBootstrapBinary(t *testing.T) { + for in, want := range map[string]bool{ + `C:\Program Files\StepSecurity\stepsecurity-dev-machine-guard-bootstrap.exe`: true, + `C:\x\STEPSECURITY-DEV-MACHINE-GUARD-BOOTSTRAP.EXE`: true, + `C:\x\versions\1.2.0\task.exe`: false, + `/tmp/stepsecurity-dev-machine-guard-task.exe`: false, + } { + if got := IsBootstrapBinary(in); got != want { + t.Errorf("%s = %v", in, got) + } + } +} + +// --- update --- + +func (f *fixture) updaterRan() string { + f.t.Helper() + if len(f.sp.calls) != 1 { + f.t.Fatalf("spawned %d times; log:\n%s", len(f.sp.calls), f.out) + } + c := f.sp.calls[0] + if filepath.Base(c.path) != updatestate.AgentFile || strings.Join(c.args, " ") != "self-update --system" { + f.t.Fatalf("call = %+v", c) + } + rel, _ := filepath.Rel(f.layout.InstallRoot, filepath.Dir(c.path)) + return filepath.ToSlash(rel) +} + +func TestUpdate_Selection(t *testing.T) { + cases := []struct { + name string + state *string + versions []string + want string + }{ + {"fresh install no state", nil, nil, "seed/1.0.0"}, + {"corrupt state", ptr(`nope`), []string{"1.2.0"}, "seed/1.0.0"}, + {"confirmed current", ptr(`{"schema":1,"current":"1.2.0","previous":"1.1.0"}`), []string{"1.2.0", "1.1.0"}, "versions/1.2.0"}, + {"probation uses previous", ptr(`{"schema":1,"current":"1.2.0","previous":"1.1.0","probation":{"version":"1.2.0"}}`), []string{"1.2.0", "1.1.0"}, "versions/1.1.0"}, + {"probation without previous uses seed", ptr(`{"schema":1,"current":"1.2.0","probation":{"version":"1.2.0"}}`), []string{"1.2.0"}, "seed/1.0.0"}, + {"current dir missing", ptr(`{"schema":1,"current":"1.2.0","previous":"1.1.0"}`), []string{"1.1.0"}, "seed/1.0.0"}, + {"two failures keep current", ptr(`{"schema":1,"current":"1.2.0","updater":{"version":"1.2.0","consecutive_failures":2}}`), []string{"1.2.0"}, "versions/1.2.0"}, + {"three failures use seed", ptr(`{"schema":1,"current":"1.2.0","updater":{"version":"1.2.0","consecutive_failures":3}}`), []string{"1.2.0"}, "seed/1.0.0"}, + {"failures of another version ignored", ptr(`{"schema":1,"current":"1.3.0","updater":{"version":"1.2.0","consecutive_failures":5}}`), []string{"1.3.0"}, "versions/1.3.0"}, + {"newer schema reads current", ptr(`{"schema":4,"current":"1.2.0"}`), []string{"1.2.0"}, "versions/1.2.0"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newFixture(t) + if tc.state != nil { + f.state(*tc.state) + } + for _, v := range tc.versions { + f.version(v) + } + f.seed("1.0.0") + if code := f.b.Update(); code != 0 { + t.Fatalf("exit = %d", code) + } + if got := f.updaterRan(); got != tc.want { + t.Fatalf("updater = %s, want %s", got, tc.want) + } + if f.sp.calls[0].deadline != defaultUpdateDeadline { + t.Fatalf("deadline = %s", f.sp.calls[0].deadline) + } + }) + } +} + +func TestUpdate_RequiresSystem(t *testing.T) { + f := newFixture(t) + f.b.IsSystem = func() bool { return false } + if code := f.b.Update(); code == 0 || len(f.sp.calls) != 0 { + t.Fatalf("code=%d calls=%d", code, len(f.sp.calls)) + } +} + +func TestUpdate_NoUpdaterExit70(t *testing.T) { + f := newFixture(t) + if code := f.b.Update(); code != ExitCannotStart { + t.Fatalf("exit = %d", code) + } +} + +func TestUpdate_RecordsConsecutiveFailures(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.2.0","future":"kept"}`) + f.version("1.2.0") + f.seed("1.0.0") + for i, res := range []SpawnResult{ + {Started: true, ExitCode: 1}, + {Started: true, Killed: true}, + {Err: errors.New("gone")}, // start failure counts, then seed runs + } { + f.sp.calls = nil + f.sp.results = []SpawnResult{res, {Started: true}} + f.b.Update() + if got := f.load().Updater; got.Version != "1.2.0" || got.ConsecutiveFailures != i+1 { + t.Fatalf("after failure %d: %+v", i+1, got) + } + } + // Threshold reached: the seed runs and the record is left alone. + f.sp.calls = nil + f.sp.results = nil + f.b.Update() + if got := f.updaterRan(); got != "seed/1.0.0" { + t.Fatalf("updater = %s", got) + } + if got := f.load().Updater; got.ConsecutiveFailures != 3 { + t.Fatalf("seed run changed the record: %+v", got) + } + // A new current resets selection; success clears the counter. + f.state(`{"schema":1,"current":"1.3.0","updater":{"version":"1.2.0","consecutive_failures":3}}`) + f.version("1.3.0") + f.sp.calls = nil + f.b.Update() + if got := f.load().Updater; got.Version != "1.3.0" || got.ConsecutiveFailures != 0 { + t.Fatalf("after success: %+v", got) + } +} + +func TestUpdate_KeepsUnknownStateFields(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.2.0","future":"kept"}`) + f.version("1.2.0") + f.sp.results = []SpawnResult{{Started: true, ExitCode: 4}} + if code := f.b.Update(); code != 4 { + t.Fatalf("exit = %d", code) + } + data, _ := os.ReadFile(f.layout.StateFile()) + if !strings.Contains(string(data), `"future"`) { + t.Fatalf("unknown field dropped: %s", data) + } +} + +func TestUpdate_LockHeldSkipsRecord(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.2.0"}`) + f.version("1.2.0") + l, err := updatestate.AcquireLock(f.layout.LockFile()) + if err != nil { + t.Fatal(err) + } + defer l.Release() + f.sp.results = []SpawnResult{{Started: true, ExitCode: 1}} + f.b.Update() + if got := f.load().Updater; got.ConsecutiveFailures != 0 { + t.Fatalf("recorded under a held lock: %+v", got) + } +} + +// --- adopt-seed --- + +func (f *fixture) adopt(seedDir string) { + f.t.Helper() + if code := f.b.AdoptSeed([]string{"--seed-dir", seedDir}); code != 0 { + f.t.Fatalf("adopt-seed exit = %d", code) + } +} + +func TestAdoptSeed_FreshInstall(t *testing.T) { + f := newFixture(t) + if err := os.RemoveAll(f.layout.VersionsDir()); err != nil { + t.Fatal(err) + } + seed := f.seed("1.5.0") + f.adopt(seed + string(filepath.Separator)) + dst := filepath.Join(f.layout.VersionsDir(), "1.5.0") + if err := updatestate.VerifyVersionDir(dst, updatestate.Version{Major: 1, Minor: 5}); err != nil { + t.Fatalf("adopted seed does not verify against its manifest: %v", err) + } + for _, n := range updatestate.VersionFiles { + got, err := os.ReadFile(filepath.Join(dst, n)) + want, _ := os.ReadFile(filepath.Join(seed, n)) + if err != nil || !bytes.Equal(got, want) { + t.Fatalf("%s not copied: %v", n, err) + } + } + if st := f.load(); st.Current != "1.5.0" || st.Previous != "" { + t.Fatalf("state = %+v", st) + } + entries, _ := os.ReadDir(f.layout.VersionsDir()) + if len(entries) != 1 { + t.Fatalf("leftovers in versions: %v", entries) + } +} + +func TestAdoptSeed_StateRules(t *testing.T) { + cases := []struct { + name string + state string + versions []string + wantCurrent string + wantPrevious string + wantUnchanged bool + }{ + {"seed newer upgrades", `{"schema":1,"current":"1.4.0","probation":{"version":"1.4.0"}}`, []string{"1.4.0"}, "1.5.0", "1.4.0", false}, + {"seed older never downgrades", `{"schema":1,"current":"1.6.0","previous":"1.5.0"}`, []string{"1.6.0"}, "1.6.0", "1.5.0", true}, + {"seed equal keeps", `{"schema":1,"current":"1.5.0","previous":"1.4.0"}`, nil, "1.5.0", "1.4.0", true}, + {"current missing repaired", `{"schema":1,"current":"1.6.0","previous":"1.4.0","probation":{"version":"1.6.0"}}`, nil, "1.5.0", "1.4.0", false}, + {"current incomplete repaired", `{"schema":1,"current":"1.6.0"}`, nil, "1.5.0", "", false}, + {"quarantined seed not adopted", `{"schema":1,"current":"1.4.0","quarantine":{"1.5.0":{"reason":"bad_runs","at":"2026-01-01T00:00:00Z"}}}`, []string{"1.4.0"}, "1.4.0", "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newFixture(t) + f.state(tc.state) + for _, v := range tc.versions { + f.version(v) + } + if tc.name == "current incomplete repaired" { + f.version("1.6.0", updatestate.AgentFile) + } + seed := f.seed("1.5.0") + f.adopt(seed) + st := f.load() + if st.Current != tc.wantCurrent || st.Previous != tc.wantPrevious { + t.Fatalf("state = %+v", st) + } + if !tc.wantUnchanged && st.Probation != nil { + t.Fatalf("probation kept: %+v", st.Probation) + } + if !updatestate.VersionDirUsable(filepath.Join(f.layout.VersionsDir(), "1.5.0")) { + t.Fatal("seed not copied") + } + }) + } +} + +func TestAdoptSeed_IncompleteSeedVersionReplaced(t *testing.T) { + f := newFixture(t) + f.version("1.5.0", updatestate.LauncherFile) + f.adopt(f.seed("1.5.0")) + if !updatestate.VersionDirUsable(filepath.Join(f.layout.VersionsDir(), "1.5.0")) { + t.Fatal("incomplete version dir not replaced") + } +} + +func TestAdoptSeed_NewerSchemaUntouched(t *testing.T) { + f := newFixture(t) + body := `{"schema":2,"current":"1.4.0"}` + f.state(body) + f.version("1.4.0") + f.adopt(f.seed("1.5.0")) + if got, _ := os.ReadFile(f.layout.StateFile()); string(got) != body { + t.Fatalf("schema-2 state rewritten: %s", got) + } +} + +func TestAdoptSeed_NoOpCases(t *testing.T) { + t.Run("lock held", func(t *testing.T) { + f := newFixture(t) + l, err := updatestate.AcquireLock(f.layout.LockFile()) + if err != nil { + t.Fatal(err) + } + defer l.Release() + f.adopt(f.seed("1.5.0")) + if _, err := os.Stat(f.layout.StateFile()); !os.IsNotExist(err) { + t.Fatal("state written without the lock") + } + if !strings.Contains(f.out.String(), "lock not taken") { + t.Fatalf("log:\n%s", f.out) + } + }) + t.Run("not system", func(t *testing.T) { + f := newFixture(t) + f.b.IsSystem = func() bool { return false } + f.adopt(f.seed("1.5.0")) + if _, err := os.Stat(f.layout.StateFile()); !os.IsNotExist(err) { + t.Fatal("state written") + } + }) + t.Run("seed outside install root", func(t *testing.T) { + f := newFixture(t) + other := filepath.Join(t.TempDir(), "seed", "1.5.0") + mkVersionDir(t, other) + f.adopt(other) + if _, err := os.Stat(f.layout.StateFile()); !os.IsNotExist(err) { + t.Fatal("adopted a foreign seed dir") + } + }) + t.Run("incomplete seed", func(t *testing.T) { + f := newFixture(t) + d := filepath.Join(f.layout.InstallRoot, "seed", "1.5.0") + mkVersionDir(t, d, updatestate.AgentFile) + f.adopt(d) + if _, err := os.Stat(f.layout.StateFile()); !os.IsNotExist(err) { + t.Fatal("adopted an incomplete seed") + } + }) + t.Run("missing flag", func(t *testing.T) { + f := newFixture(t) + if code := f.b.AdoptSeed(nil); code != 0 { + t.Fatalf("exit = %d", code) + } + }) +} + +func TestChildEnv_ReplacesOurVars(t *testing.T) { + f := newFixture(t) + env := f.b.childEnv(map[string]string{EnvRunRecord: "r", EnvBootstrapVersion: "1.2.0"}) + joined := strings.Join(env, "\n") + if strings.Contains(joined, "stale") || !strings.Contains(joined, "STEPSEC_RUN_RECORD=r") || strings.Contains(joined, EnvBootstrapFallback) { + t.Fatalf("env = %v", env) + } +} + +// Overlapping hourly + Logon runs of one version: each run finalizes only its +// own record (run_id), never the newest one. +func TestRun_OverlappingRunsKeepTheirOwnRecords(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.2.0"}`) + f.version("1.2.0") + nested := false + f.sp.during = func(spawnCall) { + if nested { + return + } + nested = true + // The Logon task fires while the hourly scan is still running. + if code := f.b.Run(scanArgs(f.layout.DataRoot)); code != 0 { + t.Errorf("inner run exit = %d", code) + } + } + f.sp.results = []SpawnResult{{Started: true, ExitCode: 5}, {Started: true, ExitCode: 0}} + if code := f.b.Run(scanArgs(f.layout.DataRoot)); code != 5 { + t.Fatalf("outer run exit = %d", code) + } + recs := updatestate.ReadRuns(f.layout.RunFile(updatestate.Version{Major: 1, Minor: 2})) + if len(recs) != 2 { + t.Fatalf("records = %+v", recs) + } + got := map[string]int{} + for _, r := range recs { + if r.ExitCode == nil { + t.Fatalf("open record left: %+v", r) + } + got[r.RunID] = *r.ExitCode + } + outer, _ := f.sp.calls[0].envValue(EnvRunID) + inner, _ := f.sp.calls[1].envValue(EnvRunID) + if outer == inner || got[outer] != 5 || got[inner] != 0 { + t.Fatalf("outer %s inner %s records %v", outer, inner, got) + } +} + +func TestAdoptSeed_UnverifiableSeedVersionReplaced(t *testing.T) { + f := newFixture(t) + f.version("1.5.0") // all files, no manifest + f.adopt(f.seed("1.5.0")) + if err := updatestate.VerifyVersionDir(filepath.Join(f.layout.VersionsDir(), "1.5.0"), updatestate.Version{Major: 1, Minor: 5}); err != nil { + t.Fatalf("not replaced by a verified copy: %v", err) + } +} + +// R-B-1: a schema-1 state that a future writer filled with RC versions +// outside current/previous still resolves current, for run and update. +func TestFrozenSchema1_RCFieldsStillResolveCurrent(t *testing.T) { + body := `{"schema":1,"current":"1.21.0","previous":"1.20.0","quarantine":{"1.22.0-rc.1":{"reason":"bad_runs"}},"updater":{"version":"1.22.0-rc.1","consecutive_failures":0},"probation":{"version":"1.22.0-rc.1"}}` + f := newFixture(t) + f.state(body) + f.version("1.21.0") + f.version("1.20.0") + f.seed("1.19.0") + f.b.Run(scanArgs(f.layout.DataRoot)) + c := f.sp.calls[0] + if !strings.Contains(c.path, filepath.Join("versions", "1.21.0")) { + t.Fatalf("run launched %s", c.path) + } + if r, ok := c.envValue(EnvBootstrapFallback); ok { + t.Fatalf("unexpected fallback %q", r) + } + f.sp.calls = nil + f.b.Update() + if got := f.updaterRan(); got != "versions/1.21.0" { + t.Fatalf("updater = %s", got) + } +} + +// R-B-1: adopt-seed never replaces an existing state with a fresh one. It +// changes current/previous through the preserving Save, and leaves a state it +// can't round-trip (odd fields, unreadable pointers) byte-for-byte alone. +func TestAdoptSeed_PreservesExistingState(t *testing.T) { + t.Run("unknown fields kept", func(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.4.0","future_top":{"a":1},"updater":{"version":"1.4.0","consecutive_failures":0,"future_nested":true}}`) + f.version("1.4.0") + f.adopt(f.seed("1.5.0")) + data, _ := os.ReadFile(f.layout.StateFile()) + if !strings.Contains(string(data), "future_top") || !strings.Contains(string(data), "future_nested") { + t.Fatalf("unknown fields dropped: %s", data) + } + if st := f.load(); st.Current != "1.5.0" || st.Previous != "1.4.0" { + t.Fatalf("state = %+v", st) + } + }) + for name, body := range map[string]string{ + "rc fields": `{"schema":1,"current":"1.4.0","quarantine":{"1.6.0-rc.1":{"reason":"x"}}}`, + "unreadable pointers": `{"schema":1,"current":"zz"}`, + "garbage": `not json`, + } { + t.Run(name, func(t *testing.T) { + f := newFixture(t) + f.state(body) + f.version("1.4.0") + f.adopt(f.seed("1.5.0")) + if got, _ := os.ReadFile(f.layout.StateFile()); string(got) != body { + t.Fatalf("state rewritten to %s", got) + } + }) + } +} + +// R-B-4: adopt-seed sets RunningVersion whenever it sets current, and only then. +func TestAdoptSeed_SetsRunningVersionWithCurrent(t *testing.T) { + cases := []struct { + name string + state string + ver []string + want []string + }{ + {"fresh install", "", nil, []string{"1.5.0"}}, + {"seed newer", `{"schema":1,"current":"1.4.0"}`, []string{"1.4.0"}, []string{"1.5.0"}}, + {"current unusable", `{"schema":1,"current":"1.6.0"}`, nil, []string{"1.5.0"}}, + {"seed older: unchanged", `{"schema":1,"current":"1.6.0"}`, []string{"1.6.0"}, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newFixture(t) + var got []string + f.b.SetRunningVersion = func(v string) error { got = append(got, v); return nil } + if tc.state != "" { + f.state(tc.state) + } + for _, v := range tc.ver { + f.version(v) + } + f.adopt(f.seed("1.5.0")) + if strings.Join(got, ",") != strings.Join(tc.want, ",") { + t.Fatalf("RunningVersion writes = %v, want %v", got, tc.want) + } + }) + } +} + +// R-B-6: only an unexpired quarantine of the seed's own version blocks +// adopting it; an expired one (D5 until) or other versions' entries don't. +func TestAdoptSeed_QuarantineExpiry(t *testing.T) { + // The fixture clock is 2026-10-19T12:00:00Z. + cases := map[string]struct { + quarantine string + want string + }{ + "permanent seed entry blocks": {`{"1.5.0":{"reason":"bad_runs","at":"2026-10-01T00:00:00Z"}}`, "1.4.0"}, + "unexpired seed entry blocks": {`{"1.5.0":{"reason":"inconclusive","at":"2026-10-15T00:00:00Z","until":"2026-10-22T00:00:00Z"}}`, "1.4.0"}, + "expired seed entry allows": {`{"1.5.0":{"reason":"inconclusive","at":"2026-10-01T00:00:00Z","until":"2026-10-08T00:00:00Z"}}`, "1.5.0"}, + "other version never blocks": {`{"1.4.5":{"reason":"bad_runs","at":"2026-10-01T00:00:00Z"}}`, "1.5.0"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.4.0","quarantine":` + tc.quarantine + `}`) + f.version("1.4.0") + f.adopt(f.seed("1.5.0")) + if got := f.load().Current; got != tc.want { + t.Fatalf("current = %s, want %s", got, tc.want) + } + }) + } +} + +// D18: a schema-1 state with values this build's Load rejects (RC versions +// outside the pointers) must not stop the failure count, or the seed-updater +// fallback never triggers and a broken updater is never recovered. +func TestUpdate_RCStateStillCountsFailuresAndFallsBack(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.21.0","previous":"1.20.0","quarantine":{"1.22.0-rc.1":{"reason":"bad_runs","future":1}},"probation":{"version":"1.22.0-rc.2"},"future_top":{"x":[1]}}`) + f.version("1.21.0") + f.version("1.20.0") + f.seed("1.19.0") + for i := 1; i <= 3; i++ { + f.sp.calls = nil + f.sp.results = []SpawnResult{{Started: true, ExitCode: 1}} + f.b.Update() + if got := f.updaterRan(); got != "versions/1.21.0" { + t.Fatalf("tick %d: updater = %s", i, got) + } + } + f.sp.calls = nil + f.sp.results = nil + f.b.Update() + if got := f.updaterRan(); got != "seed/1.19.0" { + t.Fatalf("4th tick: updater = %s, want the seed", got) + } + data, _ := os.ReadFile(f.layout.StateFile()) + for _, keep := range []string{`"1.22.0-rc.1"`, `"future": 1`, `"1.22.0-rc.2"`, `"future_top"`, `"consecutive_failures": 3`} { + if !strings.Contains(string(data), keep) { + t.Fatalf("missing %s in %s", keep, data) + } + } +} + +// D18 follow-up: on a schema-2 state the failure count still increments (the +// updater fields keep their meaning in every schema) and the 4th tick picks +// the seed; schema and every other field stay as they were. +func TestUpdate_Schema2StateStillCountsFailuresAndFallsBack(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":2,"current":"1.21.0","previous":"1.20.0","brand_new":{"x":[1]},"updater":{"version":"","consecutive_failures":0,"extra":true}}`) + f.version("1.21.0") + f.version("1.20.0") + f.seed("1.19.0") + for i := 1; i <= 3; i++ { + f.sp.calls = nil + f.sp.results = []SpawnResult{{Started: true, ExitCode: 1}} + f.b.Update() + if got := f.updaterRan(); got != "versions/1.21.0" { + t.Fatalf("tick %d: updater = %s", i, got) + } + } + f.sp.calls = nil + f.sp.results = nil + f.b.Update() + if got := f.updaterRan(); got != "seed/1.19.0" { + t.Fatalf("4th tick: updater = %s, want the seed", got) + } + data, _ := os.ReadFile(f.layout.StateFile()) + for _, keep := range []string{`"schema": 2`, `"brand_new"`, `"extra": true`, `"consecutive_failures": 3`, `"version": "1.21.0"`} { + if !strings.Contains(string(data), keep) { + t.Fatalf("missing %s in %s", keep, data) + } + } +} + +// FR1-7: a deadline kill is a watchdog kill (Bad) even if the system slept +// meanwhile: the deadline already counts awake time only. +func TestRunOutcomeKillIsWatchdog(t *testing.T) { + code, killed := runOutcome(SpawnResult{Started: true, Killed: true}) + exit := code + if !killed || code != ExitKilled || updatestate.ClassifyRun(updatestate.RunRecord{Version: "1.20.0", Started: true, ExitCode: &exit, WatchdogKilled: killed}) != updatestate.RunBad { + t.Fatalf("kill: code %d killed %v", code, killed) + } + if code, killed := runOutcome(SpawnResult{Started: true, ExitCode: 3}); code != 3 || killed { + t.Fatalf("exit 3: code %d killed %v", code, killed) + } +} + +// Copilot 4356483380: the updater, run from versions\\, resolves the +// same install root as the bootstrap that launched it, wherever the MSI +// put INSTALLFOLDER. +func TestUpdaterRootMatchesBootstrapRoot(t *testing.T) { + root := filepath.Join(t.TempDir(), "D", "Apps", "StepSecurity") + exe := filepath.Join(root, BootstrapBinary) + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(exe, nil, 0o644); err != nil { + t.Fatal(err) + } + b := NewBootstrap(exe, func() bool { return true }) + v, _ := updatestate.ParseVersion("1.20.0") + updater := b.Layout.VersionDir(v) + got, ok := updatestate.BootstrapRoot(filepath.Join(updater, updatestate.AgentFile), func(p string) bool { + _, err := os.Stat(p) + return err == nil + }) + if !ok || got != b.Layout.InstallRoot { + t.Fatalf("updater root = %q (%v), bootstrap root = %q", got, ok, b.Layout.InstallRoot) + } +} diff --git a/cmd/stepsecurity-dev-machine-guard-task/main_test.go b/internal/launcher/job_windows_test.go similarity index 88% rename from cmd/stepsecurity-dev-machine-guard-task/main_test.go rename to internal/launcher/job_windows_test.go index 847e7a36..0a0e5f5e 100644 --- a/cmd/stepsecurity-dev-machine-guard-task/main_test.go +++ b/internal/launcher/job_windows_test.go @@ -1,6 +1,6 @@ //go:build windows -package main +package launcher import ( "testing" @@ -10,9 +10,9 @@ import ( ) func TestNewKillOnCloseJob_AllowsBreakaway(t *testing.T) { - job, err := newKillOnCloseJob() + job, err := NewKillOnCloseJob() if err != nil { - t.Fatalf("newKillOnCloseJob: %v", err) + t.Fatalf("NewKillOnCloseJob: %v", err) } defer func() { _ = windows.CloseHandle(job) }() diff --git a/internal/launcher/main_test.go b/internal/launcher/main_test.go new file mode 100644 index 00000000..bf3018bd --- /dev/null +++ b/internal/launcher/main_test.go @@ -0,0 +1,10 @@ +package launcher + +import ( + "os" + "testing" + + "github.com/step-security/dev-machine-guard/internal/proctest" +) + +func TestMain(m *testing.M) { os.Exit(proctest.Main(m)) } diff --git a/internal/launcher/runningversion_other.go b/internal/launcher/runningversion_other.go new file mode 100644 index 00000000..a3c4bc10 --- /dev/null +++ b/internal/launcher/runningversion_other.go @@ -0,0 +1,6 @@ +//go:build !windows + +package launcher + +// SetRunningVersion is a no-op off Windows (no registry inventory). +func SetRunningVersion(string) error { return nil } diff --git a/internal/launcher/runningversion_windows.go b/internal/launcher/runningversion_windows.go new file mode 100644 index 00000000..8ed8d94f --- /dev/null +++ b/internal/launcher/runningversion_windows.go @@ -0,0 +1,20 @@ +//go:build windows + +package launcher + +import "golang.org/x/sys/windows/registry" + +// runningVersionKey holds RunningVersion (updater-owned inventory) beside +// the MSI-owned AgentVersion, which is never written here. +const runningVersionKey = `Software\StepSecurity` + +// SetRunningVersion writes HKLM\Software\StepSecurity\RunningVersion (64-bit +// view). +func SetRunningVersion(v string) error { + k, _, err := registry.CreateKey(registry.LOCAL_MACHINE, runningVersionKey, registry.SET_VALUE|registry.WOW64_64KEY) + if err != nil { + return err + } + defer k.Close() + return k.SetStringValue("RunningVersion", v) +} diff --git a/internal/launcher/runningversion_windows_test.go b/internal/launcher/runningversion_windows_test.go new file mode 100644 index 00000000..8f28f916 --- /dev/null +++ b/internal/launcher/runningversion_windows_test.go @@ -0,0 +1,34 @@ +//go:build windows + +package launcher + +import ( + "errors" + "testing" + + "golang.org/x/sys/windows/registry" +) + +// Needs an elevated process (GitHub's windows-latest runners are). +func TestSetRunningVersion_WritesHKLM(t *testing.T) { + k, _, err := registry.CreateKey(registry.LOCAL_MACHINE, runningVersionKey, registry.ALL_ACCESS|registry.WOW64_64KEY) + if err != nil { + t.Skipf("HKLM not writable (not elevated?): %v", err) + } + defer k.Close() + prev, _, perr := k.GetStringValue("RunningVersion") + t.Cleanup(func() { + if perr == nil { + _ = k.SetStringValue("RunningVersion", prev) + } else if errors.Is(perr, registry.ErrNotExist) { + _ = k.DeleteValue("RunningVersion") + } + }) + if err := SetRunningVersion("9.8.7"); err != nil { + t.Fatal(err) + } + got, _, err := k.GetStringValue("RunningVersion") + if err != nil || got != "9.8.7" { + t.Fatalf("RunningVersion = %q, %v", got, err) + } +} diff --git a/internal/launcher/runs_concurrency_test.go b/internal/launcher/runs_concurrency_test.go new file mode 100644 index 00000000..7da25fec --- /dev/null +++ b/internal/launcher/runs_concurrency_test.go @@ -0,0 +1,107 @@ +package launcher + +import ( + "bytes" + "fmt" + "math/rand/v2" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/updatestate" + + "github.com/step-security/dev-machine-guard/internal/proctest" +) + +const envRunLoopRoot = "DMG_TEST_RUNLOOP_ROOT" + +// TestHelperRunLoop is the subprocess body of +// TestRun_ConcurrentProcessesKeepEveryRecord; it does nothing otherwise. +func TestHelperRunLoop(t *testing.T) { + root := os.Getenv(envRunLoopRoot) + if root == "" { + t.Skip("subprocess helper") + } + proctest.Guard() + l := updatestate.Layout{InstallRoot: filepath.Join(root, "install"), DataRoot: filepath.Join(root, "data")} + seq, last := 0, "" + logBuf := &bytes.Buffer{} + b := &Bootstrap{ + Layout: l, + Spawn: func(string, []string, []string, time.Duration, func()) SpawnResult { + time.Sleep(time.Duration(rand.IntN(3000)) * time.Microsecond) + return SpawnResult{Started: true} + }, + IsSystem: func() bool { return false }, + Now: time.Now, + Sleep: time.Sleep, + Getenv: func(string) string { return "" }, + Environ: func() []string { return nil }, + Out: logBuf, + NewRunID: func() string { + seq++ + last = fmt.Sprintf("%d-%d", os.Getpid(), seq) + return last + }, + LockPoll: time.Millisecond, + } + runFile := l.RunFile(updatestate.Version{Major: 1, Minor: 2}) + missing := 0 + for i := 0; i < 40; i++ { + b.Run([]string{"send-telemetry", "--install-dir=" + l.DataRoot}) + found := false + for _, r := range updatestate.ReadRuns(runFile) { + if r.RunID == last && r.ExitCode != nil { + found = true + } + } + if !found { + missing++ + } + // Pace the loop like real overlapping tasks: one process must not + // run MaxRunRecords launches during the other's single launch, or + // the cap (not a lost update) evicts the other's open record. + time.Sleep(2 * time.Millisecond) + } + if missing > 0 { + fmt.Printf("lost %d of 40 run records\n%s", missing, logBuf) + os.Exit(1) + } +} + +// R-B-2: two bootstrap processes (the hourly and Logon tasks firing together) +// append and finalize records in the same runs\.json concurrently; no +// record may be lost. +func TestRun_ConcurrentProcessesKeepEveryRecord(t *testing.T) { + if os.Getenv(envRunLoopRoot) != "" { + t.Skip("inside the helper") + } + root := t.TempDir() + l := updatestate.Layout{InstallRoot: filepath.Join(root, "install"), DataRoot: filepath.Join(root, "data")} + if err := os.MkdirAll(l.StateDir(), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(l.StateFile(), []byte(`{"schema":1,"current":"1.2.0"}`), 0o600); err != nil { + t.Fatal(err) + } + mkVersionDir(t, filepath.Join(l.VersionsDir(), "1.2.0")) + var cmds []*exec.Cmd + var outs []*bytes.Buffer + for i := 0; i < 2; i++ { + cmd := proctest.Command(t, os.Args[0], "-test.run=^TestHelperRunLoop$", "-test.count=1") + cmd.Env = append(cmd.Env, envRunLoopRoot+"="+root) + out := &bytes.Buffer{} + cmd.Stdout, cmd.Stderr = out, out + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + cmds, outs = append(cmds, cmd), append(outs, out) + } + for i, cmd := range cmds { + if err := cmd.Wait(); err != nil { + t.Errorf("process %d: %v\n%s", i, err, outs[i]) + } + } +} diff --git a/internal/launcher/spawn.go b/internal/launcher/spawn.go new file mode 100644 index 00000000..f8bdbfbd --- /dev/null +++ b/internal/launcher/spawn.go @@ -0,0 +1,62 @@ +package launcher + +import ( + "errors" + "github.com/step-security/dev-machine-guard/internal/awakeclock" + "os/exec" + "time" +) + +// SpawnResult is the outcome of one child run. +type SpawnResult struct { + Started bool // false: the child never started (Err says why) + ExitCode int // valid when Started + Killed bool // the deadline fired and the child's tree was terminated + Err error // start error, or a wait error with no exit status +} + +// Spawner starts path with args and env, calls onStart once the child is +// running, and waits for it. A positive deadline terminates the child's whole +// process tree when it expires. +type Spawner func(path string, args, env []string, deadline time.Duration, onStart func()) SpawnResult + +// watchdogPoll is how often the awake-time deadline is checked. +var watchdogPoll = 10 * time.Second + +// waitWithDeadline waits for a started cmd, calling kill once if deadline +// (when > 0) expires first. +func waitWithDeadline(cmd *exec.Cmd, deadline time.Duration, kill func()) SpawnResult { + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + // The deadline counts awake time (sleep excluded), see awakeclock. + timeout := make(chan struct{}, 1) + stop := make(chan struct{}) + defer close(stop) + if deadline > 0 { + start := awakeclock.Begin(awakeclock.System) + go func() { + if fired, _ := start.Wait(deadline, stop, watchdogPoll); fired { + timeout <- struct{}{} + } + }() + } + res := SpawnResult{Started: true} + var err error + select { + case err = <-done: + case <-timeout: + res.Killed = true + kill() + err = <-done + } + var ee *exec.ExitError + switch { + case err == nil: + case errors.As(err, &ee): + res.ExitCode = ee.ExitCode() + default: + res.ExitCode = 1 + res.Err = err + } + return res +} diff --git a/internal/launcher/spawn_other.go b/internal/launcher/spawn_other.go new file mode 100644 index 00000000..5f4a8195 --- /dev/null +++ b/internal/launcher/spawn_other.go @@ -0,0 +1,22 @@ +//go:build !windows + +package launcher + +import ( + "os/exec" + "time" +) + +// SpawnHidden runs the child directly; off Windows there is no console or +// job object to manage, and only the direct child is killed on deadline. +func SpawnHidden(path string, args, env []string, deadline time.Duration, onStart func()) SpawnResult { + cmd := exec.Command(path, args...) // #nosec G204 -- the bootstrap launches its own resolved version binaries + cmd.Env = env + if err := cmd.Start(); err != nil { + return SpawnResult{Err: err} + } + if onStart != nil { + onStart() + } + return waitWithDeadline(cmd, deadline, func() { _ = cmd.Process.Kill() }) +} diff --git a/internal/launcher/spawn_other_test.go b/internal/launcher/spawn_other_test.go new file mode 100644 index 00000000..95b3b947 --- /dev/null +++ b/internal/launcher/spawn_other_test.go @@ -0,0 +1,24 @@ +//go:build !windows + +package launcher + +import ( + "testing" + "time" +) + +func TestSpawnHidden_ExitCodeAndDeadline(t *testing.T) { + started := false + res := SpawnHidden("/bin/sh", []string{"-c", "exit 3"}, nil, 0, func() { started = true }) + if !res.Started || !started || res.ExitCode != 3 || res.Killed { + t.Fatalf("exit 3: %+v", res) + } + res = SpawnHidden("/bin/sh", []string{"-c", "sleep 30"}, nil, 200*time.Millisecond, nil) + if !res.Started || !res.Killed { + t.Fatalf("deadline: %+v", res) + } + res = SpawnHidden("/nonexistent/task.exe", nil, nil, 0, nil) + if res.Started || res.Err == nil { + t.Fatalf("missing binary: %+v", res) + } +} diff --git a/internal/launcher/spawn_windows.go b/internal/launcher/spawn_windows.go new file mode 100644 index 00000000..8c7c73d2 --- /dev/null +++ b/internal/launcher/spawn_windows.go @@ -0,0 +1,83 @@ +//go:build windows + +package launcher + +import ( + "fmt" + "os/exec" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" + + "github.com/step-security/dev-machine-guard/internal/winproc" +) + +// CreateNoWindow is CREATE_NO_WINDOW: the console-subsystem child gets no +// console, so nothing flashes under the INTERACTIVE scan task. +const CreateNoWindow uint32 = 0x08000000 + +// SpawnHidden starts the child under CREATE_NO_WINDOW inside a kill-on-close +// job (joined before the child runs; if it can't be, nothing runs), so the child's whole tree dies with this process (Stop-ScheduledTask +// and the task's ExecutionTimeLimit only terminate the action's PID) and a +// deadline terminates the whole tree, nested launcher job included. +func SpawnHidden(path string, args, env []string, deadline time.Duration, onStart func()) SpawnResult { + cmd := exec.Command(path, args...) // #nosec G204 -- the bootstrap launches its own resolved version binaries + cmd.Env = env + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: CreateNoWindow} + job, err := newJob() + if err != nil { + return SpawnResult{Err: fmt.Errorf("kill-on-close job: %w", err)} + } + defer func() { _ = windows.CloseHandle(job) }() + if err := winproc.StartInJob(cmd, job); err != nil { + return SpawnResult{Err: err} + } + if onStart != nil { + onStart() + } + return waitWithDeadline(cmd, deadline, func() { + if windows.TerminateJobObject(job, uint32(ExitKilled)) != nil { + _ = cmd.Process.Kill() + } + }) +} + +// newJob is NewKillOnCloseJob; tests replace it. +var newJob = NewKillOnCloseJob + +// NewKillOnCloseJob creates a job whose processes die when its last handle +// closes. BREAKAWAY_OK lets the agent's CREATE_BREAKAWAY_FROM_JOB spawns +// (WSL guest scans) outlive the run instead of being killed. +func NewKillOnCloseJob() (windows.Handle, error) { + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + return 0, err + } + info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{ + BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{ + LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | windows.JOB_OBJECT_LIMIT_BREAKAWAY_OK, + }, + } + if _, err := windows.SetInformationJobObject( + job, + windows.JobObjectExtendedLimitInformation, + uintptr(unsafe.Pointer(&info)), + uint32(unsafe.Sizeof(info)), + ); err != nil { + _ = windows.CloseHandle(job) + return 0, err + } + return job, nil +} + +// AssignToJob puts the process pid into job. +func AssignToJob(job windows.Handle, pid int) error { + h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(pid)) // #nosec G115 -- pid from os.Process + if err != nil { + return err + } + defer func() { _ = windows.CloseHandle(h) }() + return windows.AssignProcessToJobObject(job, h) +} diff --git a/internal/launcher/spawn_windows_test.go b/internal/launcher/spawn_windows_test.go new file mode 100644 index 00000000..776b2971 --- /dev/null +++ b/internal/launcher/spawn_windows_test.go @@ -0,0 +1,140 @@ +//go:build windows + +package launcher + +import ( + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "golang.org/x/sys/windows" + + "github.com/step-security/dev-machine-guard/internal/proctest" +) + +const envTree = "DMG_TEST_TREE" + +// TestHelperTree is a subprocess body: "parent" starts a "child" copy of +// this binary (inheriting the job), writes the child's pid and sleeps; the +// child just sleeps. It does nothing in a normal test run. +func TestHelperTree(t *testing.T) { + switch os.Getenv(envTree) { + case "parent", "child": + proctest.GuardDeadline() // the job must kill the tree + } + switch os.Getenv(envTree) { + case "parent": + child := exec.Command(os.Args[0], "-test.run=^TestHelperTree$") + child.Env = append(os.Environ(), envTree+"=child") + if err := child.Start(); err != nil { + os.Exit(3) + } + _ = os.WriteFile(os.Getenv(envTree+"_PIDFILE"), []byte(strconv.Itoa(child.Process.Pid)), 0o600) + time.Sleep(time.Minute) + case "child": + time.Sleep(time.Minute) + default: + t.Skip("subprocess helper") + } +} + +// R-B-5: the bootstrap deadline terminates the whole job tree (the child's +// own children included) with exit code 124. +func TestSpawnHidden_DeadlineKillsWholeTree(t *testing.T) { + pidFile := filepath.Join(t.TempDir(), "child.pid") + env := append(os.Environ(), envTree+"=parent", envTree+"_PIDFILE="+pidFile) + start := time.Now() + res := SpawnHidden(os.Args[0], []string{"-test.run=^TestHelperTree$"}, env, 4*time.Second, nil) + if !res.Started || !res.Killed || res.ExitCode != ExitKilled { + t.Fatalf("result = %+v, want started, killed, exit %d", res, ExitKilled) + } + if el := time.Since(start); el > 30*time.Second { + t.Fatalf("took %s", el) + } + data, err := os.ReadFile(pidFile) + if err != nil { + t.Fatalf("child never started: %v", err) + } + pid, _ := strconv.Atoi(strings.TrimSpace(string(data))) + h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid)) // #nosec G115 -- test pid + if err != nil { + return // already gone + } + defer windows.CloseHandle(h) + if ev, _ := windows.WaitForSingleObject(h, 5000); ev != windows.WAIT_OBJECT_0 { + t.Fatalf("grandchild %d survived the deadline kill", pid) + } +} + +// waitGone reports whether pid exits within d (an unopenable pid is gone). +func waitGone(t *testing.T, pid int, d time.Duration) bool { + t.Helper() + h, err := windows.OpenProcess(windows.SYNCHRONIZE|windows.PROCESS_TERMINATE, false, uint32(pid)) // #nosec G115 -- test pid + if err != nil { + return true + } + defer windows.CloseHandle(h) + if ev, _ := windows.WaitForSingleObject(h, uint32(d.Milliseconds())); ev == windows.WAIT_OBJECT_0 { + return true + } + _ = windows.TerminateProcess(h, 1) + return false +} + +// Copilot 4356483255: the child must be in the job before it runs, so a +// grandchild it spawns at once can't escape. A slow job setup used to +// leave the child running outside the job meanwhile. +func TestSpawnHidden_GrandchildSpawnedAtOnceDiesWithJob(t *testing.T) { + newJob = func() (windows.Handle, error) { + time.Sleep(3 * time.Second) + return NewKillOnCloseJob() + } + t.Cleanup(func() { newJob = NewKillOnCloseJob }) + pidFile := filepath.Join(t.TempDir(), "child.pid") + env := append(os.Environ(), envTree+"=parent", envTree+"_PIDFILE="+pidFile) + res := SpawnHidden(os.Args[0], []string{"-test.run=^TestHelperTree$"}, env, 6*time.Second, nil) + if !res.Started || !res.Killed { + t.Fatalf("result = %+v, want started and killed", res) + } + data, err := os.ReadFile(pidFile) + if err != nil { + t.Fatalf("grandchild never started: %v", err) + } + pid, _ := strconv.Atoi(strings.TrimSpace(string(data))) + if !waitGone(t, pid, 5*time.Second) { + t.Fatalf("grandchild %d escaped the job", pid) + } +} + +// Copilot 4356483255: a job setup failure must not leave the child +// running uncontained: nothing runs and the spawn reports the error. +func TestSpawnHidden_JobSetupFailureRunsNothing(t *testing.T) { + for name, mk := range map[string]func() (windows.Handle, error){ + "create fails": func() (windows.Handle, error) { return 0, windows.ERROR_ACCESS_DENIED }, + "assign fails": func() (windows.Handle, error) { return windows.CreateEvent(nil, 0, 0, nil) }, + } { + t.Run(name, func(t *testing.T) { + newJob = mk + t.Cleanup(func() { newJob = NewKillOnCloseJob }) + pidFile := filepath.Join(t.TempDir(), "child.pid") + env := append(os.Environ(), envTree+"=parent", envTree+"_PIDFILE="+pidFile) + started := false + res := SpawnHidden(os.Args[0], []string{"-test.run=^TestHelperTree$"}, env, 4*time.Second, func() { started = true }) + if data, err := os.ReadFile(pidFile); err == nil { + pid, _ := strconv.Atoi(strings.TrimSpace(string(data))) + waitGone(t, pid, 0) + } + if res.Started || started || res.Err == nil { + t.Fatalf("result = %+v (onStart called: %v), want not started with an error", res, started) + } + time.Sleep(time.Second) + if _, err := os.Stat(pidFile); err == nil { + t.Fatal("the child ran uncontained") + } + }) + } +} diff --git a/internal/launcher/stopformsi_test.go b/internal/launcher/stopformsi_test.go new file mode 100644 index 00000000..277bd0b9 --- /dev/null +++ b/internal/launcher/stopformsi_test.go @@ -0,0 +1,77 @@ +package launcher + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// D29: adopt-seed keeps a helper copy of the bootstrap in state\msi\ for +// the MSI's stop-for-msi action (not an MSI file, so the action's own +// image never races InstallFiles). +func TestAdoptSeedRefreshesMSIHelper(t *testing.T) { + f := newFixture(t) + self := filepath.Join(t.TempDir(), "stepsecurity-dev-machine-guard-bootstrap.exe") + if err := os.WriteFile(self, []byte("bootstrap-image-v1"), 0o755); err != nil { + t.Fatal(err) + } + f.b.SelfExe = self + f.adopt(f.seed("1.5.0")) + got, err := os.ReadFile(f.layout.MSIHelperFile()) + if err != nil || !bytes.Equal(got, []byte("bootstrap-image-v1")) { + t.Fatalf("helper = %q, %v", got, err) + } + if err := os.WriteFile(self, []byte("bootstrap-image-v2"), 0o755); err != nil { + t.Fatal(err) + } + f.adopt(f.seed("1.5.0")) + if got, _ := os.ReadFile(f.layout.MSIHelperFile()); string(got) != "bootstrap-image-v2" { + t.Fatalf("helper not refreshed: %q", got) + } +} + +func TestStopForMSIVerb(t *testing.T) { + f := newFixture(t) + if code := f.b.Main([]string{"stop-for-msi", "--install-dir", f.layout.InstallRoot}); code != 0 { + t.Fatalf("stop-for-msi = %d", code) + } + if !strings.Contains(f.out.String(), "stop-for-msi") { + t.Fatalf("not logged: %s", f.out.String()) + } + if code := f.b.Main([]string{"stop-for-msi"}); code != 0 { + t.Fatal("stop-for-msi without --install-dir must not fail the MSI") + } +} + +// SCCM e2e B2: a failed upgrade's rollback puts back the helper the +// previous install left, not the failed build's; a commit drops the copy. +func TestAdoptSeedRollbackRestoresMSIHelper(t *testing.T) { + f := newFixture(t) + self := filepath.Join(t.TempDir(), "stepsecurity-dev-machine-guard-bootstrap.exe") + write := func(s string) { + if err := os.WriteFile(self, []byte(s), 0o755); err != nil { + t.Fatal(err) + } + } + f.b.SelfExe = self + write("bootstrap-old") + f.adopt(f.seed("1.5.0")) + f.adoptFlag("--commit") + write("bootstrap-failed") + f.adopt(f.seed("1.6.0")) + f.adoptFlag("--rollback") + if got, _ := os.ReadFile(f.layout.MSIHelperFile()); string(got) != "bootstrap-old" { + t.Fatalf("helper after rollback = %q, want the previous install's", got) + } + write("bootstrap-new") + f.adopt(f.seed("1.6.0")) + f.adoptFlag("--commit") + if got, _ := os.ReadFile(f.layout.MSIHelperFile()); string(got) != "bootstrap-new" { + t.Fatalf("helper after commit = %q", got) + } + if _, err := os.Lstat(f.layout.MSIHelperFile() + ".prev"); !os.IsNotExist(err) { + t.Fatalf("previous helper copy kept after commit: %v", err) + } +} diff --git a/internal/lock/helper_guard_test.go b/internal/lock/helper_guard_test.go new file mode 100644 index 00000000..7674e836 --- /dev/null +++ b/internal/lock/helper_guard_test.go @@ -0,0 +1,79 @@ +package lock + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/proctest" +) + +// middleEnv makes the test binary start a holder, print its pid, and block: +// the holder's parent, for the test to kill. +const middleEnv = "DMG_LOCK_TEST_MIDDLE" + +func runMiddle(target string) { + cmd := exec.Command(os.Args[0], "-test.run=^$") // #nosec G204 -- the test binary + cmd.Env = append(os.Environ(), helperEnv+"="+target, middleEnv+"=") + out, err := cmd.StdoutPipe() + if err != nil || cmd.Start() != nil { + os.Exit(1) + } + if line, _ := bufio.NewReader(out).ReadString('\n'); strings.TrimSpace(line) != "ready" { + os.Exit(1) + } + fmt.Println(cmd.Process.Pid) + time.Sleep(time.Hour) + os.Exit(0) +} + +// A holder whose test process died (killed, timed out) must not live on. +func TestHolderExitsWhenParentDies(t *testing.T) { + cmd := proctest.Command(t, os.Args[0], "-test.run=^$") + cmd.Env = append(cmd.Env, middleEnv+"="+testLockTarget(t)) + out, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + line, _ := bufio.NewReader(out).ReadString('\n') + pid, err := strconv.Atoi(strings.TrimSpace(line)) + if err != nil { + t.Fatalf("middle said %q", line) + } + _ = cmd.Process.Kill() + _ = cmd.Wait() + if !proctest.WaitGone(pid, 10*time.Second) { + proctest.KillPid(pid) + t.Fatalf("holder %d outlived its parent", pid) + } +} + +// A holder stops at the deadline its test passed down. +func TestHolderExitsAtDeadline(t *testing.T) { + cmd := proctest.Command(t, os.Args[0], "-test.run=^$") + cmd.Env = append(cmd.Env, helperEnv+"="+testLockTarget(t), + proctest.EnvDeadline+"="+strconv.FormatInt(time.Now().Add(time.Second).UnixNano(), 10)) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + go func() { _ = cmd.Wait(); close(done) }() + select { + case <-done: + if code := cmd.ProcessState.ExitCode(); code != proctest.ExitGuard { + t.Fatalf("holder exit %d, want %d", code, proctest.ExitGuard) + } + case <-time.After(15 * time.Second): + _ = cmd.Process.Kill() + <-done + t.Fatal("holder ignored its deadline") + } +} diff --git a/internal/lock/lock_test.go b/internal/lock/lock_test.go index 98707e7c..689b8656 100644 --- a/internal/lock/lock_test.go +++ b/internal/lock/lock_test.go @@ -5,10 +5,11 @@ import ( "errors" "fmt" "os" - "os/exec" "strings" "testing" "time" + + "github.com/step-security/dev-machine-guard/internal/proctest" ) // helperEnv makes the test binary act as a lock holder: acquire, print @@ -16,7 +17,12 @@ import ( const helperEnv = "DMG_LOCK_TEST_HOLDER" func TestMain(m *testing.M) { + if target := os.Getenv(middleEnv); target != "" { + proctest.Guard() + runMiddle(target) + } if target := os.Getenv(helperEnv); target != "" { + proctest.Guard() setLockTarget(target) lk, err := Acquire(nil) if err != nil { @@ -28,7 +34,7 @@ func TestMain(m *testing.M) { time.Sleep(time.Hour) os.Exit(0) } - os.Exit(m.Run()) + os.Exit(proctest.Main(m)) } func TestAcquireHeldInProcess(t *testing.T) { @@ -55,8 +61,8 @@ func TestAcquireAfterHolderKilled(t *testing.T) { target := testLockTarget(t) setLockTarget(target) - cmd := exec.Command(os.Args[0], "-test.run=^$") - cmd.Env = append(os.Environ(), helperEnv+"="+target) + cmd := proctest.Command(t, os.Args[0], "-test.run=^$") + cmd.Env = append(cmd.Env, helperEnv+"="+target) out, err := cmd.StdoutPipe() if err != nil { t.Fatal(err) diff --git a/internal/proctest/children_darwin.go b/internal/proctest/children_darwin.go new file mode 100644 index 00000000..825629a5 --- /dev/null +++ b/internal/proctest/children_darwin.go @@ -0,0 +1,24 @@ +package proctest + +import ( + "os" + + "golang.org/x/sys/unix" +) + +const sZomb = 5 // SZOMB, sys/proc.h + +func children() ([]int, error) { + procs, err := unix.SysctlKinfoProcSlice("kern.proc.all") + if err != nil { + return nil, err + } + self := int32(os.Getpid()) // #nosec G115 -- pid + var out []int + for _, p := range procs { + if p.Eproc.Ppid == self && p.Proc.P_stat != sZomb { + out = append(out, int(p.Proc.P_pid)) + } + } + return out, nil +} diff --git a/internal/proctest/children_linux.go b/internal/proctest/children_linux.go new file mode 100644 index 00000000..f9278b49 --- /dev/null +++ b/internal/proctest/children_linux.go @@ -0,0 +1,35 @@ +package proctest + +import ( + "os" + "strconv" + "strings" +) + +func children() ([]int, error) { + entries, err := os.ReadDir("/proc") + if err != nil { + return nil, err + } + self := os.Getpid() + var out []int + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + b, err := os.ReadFile("/proc/" + e.Name() + "/stat") + if err != nil { + continue + } + // pid (comm) state ppid ...; comm may contain spaces and parens. + f := strings.Fields(string(b[strings.LastIndexByte(string(b), ')')+1:])) + if len(f) < 2 || f[0] == "Z" { + continue + } + if ppid, _ := strconv.Atoi(f[1]); ppid == self { + out = append(out, pid) + } + } + return out, nil +} diff --git a/internal/proctest/children_other.go b/internal/proctest/children_other.go new file mode 100644 index 00000000..8154aa35 --- /dev/null +++ b/internal/proctest/children_other.go @@ -0,0 +1,5 @@ +//go:build !darwin && !linux && !windows + +package proctest + +func children() ([]int, error) { return nil, nil } diff --git a/internal/proctest/proctest.go b/internal/proctest/proctest.go new file mode 100644 index 00000000..b99533b3 --- /dev/null +++ b/internal/proctest/proctest.go @@ -0,0 +1,139 @@ +// Package proctest keeps the processes tests start from outliving them. +// +// - Command (test side) ties a child to the test: it is killed, with its +// process group, when the test ends or its deadline passes, and it gets +// that deadline in EnvDeadline. +// - Guard (child side) is a helper's first call: the helper exits at the +// deadline, or as soon as the process that started it is gone, even when +// the test binary was killed and no cleanup ran. +// - Main (package side) kills any child still running shortly before +// -test.timeout and after the tests. +// +// -test.timeout alone is not enough: helpers dispatched from TestMain never +// arm it, -test.run helpers are started without it, and a child stuck +// between fork and exec runs no Go code at all (seen with -race on macOS: +// the fork child spins in the race runtime with signals blocked). +package proctest + +import ( + "context" + "flag" + "fmt" + "os" + "os/exec" + "strconv" + "sync/atomic" + "syscall" + "testing" + "time" +) + +const ( + // EnvDeadline carries a helper's deadline (Unix nanoseconds). + EnvDeadline = "DMG_PROCTEST_DEADLINE" + // DefaultTimeout bounds a helper started without a deadline. + DefaultTimeout = 10 * time.Minute + // ExitGuard is a helper's exit code when Guard ends it. + ExitGuard = 86 +) + +// Command is exec.Command bound to t. Add to cmd.Env rather than +// replacing it, so the helper keeps its deadline. +func Command(t testing.TB, name string, args ...string) *exec.Cmd { + t.Helper() + deadline := time.Now().Add(DefaultTimeout) + if d, ok := t.(interface{ Deadline() (time.Time, bool) }); ok { + if dl, ok := d.Deadline(); ok && dl.Before(deadline) { + deadline = dl + } + } + ctx, cancel := context.WithDeadline(t.Context(), deadline) + t.Cleanup(cancel) + cmd := exec.CommandContext(ctx, name, args...) // #nosec G204 -- test subprocess + cmd.Env = append(os.Environ(), EnvDeadline+"="+strconv.FormatInt(deadline.UnixNano(), 10)) + killTreeOnCancel(cmd) + cmd.WaitDelay = 5 * time.Second + return cmd +} + +// Guard ends this helper process at its deadline or when its parent exits. +func Guard() { guard(true) } + +// GuardDeadline is Guard without the parent watch, for helpers whose test +// checks that something else kills them once their parent is gone. +func GuardDeadline() { guard(false) } + +func guard(parent bool) { + deadline := time.Now().Add(DefaultTimeout) + if n, err := strconv.ParseInt(os.Getenv(EnvDeadline), 10, 64); err == nil { + deadline = time.Unix(0, n) + } + time.AfterFunc(time.Until(deadline), func() { exit("deadline passed") }) + if parent { + watchParent(func() { exit("parent exited") }) + } +} + +func exit(why string) { + fmt.Fprintf(os.Stderr, "proctest: helper %d exiting: %s\n", os.Getpid(), why) + os.Exit(ExitGuard) +} + +// Main runs the tests for TestMain, killing children still running shortly +// before -test.timeout (failing the run) and after the tests. +func Main(m *testing.M) int { + if !flag.Parsed() { + flag.Parse() + } + var timeout time.Duration + if f := flag.Lookup("test.timeout"); f != nil { + if g, ok := f.Value.(flag.Getter); ok { + timeout, _ = g.Get().(time.Duration) + } + } + var reaped atomic.Int64 + if timeout > 5*time.Second { + // Early enough for a test blocked on a stuck child to fail cleanly. + margin := min(timeout/10, 30*time.Second) + t1 := time.AfterFunc(timeout-margin, func() { reaped.Add(int64(reap("%s before -test.timeout", margin))) }) + defer t1.Stop() + // Just before the timeout panic: stop new forks, then reap, so no + // child is left to be orphaned by it. + t2 := time.AfterFunc(timeout-time.Second, func() { + syscall.ForkLock.Lock() // never released: the run is ending + reaped.Add(int64(reap("1s before -test.timeout"))) + }) + defer t2.Stop() + } + code := m.Run() + reap("after the tests") + if reaped.Load() > 0 && code == 0 { + code = 1 + } + return code +} + +func reap(when string, a ...any) int { + n := KillChildren() + if n > 0 { + fmt.Fprintf(os.Stderr, "proctest: killed %d child process(es) still running "+when+"\n", append([]any{n}, a...)...) + } + return n +} + +// KillChildren kills every live child of this process (and, on Unix, its +// process group) and returns how many it found. +func KillChildren() int { + pids, err := children() + if err != nil { + fmt.Fprintf(os.Stderr, "proctest: listing child processes: %v\n", err) + return 0 + } + for _, pid := range pids { + killTree(pid) + } + return len(pids) +} + +// KillPid kills pid and, on Unix, the process group it leads. +func KillPid(pid int) { killTree(pid) } diff --git a/internal/proctest/proctest_test.go b/internal/proctest/proctest_test.go new file mode 100644 index 00000000..86951a1c --- /dev/null +++ b/internal/proctest/proctest_test.go @@ -0,0 +1,119 @@ +package proctest + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "testing" + "time" +) + +const envRole = "DMG_PROCTEST_ROLE" + +func TestMain(m *testing.M) { + switch os.Getenv(envRole) { + case "sleep": // guarded + Guard() + time.Sleep(time.Hour) + case "sleep-raw": // unguarded + time.Sleep(time.Hour) + case "spawn", "spawn-raw": // start a sleeper, print its pid, sleep + role := "sleep" + if os.Getenv(envRole) == "spawn-raw" { + role = "sleep-raw" + } else { + Guard() + } + c := exec.Command(os.Args[0]) // #nosec G204 -- the test binary + c.Env = append(os.Environ(), envRole+"="+role) + if c.Start() != nil { + os.Exit(1) + } + fmt.Println(c.Process.Pid) + time.Sleep(time.Hour) + } + os.Exit(Main(m)) +} + +func start(t *testing.T, role string) (*exec.Cmd, int) { + t.Helper() + cmd := Command(t, os.Args[0]) + cmd.Env = append(cmd.Env, envRole+"="+role) + out, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + line, _ := bufio.NewReader(out).ReadString('\n') + pid, err := strconv.Atoi(strings.TrimSpace(line)) + if err != nil { + t.Fatalf("%s said %q", role, line) + } + return cmd, pid +} + +func TestGuardExitsAtDeadline(t *testing.T) { + cmd := Command(t, os.Args[0]) + cmd.Env = append(cmd.Env, envRole+"=sleep", EnvDeadline+"="+strconv.FormatInt(time.Now().Add(time.Second).UnixNano(), 10)) + t0 := time.Now() + _ = cmd.Run() + if code := cmd.ProcessState.ExitCode(); code != ExitGuard || time.Since(t0) > 10*time.Second { + t.Fatalf("exit %d after %s", code, time.Since(t0)) + } +} + +func TestGuardExitsWhenParentDies(t *testing.T) { + cmd, pid := start(t, "spawn") + _ = cmd.Process.Kill() + _ = cmd.Wait() + if !WaitGone(pid, 5*time.Second) { + KillPid(pid) + t.Fatalf("guarded helper %d outlived its parent", pid) + } +} + +// Unguarded grandchildren die with the test's process group. +func TestCommandKillsTreeWhenTestEnds(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no process groups; Windows helpers rely on Guard") + } + var mid, leaf int + t.Run("start", func(t *testing.T) { + var cmd *exec.Cmd + cmd, leaf = start(t, "spawn-raw") + mid = cmd.Process.Pid + t.Cleanup(func() { _ = cmd.Wait() }) + }) + for _, pid := range []int{mid, leaf} { + if !WaitGone(pid, 5*time.Second) { + KillPid(pid) + t.Fatalf("%d outlived its test", pid) + } + } +} + +func TestKillChildren(t *testing.T) { + cmd := exec.Command(os.Args[0]) // #nosec G204 -- the test binary + cmd.Env = append(os.Environ(), envRole+"=sleep-raw") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + go func() { _ = cmd.Wait(); close(done) }() + if n := KillChildren(); n < 1 { + _ = cmd.Process.Kill() + t.Fatalf("KillChildren found %d", n) + } + select { + case <-done: + case <-time.After(5 * time.Second): + _ = cmd.Process.Kill() + t.Fatal("child survived KillChildren") + } +} diff --git a/internal/proctest/proctest_unix.go b/internal/proctest/proctest_unix.go new file mode 100644 index 00000000..46625f69 --- /dev/null +++ b/internal/proctest/proctest_unix.go @@ -0,0 +1,66 @@ +//go:build !windows + +package proctest + +import ( + "errors" + "os" + "os/exec" + "syscall" + "time" +) + +// killTreeOnCancel starts cmd in its own process group and kills the group +// on cancel, so grandchildren go too. +func killTreeOnCancel(cmd *exec.Cmd) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Setpgid = true + cmd.Cancel = func() error { + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); errors.Is(err, syscall.ESRCH) { + return os.ErrProcessDone + } else if err != nil { + return err + } + return nil + } +} + +// killTree kills pid and the process group it leads, if any. A group id +// equal to pid can only be pid's own group while pid is alive. +func killTree(pid int) { + _ = syscall.Kill(-pid, syscall.SIGKILL) + _ = syscall.Kill(pid, syscall.SIGKILL) +} + +// watchParent calls onExit once this process is reparented: its parent +// exited. +func watchParent(onExit func()) { + ppid := os.Getppid() + if ppid == 1 { + onExit() + return + } + go func() { + for { + time.Sleep(200 * time.Millisecond) + if os.Getppid() != ppid { + onExit() + return + } + } + }() +} + +// WaitGone reports whether pid (not a child of this process) exits within d. +func WaitGone(pid int, d time.Duration) bool { + for end := time.Now().Add(d); ; time.Sleep(50 * time.Millisecond) { + if errors.Is(syscall.Kill(pid, 0), syscall.ESRCH) { + return true + } + if time.Now().After(end) { + return false + } + } +} diff --git a/internal/proctest/proctest_windows.go b/internal/proctest/proctest_windows.go new file mode 100644 index 00000000..b7ee881c --- /dev/null +++ b/internal/proctest/proctest_windows.go @@ -0,0 +1,74 @@ +//go:build windows + +package proctest + +import ( + "errors" + "os" + "os/exec" + "strings" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +// killTreeOnCancel keeps exec's default cancel (kill the child); helpers +// it started exit through Guard's parent watch. +func killTreeOnCancel(*exec.Cmd) {} + +func killTree(pid int) { + h, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, uint32(pid)) // #nosec G115 -- pid from a process snapshot + if err != nil { + return + } + _ = windows.TerminateProcess(h, 1) + _ = windows.CloseHandle(h) +} + +// watchParent calls onExit once the parent process exits. A parent that +// can't be opened for another reason (access) is not watched. +func watchParent(onExit func()) { + h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(os.Getppid())) // #nosec G115 -- parent pid + if err != nil { + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + onExit() // already gone + } + return + } + go func() { + if ev, _ := windows.WaitForSingleObject(h, windows.INFINITE); ev == windows.WAIT_OBJECT_0 { + onExit() + } + }() +} + +// WaitGone reports whether pid exits within d. +func WaitGone(pid int, d time.Duration) bool { + h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid)) // #nosec G115 -- test pid + if err != nil { + return true + } + defer func() { _ = windows.CloseHandle(h) }() + ev, _ := windows.WaitForSingleObject(h, uint32(d.Milliseconds())) // #nosec G115 -- short test wait + return ev == windows.WAIT_OBJECT_0 +} + +func children() ([]int, error) { + snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return nil, err + } + defer func() { _ = windows.CloseHandle(snap) }() + self := uint32(os.Getpid()) // #nosec G115 -- pid + var pe windows.ProcessEntry32 + pe.Size = uint32(unsafe.Sizeof(pe)) + var out []int + for err = windows.Process32First(snap, &pe); err == nil; err = windows.Process32Next(snap, &pe) { + // A console app can own its conhost.exe; that is not a test's child. + if pe.ParentProcessID == self && pe.ProcessID != self && !strings.EqualFold(windows.UTF16ToString(pe.ExeFile[:]), "conhost.exe") { + out = append(out, int(pe.ProcessID)) + } + } + return out, nil +} diff --git a/internal/progress/filelog/broken_stderr_test.go b/internal/progress/filelog/broken_stderr_test.go index f76d5395..afffdfbe 100644 --- a/internal/progress/filelog/broken_stderr_test.go +++ b/internal/progress/filelog/broken_stderr_test.go @@ -3,11 +3,12 @@ package filelog import ( "fmt" "os" - "os/exec" "path/filepath" "runtime" "strings" "testing" + + "github.com/step-security/dev-machine-guard/internal/proctest" ) // A broken stderr (EPIPE) must not stop the file copy. @@ -44,6 +45,7 @@ func TestTee_BrokenFD2DoesNotKillProcess(t *testing.T) { t.Skip("no SIGPIPE on windows") } if path := os.Getenv("FILELOG_SIGPIPE_CHILD"); path != "" { + proctest.Guard() c, err := Start(path, 0) if err != nil { os.Exit(3) @@ -60,8 +62,8 @@ func TestTee_BrokenFD2DoesNotKillProcess(t *testing.T) { } _ = r.Close() path := filepath.Join(t.TempDir(), "err.log") - cmd := exec.Command(os.Args[0], "-test.run=^TestTee_BrokenFD2DoesNotKillProcess$") - cmd.Env = append(os.Environ(), "FILELOG_SIGPIPE_CHILD="+path) + cmd := proctest.Command(t, os.Args[0], "-test.run=^TestTee_BrokenFD2DoesNotKillProcess$") + cmd.Env = append(cmd.Env, "FILELOG_SIGPIPE_CHILD="+path) cmd.Stderr = w err = cmd.Run() _ = w.Close() diff --git a/internal/progress/filelog/main_test.go b/internal/progress/filelog/main_test.go new file mode 100644 index 00000000..7a2dffd4 --- /dev/null +++ b/internal/progress/filelog/main_test.go @@ -0,0 +1,10 @@ +package filelog + +import ( + "os" + "testing" + + "github.com/step-security/dev-machine-guard/internal/proctest" +) + +func TestMain(m *testing.M) { os.Exit(proctest.Main(m)) } diff --git a/internal/schtasks/main_test.go b/internal/schtasks/main_test.go new file mode 100644 index 00000000..178d5d77 --- /dev/null +++ b/internal/schtasks/main_test.go @@ -0,0 +1,10 @@ +package schtasks + +import ( + "os" + "testing" + + "github.com/step-security/dev-machine-guard/internal/proctest" +) + +func TestMain(m *testing.M) { os.Exit(proctest.Main(m)) } diff --git a/internal/schtasks/schtasks.go b/internal/schtasks/schtasks.go index d4152cda..782f71f3 100644 --- a/internal/schtasks/schtasks.go +++ b/internal/schtasks/schtasks.go @@ -14,6 +14,7 @@ import ( "github.com/step-security/dev-machine-guard/internal/config" "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/launcher" "github.com/step-security/dev-machine-guard/internal/paths" "github.com/step-security/dev-machine-guard/internal/progress" ) @@ -61,7 +62,7 @@ func Install(exec executor.Executor, log *progress.Logger, maxExec time.Duration log.Progress("Previous installation removed. Installing new version...") } - binaryPath, err := os.Executable() + binaryPath, err := executablePath() if err != nil { return fmt.Errorf("determining binary path: %w", err) } @@ -125,6 +126,15 @@ func Install(exec executor.Executor, log *progress.Logger, maxExec time.Duration applyBatterySettings(ctx, exec, log, logonTaskName, timeLimit) } + // Self-update (MSI bootstrap layout): the SYSTEM update task. Best-effort + // like the logon task — scans don't depend on it, and failing here would + // roll back the whole MSI. msi-smoke asserts it exists. + if root, ok := resolveBootstrapRoot(exec, binaryPath); ok && exec.IsRoot() { + if err := installUpdateTask(ctx, exec, log, filepath.Join(root, launcher.BootstrapBinary), time.Now()); err != nil { + log.Warn("could not create the self-update task %q: %v — scans are unaffected", updateTaskName, err) + } + } + // Turn on Task Scheduler's operational history so every fire / miss / // failure is recorded in Event Viewer — the primary way to debug why a // scheduled scan didn't run. Best-effort (needs admin). @@ -163,10 +173,18 @@ func RunNow(exec executor.Executor, log *progress.Logger) error { return nil } -// Uninstall removes the scheduled task. +// Uninstall removes the scheduled task. For an MSI bootstrap install it also +// removes the update task, stops running DMG processes and deletes the +// self-update state (see uninstallBootstrap). func Uninstall(exec executor.Executor, log *progress.Logger) error { ctx := context.Background() + if self, err := executablePath(); err == nil { + if root, ok := resolveBootstrapRoot(exec, self); ok { + return uninstallBootstrap(ctx, exec, log, root) + } + } + if !isConfigured(ctx, exec) { log.Progress("Agent is not currently configured for periodic execution") return nil @@ -209,10 +227,17 @@ func isConfigured(ctx context.Context, exec executor.Executor) bool { // (ad-hoc deploys of just the agent .exe), we fall back to the agent // directly — subprocess flashes are still suppressed via winproc, only // the agent's own console-flash mitigation is forfeited. +// +// An MSI bootstrap install (stepsecurity-dev-machine-guard-bootstrap.exe in +// the install root) wins: the action becomes `bootstrap.exe run +// send-telemetry …`, which resolves the self-update version at each run. func resolveTaskBinary(exec executor.Executor, agentPath string) string { - launcher := filepath.Join(filepath.Dir(agentPath), launcherName) - if exec.FileExists(launcher) { - return launcher + if root, ok := resolveBootstrapRoot(exec, agentPath); ok { + return filepath.Join(root, launcher.BootstrapBinary) + } + launcherPath := filepath.Join(filepath.Dir(agentPath), launcherName) + if exec.FileExists(launcherPath) { + return launcherPath } return agentPath } @@ -223,8 +248,15 @@ func resolveTaskBinary(exec executor.Executor, agentPath string) string { // wrapper) — env config and log routing both live in the binary now // (--install-dir + filelog), and the wrapper was the source of a visible // cmd.exe flash on every fire. +// +// When binaryPath is the bootstrap, the agent arguments go behind its `run` +// verb. func buildCreateArgs(name, binaryPath, stepHome string, schedule []string, isAdmin bool) []string { - taskCmd := fmt.Sprintf(`"%s" send-telemetry --install-dir="%s"`, binaryPath, stepHome) + verb := "" + if launcher.IsBootstrapBinary(binaryPath) { + verb = "run " + } + taskCmd := fmt.Sprintf(`"%s" %ssend-telemetry --install-dir="%s"`, binaryPath, verb, stepHome) args := []string{"/create", "/tn", name, "/tr", taskCmd} args = append(args, schedule...) args = append(args, "/f") diff --git a/internal/schtasks/selfupdate.go b/internal/schtasks/selfupdate.go new file mode 100644 index 00000000..16faf1a8 --- /dev/null +++ b/internal/schtasks/selfupdate.go @@ -0,0 +1,248 @@ +package schtasks + +import ( + "context" + "encoding/xml" + "errors" + "fmt" + "html" + "os" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/progress" + "github.com/step-security/dev-machine-guard/internal/updatestate" +) + +// updateTaskName is the SYSTEM self-update task of MSI bootstrap installs. +// It has its own name so the loader's same-name task handling (M-2) never +// touches it. +const updateTaskName = taskName + " (Update)" + +// UpdateTaskName is exported for diagnostics (schedinfo-style queries). +const UpdateTaskName = updateTaskName + +// Update task settings (spec §2). +const ( + updateInterval = "PT1H" + updateRandomDelay = "PT10M" + updateBootDelay = "PT15M" + updateExecutionLimit = "PT30M" +) + +// stopProcessesWait bounds how long uninstall waits for a running scan to +// release the instance mutex before terminating DMG processes. +const stopProcessesWait = 60 * time.Second + +// executablePath is swapped by tests. +var executablePath = os.Executable + +// resolveBootstrapRoot returns the install root of an MSI bootstrap install +// that agentPath belongs to: the agent's own directory (legacy top-level +// layout) or, for an agent under seed\\ or versions\\, two levels +// up. ok is false when no bootstrap sits in that root. +func resolveBootstrapRoot(exec executor.Executor, agentPath string) (string, bool) { + return updatestate.BootstrapRoot(agentPath, exec.FileExists) +} + +// updateTaskXML renders the full task definition. Only a full XML import can +// express the SYSTEM principal with two triggers, the random and boot delays +// and IgnoreNew in one registration. +func updateTaskXML(bootstrapPath string, start time.Time) string { + var cmd strings.Builder + _ = xml.EscapeText(&cmd, []byte(bootstrapPath)) + return ` + + + StepSecurity + StepSecurity Dev Machine Guard self-update (SYSTEM). + \` + updateTaskName + ` + + + + + ` + updateInterval + ` + false + + ` + start.Format("2006-01-02T15:04:05") + ` + true + ` + updateRandomDelay + ` + + + true + ` + updateBootDelay + ` + + + + + S-1-5-18 + HighestAvailable + + + + IgnoreNew + false + false + true + true + false + + false + false + + true + true + false + false + false + ` + updateExecutionLimit + ` + 7 + + + + ` + cmd.String() + ` + update + + + +` +} + +// updateTaskMismatches lists the required update-task settings xml lacks. +func updateTaskMismatches(taskXML, bootstrapPath string) []string { + var bad []string + for _, kv := range [][2]string{ + {"UserId", "S-1-5-18"}, + {"MultipleInstancesPolicy", "IgnoreNew"}, + {"DisallowStartIfOnBatteries", "false"}, + {"StopIfGoingOnBatteries", "false"}, + {"StartWhenAvailable", "true"}, + {"ExecutionTimeLimit", updateExecutionLimit}, + {"Arguments", "update"}, + } { + if got, ok := xmlElementValue(taskXML, kv[0]); !ok || got != kv[1] { + // Task Scheduler may render the SYSTEM principal by name. + if kv[0] == "UserId" && strings.EqualFold(got, "SYSTEM") { + continue + } + bad = append(bad, fmt.Sprintf("%s=%q (want %q)", kv[0], got, kv[1])) + } + } + if got, _ := xmlElementValue(taskXML, "Command"); !strings.EqualFold(strings.Trim(html.UnescapeString(got), `"`), bootstrapPath) { + bad = append(bad, fmt.Sprintf("Command=%q (want %q)", got, bootstrapPath)) + } + return bad +} + +// installUpdateTask (re)creates the SYSTEM update task from full XML and +// verifies the registration. +func installUpdateTask(ctx context.Context, exec executor.Executor, log *progress.Logger, bootstrapPath string, now time.Time) error { + if err := importTaskXML(ctx, exec, updateTaskName, updateTaskXML(bootstrapPath, now)); err != nil { + return err + } + out, stderr, code, err := exec.Run(ctx, "schtasks", "/query", "/tn", updateTaskName, "/xml") + if err != nil || code != 0 { + return fmt.Errorf("verifying task (exit %d, %v): %s", code, err, strings.TrimSpace(stderr)) + } + if bad := updateTaskMismatches(decodeTaskXML(out), bootstrapPath); len(bad) > 0 { + return fmt.Errorf("task registered with wrong settings: %s", strings.Join(bad, ", ")) + } + log.Progress(" Task: %s", updateTaskName) + return nil +} + +// importTaskXML registers name from a full task XML (UTF-16LE + BOM temp +// file, `schtasks /create /xml … /f`). +func importTaskXML(ctx context.Context, exec executor.Executor, name, taskXML string) error { + tmp, err := os.CreateTemp("", "stepsec-task-*.xml") + if err != nil { + return fmt.Errorf("creating temp task XML: %w", err) + } + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() + _, werr := tmp.Write(encodeTaskXMLUTF16(taskXML)) + if cerr := tmp.Close(); werr == nil { + werr = cerr + } + if werr != nil { + return fmt.Errorf("writing temp task XML: %w", werr) + } + _, stderr, code, err := exec.Run(ctx, "schtasks", "/create", "/tn", name, "/xml", tmpPath, "/f") + if err != nil { + return fmt.Errorf("schtasks /create /xml: %w", err) + } + if code != 0 { + return fmt.Errorf("schtasks /create /xml (exit code %d): %s", code, strings.TrimSpace(stderr)) + } + return nil +} + +// deleteTaskIfPresent deletes name when it is registered. A missing task is +// not an error; a failed delete of a present task is. +func deleteTaskIfPresent(ctx context.Context, exec executor.Executor, log *progress.Logger, name string) error { + if _, _, code, _ := exec.Run(ctx, "schtasks", "/query", "/tn", name); code != 0 { + log.Debug("task %q not registered; nothing to delete", name) + return nil + } + _, stderr, code, err := exec.Run(ctx, "schtasks", "/delete", "/tn", name, "/f") + if err != nil { + return fmt.Errorf("deleting task %q: %w", name, err) + } + if code != 0 { + return fmt.Errorf("deleting task %q (exit code %d): %s", name, code, strings.TrimSpace(stderr)) + } + log.Progress("Removed scheduled task: %s", name) + return nil +} + +// Platform hooks, swapped by tests: stop DMG processes under root (waiting +// for the instance mutex first) and delete the RunningVersion value. +var ( + stopDMGProcesses = stopDMGProcessesPlatform + deleteRunningVerReg = deleteRunningVersionPlatform +) + +// removeAllRetry retries RemoveAll briefly: an image whose process was just +// terminated (or an AV scanner's handle) can keep a file locked for a moment. +func removeAllRetry(dir string) error { + var err error + for i := 0; i < 5; i++ { + if err = os.RemoveAll(dir); err == nil { + return nil + } + time.Sleep(time.Second) + } + return err +} + +// uninstallBootstrap is the MSI bootstrap uninstall (spec §5): delete all 3 +// tasks, stop running DMG processes, then remove versions\, state\ and the +// RunningVersion value. ProgramData is left alone (M-8 is out of scope). +// Every step runs even if an earlier one failed; the errors are joined. +func uninstallBootstrap(ctx context.Context, exec executor.Executor, log *progress.Logger, root string) error { + var errs []error + for _, name := range []string{taskName, logonTaskName, updateTaskName} { + if err := deleteTaskIfPresent(ctx, exec, log, name); err != nil { + errs = append(errs, err) + } + } + if err := stopDMGProcesses(root, stopProcessesWait, log); err != nil { + log.Warn("stopping running DMG processes: %v", err) + } + layout := updatestate.Layout{InstallRoot: root} + for _, dir := range []string{layout.VersionsDir(), layout.StateDir()} { + if err := removeAllRetry(dir); err != nil { + errs = append(errs, fmt.Errorf("removing %s: %w", dir, err)) + } else { + log.Debug("removed %s", dir) + } + } + if err := deleteRunningVerReg(); err != nil { + errs = append(errs, fmt.Errorf("deleting RunningVersion: %w", err)) + } + if len(errs) == 0 { + log.Progress("Windows Task Scheduler configuration and self-update state removed successfully") + } + return errors.Join(errs...) +} diff --git a/internal/schtasks/selfupdate_other.go b/internal/schtasks/selfupdate_other.go new file mode 100644 index 00000000..468398af --- /dev/null +++ b/internal/schtasks/selfupdate_other.go @@ -0,0 +1,13 @@ +//go:build !windows + +package schtasks + +import ( + "time" + + "github.com/step-security/dev-machine-guard/internal/progress" +) + +func stopDMGProcessesPlatform(string, time.Duration, *progress.Logger) error { return nil } + +func deleteRunningVersionPlatform() error { return nil } diff --git a/internal/schtasks/selfupdate_test.go b/internal/schtasks/selfupdate_test.go new file mode 100644 index 00000000..82e5834f --- /dev/null +++ b/internal/schtasks/selfupdate_test.go @@ -0,0 +1,291 @@ +package schtasks + +import ( + "context" + "encoding/xml" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/launcher" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +const testBootstrap = `C:\Program Files\StepSecurity\stepsecurity-dev-machine-guard-bootstrap.exe` + +func TestUpdateTaskXML_WellFormedAndComplete(t *testing.T) { + start := time.Date(2026, 10, 19, 13, 4, 5, 0, time.Local) + doc := updateTaskXML(`C:\Program Files\A&B\`+launcher.BootstrapBinary, start) + dec := xml.NewDecoder(strings.NewReader(strings.Replace(doc, `encoding="UTF-16"`, `encoding="UTF-8"`, 1))) + for { + _, err := dec.Token() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("not well-formed: %v\n%s", err, doc) + } + } + for _, want := range []string{ + `S-1-5-18`, + `HighestAvailable`, + `PT1H`, + `PT10M`, + ``, + `PT15M`, + `2026-10-19T13:04:05`, + `IgnoreNew`, + `false`, + `false`, + `true`, + `PT30M`, + `C:\Program Files\A&B\stepsecurity-dev-machine-guard-bootstrap.exe`, + `update`, + `\StepSecurity Dev Machine Guard (Update)`, + } { + if !strings.Contains(doc, want) { + t.Errorf("missing %s", want) + } + } + // The XML must round-trip through the UTF-16 import encoding. + if got := decodeTaskXML(string(encodeTaskXMLUTF16(doc))); got != doc { + t.Fatal("UTF-16 round trip changed the XML") + } + if bad := updateTaskMismatches(doc, `C:\Program Files\A&B\`+launcher.BootstrapBinary); len(bad) != 0 { + t.Fatalf("own XML mismatches: %v", bad) + } +} + +func TestUpdateTaskMismatches(t *testing.T) { + doc := updateTaskXML(testBootstrap, time.Now()) + if bad := updateTaskMismatches(strings.Replace(doc, "S-1-5-18", "SYSTEM", 1), testBootstrap); len(bad) != 0 { + t.Fatalf("SYSTEM by name rejected: %v", bad) + } + broken := strings.Replace(doc, "IgnoreNew", "Parallel", 1) + broken = strings.Replace(broken, "true", "false", 1) + if bad := updateTaskMismatches(broken, `C:\other.exe`); len(bad) != 3 { + t.Fatalf("mismatches = %v", bad) + } +} + +func TestResolveBootstrapRoot(t *testing.T) { + root := filepath.Join("/", "Program Files", "StepSecurity") + mock := executor.NewMock() + mock.SetFile(filepath.Join(root, launcher.BootstrapBinary), []byte{}) + for _, agent := range []string{ + filepath.Join(root, "seed", "1.2.3", "stepsecurity-dev-machine-guard.exe"), + filepath.Join(root, "versions", "1.2.4", "stepsecurity-dev-machine-guard.exe"), + filepath.Join(root, "stepsecurity-dev-machine-guard.exe"), + } { + if got, ok := resolveBootstrapRoot(mock, agent); !ok || got != root { + t.Errorf("%s: root=%q ok=%v", agent, got, ok) + } + } + for _, agent := range []string{ + filepath.Join(root, "other", "1.2.3", "stepsecurity-dev-machine-guard.exe"), + filepath.Join("/", "elsewhere", "stepsecurity-dev-machine-guard.exe"), + } { + if _, ok := resolveBootstrapRoot(mock, agent); ok { + t.Errorf("%s: unexpected bootstrap root", agent) + } + } + if got := resolveTaskBinary(mock, filepath.Join(root, "seed", "1.2.3", "stepsecurity-dev-machine-guard.exe")); got != filepath.Join(root, launcher.BootstrapBinary) { + t.Errorf("task binary = %q", got) + } +} + +func TestBuildCreateArgs_BootstrapRunVerb(t *testing.T) { + args := buildCreateArgs(taskName, testBootstrap, `C:\ProgramData\StepSecurity`, hourlySchedule(4), true) + want := `"` + testBootstrap + `" run send-telemetry --install-dir="C:\ProgramData\StepSecurity"` + if got := trArg(t, args); got != want { + t.Fatalf("/tr = %q, want %q", got, want) + } + if len(want) > 261 { + t.Fatalf("/tr is %d chars; schtasks caps it at 261", len(want)) + } + logon := buildCreateArgs(logonTaskName, testBootstrap, `C:\ProgramData\StepSecurity`, []string{"/sc", "ONLOGON"}, true) + if got := trArg(t, logon); got != want { + t.Fatalf("logon /tr = %q", got) + } + legacy := buildCreateArgs(taskName, `C:\x\stepsecurity-dev-machine-guard-task.exe`, `C:\d`, hourlySchedule(4), true) + if got := trArg(t, legacy); strings.Contains(got, " run ") { + t.Fatalf("legacy action got the run verb: %q", got) + } +} + +// updateTaskExec answers schtasks calls from a table and records them. +type updateTaskExec struct { + *executor.Mock + calls []string + present map[string]bool + queryXML string + failOn string + files map[string][]byte // captured /xml imports +} + +func (e *updateTaskExec) Run(_ context.Context, name string, args ...string) (string, string, int, error) { + line := strings.Join(append([]string{name}, args...), " ") + e.calls = append(e.calls, line) + if e.failOn != "" && strings.Contains(line, e.failOn) { + return "", "boom", 1, nil + } + if name != "schtasks" || len(args) < 3 { + return "", "", 0, nil + } + task := args[2] + switch args[0] { + case "/query": + if !e.present[task] { + return "", "ERROR: not found", 1, nil + } + if len(args) > 3 && args[3] == "/xml" { + return e.queryXML, "", 0, nil + } + case "/delete": + delete(e.present, task) + case "/create": + if len(args) > 4 && args[3] == "/xml" { + data, _ := os.ReadFile(args[4]) + e.files[task] = data + } + e.present[task] = true + } + return "", "", 0, nil +} + +func newUpdateTaskExec() *updateTaskExec { + return &updateTaskExec{Mock: executor.NewMock(), present: map[string]bool{}, files: map[string][]byte{}} +} + +func TestInstallUpdateTask_ImportsAndVerifies(t *testing.T) { + e := newUpdateTaskExec() + now := time.Date(2026, 10, 19, 1, 2, 3, 0, time.Local) + e.queryXML = string(encodeTaskXMLUTF16(updateTaskXML(testBootstrap, now))) + log := progress.NewLogger(progress.LevelInfo) + if err := installUpdateTask(context.Background(), e, log, testBootstrap, now); err != nil { + t.Fatal(err) + } + got := decodeTaskXML(string(e.files[updateTaskName])) + if got != updateTaskXML(testBootstrap, now) { + t.Fatalf("imported XML differs:\n%s", got) + } + if !strings.HasPrefix(string(e.files[updateTaskName]), "\xff\xfe") { + t.Fatal("import file is not UTF-16LE with BOM") + } + // Idempotent: a second install overwrites (/f) and verifies again. + if err := installUpdateTask(context.Background(), e, log, testBootstrap, now); err != nil { + t.Fatal(err) + } + for _, c := range e.calls { + if strings.HasPrefix(c, "schtasks /create") && !strings.HasSuffix(c, "/f") { + t.Fatalf("create without /f: %s", c) + } + } +} + +func TestInstallUpdateTask_VerifyFailure(t *testing.T) { + e := newUpdateTaskExec() + e.queryXML = strings.Replace(updateTaskXML(testBootstrap, time.Now()), "IgnoreNew", "Queue", 1) + if err := installUpdateTask(context.Background(), e, newTestLogger(), testBootstrap, time.Now()); err == nil || !strings.Contains(err.Error(), "MultipleInstancesPolicy") { + t.Fatalf("err = %v", err) + } + e = newUpdateTaskExec() + e.failOn = "/create" + if err := installUpdateTask(context.Background(), e, newTestLogger(), testBootstrap, time.Now()); err == nil { + t.Fatal("expected create failure") + } +} + +func stubPlatformHooks(t *testing.T) (stopped *string, regDeleted *bool) { + t.Helper() + var s string + var r bool + oldStop, oldReg, oldExe := stopDMGProcesses, deleteRunningVerReg, executablePath + stopDMGProcesses = func(root string, wait time.Duration, _ *progress.Logger) error { + if wait != stopProcessesWait { + t.Errorf("wait = %s", wait) + } + s = root + return nil + } + deleteRunningVerReg = func() error { r = true; return nil } + t.Cleanup(func() { stopDMGProcesses, deleteRunningVerReg, executablePath = oldStop, oldReg, oldExe }) + return &s, &r +} + +func TestUninstall_BootstrapRemovesEverything(t *testing.T) { + root := t.TempDir() + for _, d := range []string{"versions/1.2.3", "state", "seed/1.2.3"} { + if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(root, "state", "state.json"), []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + stopped, reg := stubPlatformHooks(t) + executablePath = func() (string, error) { + return filepath.Join(root, "seed", "1.2.3", "stepsecurity-dev-machine-guard.exe"), nil + } + + e := newUpdateTaskExec() + e.SetFile(filepath.Join(root, launcher.BootstrapBinary), []byte{}) + e.present[taskName] = true + e.present[updateTaskName] = true // logon task already gone: not an error + if err := Uninstall(e, newTestLogger()); err != nil { + t.Fatal(err) + } + if len(e.present) != 0 { + t.Fatalf("tasks left: %v", e.present) + } + if *stopped != root || !*reg { + t.Fatalf("stopped=%q reg=%v", *stopped, *reg) + } + for _, d := range []string{"versions", "state"} { + if _, err := os.Stat(filepath.Join(root, d)); !os.IsNotExist(err) { + t.Fatalf("%s not removed", d) + } + } + if _, err := os.Stat(filepath.Join(root, "seed")); err != nil { + t.Fatal("seed is MSI-owned and must be left to the MSI") + } +} + +func TestUninstall_BootstrapContinuesPastFailures(t *testing.T) { + root := t.TempDir() + stopDone, _ := stubPlatformHooks(t) + executablePath = func() (string, error) { + return filepath.Join(root, "seed", "1.2.3", "stepsecurity-dev-machine-guard.exe"), nil + } + e := newUpdateTaskExec() + e.SetFile(filepath.Join(root, launcher.BootstrapBinary), []byte{}) + e.present[taskName] = true + e.present[updateTaskName] = true + e.failOn = "/delete /tn " + taskName + " /f" + err := Uninstall(e, newTestLogger()) + if err == nil { + t.Fatal("expected the failed delete to be reported") + } + if e.present[updateTaskName] || *stopDone != root { + t.Fatal("later steps skipped after a failure") + } +} + +func TestUninstall_LegacyLeavesUpdateTask(t *testing.T) { + stubPlatformHooks(t) + executablePath = func() (string, error) { return filepath.Join(t.TempDir(), "stepsecurity-dev-machine-guard.exe"), nil } + e := newUpdateTaskExec() + e.present[taskName] = true + e.present[updateTaskName] = true + if err := Uninstall(e, newTestLogger()); err != nil { + t.Fatal(err) + } + if !e.present[updateTaskName] { + t.Fatal("a non-bootstrap uninstall deleted the MSI's update task (M-2)") + } +} diff --git a/internal/schtasks/selfupdate_windows.go b/internal/schtasks/selfupdate_windows.go new file mode 100644 index 00000000..d1b0a4bd --- /dev/null +++ b/internal/schtasks/selfupdate_windows.go @@ -0,0 +1,103 @@ +//go:build windows + +package schtasks + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/progress" + "github.com/step-security/dev-machine-guard/internal/updatestate" + "github.com/step-security/dev-machine-guard/internal/winproc" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +// instanceMutex is the scan singleton (internal/lock). +var instanceMutex = `Global\StepSecurityDevMachineGuard` + +// stopDMGProcessesPlatform waits up to wait for a running scan to release the +// instance mutex, then terminates every process (other than this one) whose +// image lives under root: launchers, agents and bootstraps from versions\, +// seed\ or the root itself. Fixes the files-in-use reboot of M-18. +func stopDMGProcessesPlatform(root string, wait time.Duration, log *progress.Logger) error { + if waitForInstanceMutex(wait) { + log.Debug("instance mutex free; no scan running") + } else { + log.Warn("a scan still holds the instance mutex after %s; terminating DMG processes", wait) + } + return terminateUnder(root, log) +} + +// waitForInstanceMutex reports whether the mutex is absent or was acquired +// (and released) within wait. Ownership is per thread, so the wait and the +// release run on one locked OS thread. +func waitForInstanceMutex(wait time.Duration) bool { + res := make(chan bool, 1) + go func() { + runtime.LockOSThread() // never unlocked: the thread exits with the goroutine + name, _ := windows.UTF16PtrFromString(instanceMutex) + h, err := windows.OpenMutex(windows.SYNCHRONIZE|windows.MUTEX_MODIFY_STATE, false, name) + if err != nil { + res <- errors.Is(err, windows.ERROR_FILE_NOT_FOUND) + return + } + defer func() { _ = windows.CloseHandle(h) }() + ev, _ := windows.WaitForSingleObject(h, uint32(wait/time.Millisecond)) // #nosec G115 -- bounded constant + if ev == windows.WAIT_OBJECT_0 || ev == windows.WAIT_ABANDONED { + _ = windows.ReleaseMutex(h) + res <- true + return + } + res <- false + }() + return <-res +} + +// terminateUnder stops every DMG process under root, then waits (bounded) +// until no process holds an MSI-owned file, since a terminated process can +// keep its image held for a while (D29). This process's own image (the +// uninstall action runs the seed agent) can't be waited for and is skipped. +func terminateUnder(root string, log *progress.Logger) error { + stopped, err := winproc.StopUnder(root) + for _, img := range stopped { + log.Progress("Stopped running DMG process %s", img) + } + self, _ := os.Executable() + var files []string + for _, f := range (updatestate.Layout{InstallRoot: root}).MSIOwnedFiles() { + if !strings.EqualFold(filepath.Clean(f), filepath.Clean(self)) { + files = append(files, f) + } + } + if held := winproc.WaitReleased(files, releaseWait, 50*time.Millisecond); len(held) > 0 { + log.Warn("still held after %s: %s", releaseWait, strings.Join(held, ", ")) + } + return err +} + +// releaseWait bounds the wait for MSI-owned files to be released. +const releaseWait = 30 * time.Second + +func stopProcess(h windows.Handle) error { return winproc.StopProcess(h) } + +// deleteRunningVersionPlatform removes HKLM\Software\StepSecurity\RunningVersion +// (updater-owned). AgentVersion is MSI-owned and left to the MSI. +func deleteRunningVersionPlatform() error { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, `Software\StepSecurity`, registry.SET_VALUE|registry.WOW64_64KEY) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + return nil + } + return err + } + defer k.Close() + if err := k.DeleteValue("RunningVersion"); err != nil && !errors.Is(err, registry.ErrNotExist) { + return err + } + return nil +} diff --git a/internal/schtasks/selfupdate_windows_test.go b/internal/schtasks/selfupdate_windows_test.go new file mode 100644 index 00000000..4e6d1f28 --- /dev/null +++ b/internal/schtasks/selfupdate_windows_test.go @@ -0,0 +1,165 @@ +//go:build windows + +package schtasks + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" + + "golang.org/x/sys/windows" + + "github.com/step-security/dev-machine-guard/internal/proctest" +) + +const envSleep = "DMG_TEST_SLEEP" + +// TestHelperSleep is a subprocess body that sleeps; skipped otherwise. +func TestHelperSleep(t *testing.T) { + if os.Getenv(envSleep) == "" { + t.Skip("subprocess helper") + } + proctest.Guard() + time.Sleep(time.Minute) +} + +func startSleeper(t *testing.T, exe string) *exec.Cmd { + t.Helper() + cmd := proctest.Command(t, exe, "-test.run=^TestHelperSleep$") + cmd.Env = append(cmd.Env, envSleep+"=1") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + return cmd +} + +func copyExe(t *testing.T, dst string) { + t.Helper() + in, err := os.Open(os.Args[0]) + if err != nil { + t.Fatal(err) + } + defer in.Close() + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + t.Fatal(err) + } + out, err := os.Create(dst) + if err != nil { + t.Fatal(err) + } + if _, err := io.Copy(out, in); err != nil { + t.Fatal(err) + } + _ = out.Close() +} + +// R-B-5: terminateUnder stops processes whose image is under the root and +// leaves everything else (including itself) running. +func TestTerminateUnder(t *testing.T) { + root := t.TempDir() + inside := filepath.Join(root, "versions", "1.2.3", "stepsecurity-dev-machine-guard.exe") + copyExe(t, inside) + in := startSleeper(t, inside) + out := startSleeper(t, os.Args[0]) + defer func() { _ = out.Process.Kill(); _, _ = out.Process.Wait() }() + time.Sleep(500 * time.Millisecond) + + if err := terminateUnder(root, newTestLogger()); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- in.Wait() }() + select { + case <-done: + case <-time.After(10 * time.Second): + _ = in.Process.Kill() + t.Fatal("process under the root still running") + } + h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(out.Process.Pid)) // #nosec G115 -- test pid + if err != nil { + t.Fatalf("process outside the root is gone: %v", err) + } + defer windows.CloseHandle(h) + if ev, _ := windows.WaitForSingleObject(h, 0); ev == windows.WAIT_OBJECT_0 { + t.Fatal("process outside the root was terminated") + } +} + +// holdMutex owns name on a locked thread until release is closed. +func holdMutex(t *testing.T, name string, release <-chan struct{}) { + t.Helper() + ready := make(chan error, 1) + go func() { + runtime.LockOSThread() + p, _ := windows.UTF16PtrFromString(name) + h, err := windows.CreateMutex(nil, true, p) + if err != nil { + ready <- err + return + } + ready <- nil + <-release + _ = windows.ReleaseMutex(h) + _ = windows.CloseHandle(h) + }() + if err := <-ready; err != nil { + t.Fatal(err) + } +} + +// R-B-5: uninstall waits for a running scan's instance mutex, bounded. +func TestWaitForInstanceMutex(t *testing.T) { + old := instanceMutex + t.Cleanup(func() { instanceMutex = old }) + instanceMutex = fmt.Sprintf(`Local\DMGUninstallTest-%d`, time.Now().UnixNano()) + + start := time.Now() + if !waitForInstanceMutex(time.Second) || time.Since(start) > 500*time.Millisecond { + t.Fatal("absent mutex should report free at once") + } + + release := make(chan struct{}) + holdMutex(t, instanceMutex, release) + go func() { time.Sleep(400 * time.Millisecond); close(release) }() + start = time.Now() + if !waitForInstanceMutex(10 * time.Second) { + t.Fatal("mutex released by the scan was not acquired") + } + if el := time.Since(start); el < 300*time.Millisecond { + t.Fatalf("returned after %s, before the scan released the mutex", el) + } + + release2 := make(chan struct{}) + defer close(release2) + holdMutex(t, instanceMutex, release2) + start = time.Now() + if waitForInstanceMutex(300 * time.Millisecond) { + t.Fatal("held mutex reported free") + } + if el := time.Since(start); el > 5*time.Second { + t.Fatalf("wait not bounded: %s", el) + } +} + +// A process that is already exiting (here: exited, handle still open) makes +// TerminateProcess fail with ACCESS_DENIED. That happens in uninstall when +// killing the bootstrap first makes its job kill the launcher and agent; +// stopProcess must count it as stopped, not as an error. +func TestStopProcess_AlreadyExitingIsStopped(t *testing.T) { + cmd := startSleeper(t, os.Args[0]) + h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_TERMINATE|windows.SYNCHRONIZE, false, uint32(cmd.Process.Pid)) // #nosec G115 -- test pid + if err != nil { + t.Fatal(err) + } + defer windows.CloseHandle(h) + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + if err := stopProcess(h); err != nil { + t.Fatalf("stopProcess on an exited process: %v", err) + } +} diff --git a/internal/selfupdate/arch.go b/internal/selfupdate/arch.go new file mode 100644 index 00000000..03a0f699 --- /dev/null +++ b/internal/selfupdate/arch.go @@ -0,0 +1,6 @@ +package selfupdate + +// NativeArch is the machine's architecture as a release-asset suffix +// (amd64 | arm64). On Windows it comes from IsWow64Process2, so an x64 build +// emulated on arm64 converges to the arm64 build. +func NativeArch() string { return nativeArch() } diff --git a/internal/selfupdate/arch_other.go b/internal/selfupdate/arch_other.go new file mode 100644 index 00000000..fb5943cc --- /dev/null +++ b/internal/selfupdate/arch_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package selfupdate + +import "runtime" + +func nativeArch() string { return runtime.GOARCH } diff --git a/internal/selfupdate/arch_windows.go b/internal/selfupdate/arch_windows.go new file mode 100644 index 00000000..1c0811d4 --- /dev/null +++ b/internal/selfupdate/arch_windows.go @@ -0,0 +1,34 @@ +//go:build windows + +package selfupdate + +import ( + "runtime" + + "golang.org/x/sys/windows" +) + +const ( + imageFileMachineAMD64 = 0x8664 + imageFileMachineARM64 = 0xAA64 +) + +func nativeArch() string { + var process, native uint16 + if err := windows.IsWow64Process2(windows.CurrentProcess(), &process, &native); err == nil { + if a := archForMachine(native); a != "" { + return a + } + } + return runtime.GOARCH +} + +func archForMachine(m uint16) string { + switch m { + case imageFileMachineAMD64: + return "amd64" + case imageFileMachineARM64: + return "arm64" + } + return "" +} diff --git a/internal/selfupdate/arch_windows_test.go b/internal/selfupdate/arch_windows_test.go new file mode 100644 index 00000000..48fa9b51 --- /dev/null +++ b/internal/selfupdate/arch_windows_test.go @@ -0,0 +1,19 @@ +//go:build windows + +package selfupdate + +import "testing" + +func TestArchForMachine(t *testing.T) { + if archForMachine(0x8664) != "amd64" || archForMachine(0xAA64) != "arm64" || archForMachine(0x14c) != "" { + t.Fatal("machine mapping wrong") + } + if a := nativeArch(); a != "amd64" && a != "arm64" { + t.Fatalf("nativeArch = %q on a Windows runner", a) + } +} + +func TestLoadMachineProxyDoesNotFail(t *testing.T) { + // Runners usually have no WinHTTP proxy; this only proves the call is safe. + _ = loadMachineProxy() +} diff --git a/internal/selfupdate/fakeapi_test.go b/internal/selfupdate/fakeapi_test.go new file mode 100644 index 00000000..2726b02e --- /dev/null +++ b/internal/selfupdate/fakeapi_test.go @@ -0,0 +1,168 @@ +package selfupdate + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +// fakeAPI is an in-process latest-binary + release download + run-status +// server. +type fakeAPI struct { + t testing.TB + srv *httptest.Server + signer *testSigner + + mu sync.Mutex + target string // version latest-binary returns + releases map[string]map[string][]byte // version -> remote asset name -> body + // fault knobs + metaStatus int // non-zero: latest-binary returns this + metaBody string // non-empty: raw latest-binary body + noLinuxFields bool // windows response omits linux_checksum fields + githubDown bool // release downloads 404 + mirrorDown bool // mirror downloads 404 + badSig map[string]bool // remote asset -> signature over wrong data + checksumOver map[string]string // remote asset -> served checksum override + eventStatus int // non-zero: run-status returns this + requests []*http.Request + events []map[string]any + githubHits map[string]int + mirrorHits map[string]int + latestRequests []string +} + +// newFakeAPI serves over TLS (srv.Client trusts it) so the HTTPS-only mirror +// rule holds. +func newFakeAPI(t testing.TB) *fakeAPI { return startFakeAPI(t, true) } + +func startFakeAPI(t testing.TB, useTLS bool) *fakeAPI { + f := &fakeAPI{ + t: t, signer: newTestSigner(t), + releases: map[string]map[string][]byte{}, + badSig: map[string]bool{}, + checksumOver: map[string]string{}, + githubHits: map[string]int{}, + mirrorHits: map[string]int{}, + } + mux := http.NewServeMux() + mux.HandleFunc("/v1/cust/developer-mdm-agent/latest-binary", f.latest) + mux.HandleFunc("/v1/cust/developer-mdm-agent/telemetry/run-status", f.runStatus) + mux.HandleFunc("/releases/", f.download("/releases/v", &f.githubDown, f.githubHits)) + mux.HandleFunc("/mirror/", f.download("/mirror/", &f.mirrorDown, f.mirrorHits)) + if useTLS { + f.srv = httptest.NewTLSServer(mux) + } else { + f.srv = httptest.NewServer(mux) + } + t.Cleanup(f.srv.Close) + return f +} + +func (f *fakeAPI) URL() string { return f.srv.URL } + +// addRelease registers the three assets of version v for arch. +func (f *fakeAPI) addRelease(v, arch string, agent, launcher, linux []byte) { + f.mu.Lock() + defer f.mu.Unlock() + f.releases[v] = map[string][]byte{ + fmt.Sprintf("stepsecurity-dev-machine-guard-%s-windows_%s.exe", v, arch): agent, + fmt.Sprintf("stepsecurity-dev-machine-guard-task-%s-windows_%s.exe", v, arch): launcher, + fmt.Sprintf("stepsecurity-dev-machine-guard-%s-linux_%s", v, arch): linux, + } +} + +func (f *fakeAPI) setTarget(v string) { f.mu.Lock(); f.target = v; f.mu.Unlock() } + +func sum(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) } + +func (f *fakeAPI) assetFields(v, remote string) (checksum, signed string) { + body, ok := f.releases[v][remote] + if !ok { + return "", "" + } + checksum = sum(body) + if o, ok := f.checksumOver[remote]; ok { + checksum = o + } + signed = f.signer.transport(checksum) + if f.badSig[remote] { + signed = f.signer.transport(strings.Repeat("0", 64)) + } + return checksum, signed +} + +func (f *fakeAPI) latest(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + f.requests = append(f.requests, r) + f.latestRequests = append(f.latestRequests, r.URL.RawQuery) + if f.metaStatus != 0 { + w.WriteHeader(f.metaStatus) + return + } + if f.metaBody != "" { + _, _ = io.WriteString(w, f.metaBody) + return + } + q := r.URL.Query() + arch, v := q.Get("arch"), f.target + resp := map[string]string{"version": v} + switch q.Get("os") { + case "windows": + resp["checksum"], resp["signed_checksum"] = f.assetFields(v, fmt.Sprintf("stepsecurity-dev-machine-guard-%s-windows_%s.exe", v, arch)) + resp["launcher_checksum"], resp["signed_launcher_checksum"] = f.assetFields(v, fmt.Sprintf("stepsecurity-dev-machine-guard-task-%s-windows_%s.exe", v, arch)) + if !f.noLinuxFields { + resp["linux_checksum"], resp["signed_linux_checksum"] = f.assetFields(v, fmt.Sprintf("stepsecurity-dev-machine-guard-%s-linux_%s", v, arch)) + } + default: + w.WriteHeader(http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(resp) +} + +func (f *fakeAPI) download(prefix string, down *bool, hits map[string]int) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + rest := strings.TrimPrefix(r.URL.Path, prefix) + v, remote, _ := strings.Cut(rest, "/") + hits[remote]++ + body, ok := f.releases[v][remote] + if *down || !ok { + http.NotFound(w, r) + return + } + _, _ = w.Write(body) + } +} + +func (f *fakeAPI) runStatus(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + defer f.mu.Unlock() + if f.eventStatus != 0 { + w.WriteHeader(f.eventStatus) + return + } + f.events = append(f.events, body) + w.WriteHeader(http.StatusOK) +} + +func (f *fakeAPI) client(arch string) *Client { + return &Client{ + HTTP: f.srv.Client(), APIEndpoint: f.srv.URL, CustomerID: "cust", APIKey: "key", + AgentVersion: "1.19.0", Arch: arch, + ReleaseBaseURL: f.srv.URL + "/releases", MirrorURL: "", + SigningKey: f.signer.pubB64, + } +} diff --git a/internal/selfupdate/helper_guard_test.go b/internal/selfupdate/helper_guard_test.go new file mode 100644 index 00000000..99e27f94 --- /dev/null +++ b/internal/selfupdate/helper_guard_test.go @@ -0,0 +1,41 @@ +package selfupdate + +import ( + "os" + "strconv" + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/proctest" +) + +// Helpers dispatched from TestMain never arm -test.timeout; each must stop +// at the deadline its test passed down. +func TestHelpersExitAtDeadline(t *testing.T) { + for name, env := range map[string][]string{ + "sleep role": {envRole + "=sleep"}, + "hung self-check": {envHelper + "=1", "DMG_SU_SMOKE_HANG=1"}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + cmd := proctest.Command(t, os.Args[0], "self-check") + cmd.Env = append(append(cmd.Env, env...), + proctest.EnvDeadline+"="+strconv.FormatInt(time.Now().Add(time.Second).UnixNano(), 10)) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + go func() { _ = cmd.Wait(); close(done) }() + select { + case <-done: + if code := cmd.ProcessState.ExitCode(); code != proctest.ExitGuard { + t.Fatalf("exit %d, want %d", code, proctest.ExitGuard) + } + case <-time.After(15 * time.Second): + _ = cmd.Process.Kill() + <-done + t.Fatal("helper ignored its deadline") + } + }) + } +} diff --git a/internal/selfupdate/killmatrix_test.go b/internal/selfupdate/killmatrix_test.go new file mode 100644 index 00000000..94c909af --- /dev/null +++ b/internal/selfupdate/killmatrix_test.go @@ -0,0 +1,740 @@ +//go:build selfupdate_faults && selfupdate_testkeys + +package selfupdate + +// U0: the trace-driven kill matrix. A clean run of +// each scenario under DMG_FAULT_TRACE lists every checkpoint the updater +// passes, per tick. Then, for every traced (tick, checkpoint) and every +// action in {kill, err, hang, diskfull}, a fresh install is driven to that +// tick, the faulted tick runs as a subprocess (hang is killed once the +// trace shows it hanging, standing in for the task's ExecutionTimeLimit), +// and clean ticks follow. U2 (convergence + its event), U6 (hygiene), U8 +// (no flip-flop) and U9 (the frozen bootstrap read) are asserted on the +// filesystem and on the events the in-process fake API received. A +// checkpoint added later is covered without editing this file. + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/config" + "github.com/step-security/dev-machine-guard/internal/faultinject" + "github.com/step-security/dev-machine-guard/internal/lock" + "github.com/step-security/dev-machine-guard/internal/proctest" + "github.com/step-security/dev-machine-guard/internal/telemetry" + "github.com/step-security/dev-machine-guard/internal/updatestate" +) + +const ( + h1Base = "1.19.0" // N: current, seed and the updater's own version + h1Target = "1.20.0" // N+1 + // cleanTicks is M (U2): ticks allowed after the last fault. + cleanTicks = 3 +) + +func init() { helperRoles["updater"] = helperUpdater } + +// helperUpdater runs one tick with everything taken from env. +func helperUpdater() int { + g := os.Getenv + if t := g("DMG_SU_INSTANCE_LOCK"); t != "" { + lock.SetTargetForTest(t) + } + config.APIEndpoint, config.CustomerID, config.APIKey = g("DMG_SU_API"), "cust", "key" + self, err := updatestate.ParseVersion(g("DMG_SU_SELF")) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 99 + } + L := updatestate.Layout{InstallRoot: g("DMG_SU_INSTALL"), DataRoot: g("DMG_SU_DATA")} + d := &Deps{ + Layout: L, Self: self, SelfExe: filepath.Join(L.VersionDir(self), updatestate.AgentFile), + Client: &Client{ + HTTP: http.DefaultClient, APIEndpoint: g("DMG_SU_API"), CustomerID: "cust", APIKey: "key", + AgentVersion: self.String(), Arch: testArch, + }, + AutoUpdate: true, + // Authenticode is C2's own test; here every staged exe "verifies". + VerifyPublisher: func(p string) error { _, err := os.Stat(p); return err }, + Smoke: RunSmoke, + AcquireInstance: func() (func(), error) { + lk, err := lock.Acquire(nil) + if err != nil { + return nil, err + } + return lk.Release, nil + }, + Send: func(ctx context.Context, ev updatestate.OutboxEvent) error { + err := telemetry.PostUpdateEvent(ctx, http.DefaultClient, telemetry.UpdateEvent{ + ExecutionID: ev.ExecutionID, DeviceID: "dev-h1", InstallChannel: "msi", + Event: ev.Event, From: ev.From, To: ev.To, Error: ev.Error, + }) + if err != nil && strings.Contains(err.Error(), "rejected") { + return fmt.Errorf("%w: %w", ErrEventRejected, err) + } + return err + }, + FreeBytes: FreeBytes, + Now: time.Now, + Logf: func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }, + } + return Run(context.Background(), d) +} + +var h1EnvSeq atomic.Int64 + +var ( + selfBytesOnce sync.Once + selfBytes []byte +) + +func testBinaryBytes(t testing.TB) []byte { + selfBytesOnce.Do(func() { + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + selfBytes, err = os.ReadFile(exe) + if err != nil { + t.Fatal(err) + } + }) + return selfBytes +} + +// h1Env is one install driven by subprocess ticks. +type h1Env struct { + t *testing.T + L updatestate.Layout + api *fakeAPI + lock string + logs []string +} + +// scenario prepares an install and names the outcome it must converge to. +type scenario struct { + name string + setup func(e *h1Env) + // converged reports whether the install reached its terminal state. + converged func(e *h1Env, st *updatestate.State) bool + // event must have reached the backend by convergence. + event, from, to string + // neverCurrent may not be current after it was quarantined (U8). + neverCurrent string +} + +func newH1Env(t *testing.T) *h1Env { + t.Helper() + root := t.TempDir() + e := &h1Env{t: t, L: updatestate.Layout{InstallRoot: filepath.Join(root, "pf"), DataRoot: filepath.Join(root, "pd")}} + e.api = startFakeAPI(t, false) + e.api.addRelease(h1Base, testArch, payload("agent", h1Base), payload("task", h1Base), payload("linux", h1Base)) + e.api.addRelease(h1Target, testArch, testBinaryBytes(t), payload("task", h1Target), payload("linux", h1Target)) + e.api.setTarget(h1Target) + if runtime.GOOS == "windows" { + // A counter, not the clock: parallel cases created within one + // Windows clock tick would otherwise share a mutex and defer. + e.lock = fmt.Sprintf("DMGTestH1-%d-%d", os.Getpid(), h1EnvSeq.Add(1)) + } else { + e.lock = filepath.Join(root, "instance.flock") + } + // seed + versions\N + state {current: N} + seed := e.L.SeedDir(mustV(h1Base)) + must(t, os.MkdirAll(seed, 0o755)) + vdir := e.L.VersionDir(mustV(h1Base)) + must(t, os.MkdirAll(vdir, 0o755)) + m := updatestate.Manifest{Version: h1Base, Files: map[string]string{}} + for _, f := range []struct{ name, kind string }{{updatestate.AgentFile, "agent"}, {updatestate.LauncherFile, "task"}, {updatestate.LinuxFile, "linux"}} { + b := payload(f.kind, h1Base) + must(t, os.WriteFile(filepath.Join(seed, f.name), b, 0o755)) + must(t, os.WriteFile(filepath.Join(vdir, f.name), b, 0o755)) + m.Files[f.name] = sum(b) + } + must(t, updatestate.WriteManifest(vdir, m)) + must(t, os.MkdirAll(e.L.StateDir(), 0o755)) + must(t, updatestate.Save(e.L.StateFile(), &updatestate.State{Schema: 1, Current: h1Base})) + return e +} + +// tick runs one updater subprocess. fault is a DMG_FAULT spec ("" = clean). +// A hang is killed as soon as the trace shows the hanging checkpoint. +func (e *h1Env) tick(fault, trace string) (exit int) { + e.t.Helper() + if err := faultinject.Validate(fault); err != nil { + e.t.Fatalf("bad fault spec %q: %v", fault, err) + } + if trace == "" { + trace = filepath.Join(e.t.TempDir(), "trace") + } + cmd := proctest.Command(e.t, os.Args[0], "-test.run=^$") + cmd.Env = append(cmd.Env, e.childEnv(fault, trace)...) + var out strings.Builder + cmd.Stdout, cmd.Stderr = &out, &out + tickStart := time.Now() + if err := cmd.Start(); err != nil { + e.t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + hangAt := "" + if cp, ok := strings.CutSuffix(fault, ":hang"); ok { + hangAt = cp + } + deadline := time.After(3 * time.Minute) + poll := time.NewTicker(50 * time.Millisecond) + defer poll.Stop() + for { + select { + case err := <-done: + e.logs = append(e.logs, fmt.Sprintf("--- tick fault=%q exit=%v took=%s\n%s", fault, err, time.Since(tickStart), out.String())) + if strings.Contains(out.String(), faultinject.MalformedMarker) { + e.dumpLogs() + e.t.Fatalf("tick fault=%q: malformed fault spec at runtime", fault) + } + if cmd.ProcessState != nil { + return cmd.ProcessState.ExitCode() + } + return -1 + case <-poll.C: + if hangAt != "" && slices.Contains(readTrace(trace), hangAt) { + time.Sleep(100 * time.Millisecond) + _ = cmd.Process.Kill() + hangAt = "" + } + case <-deadline: + _ = cmd.Process.Kill() + e.t.Fatalf("tick fault=%q did not finish", fault) + } + } +} + +// childEnv is what the updater subprocess adds to its environment (the +// smoke self-check it starts inherits it). +func (e *h1Env) childEnv(fault, trace string) []string { + return append([]string{}, + envRole+"=updater", envHelper+"=1", + "DMG_SU_API="+e.api.URL(), "DMG_SU_SELF="+h1Base, + "DMG_SU_INSTALL="+e.L.InstallRoot, "DMG_SU_DATA="+e.L.DataRoot, + "DMG_SU_INSTANCE_LOCK="+e.lock, + "DMG_TEST_SSHSIG_PUBKEY="+e.api.signer.pubB64, + "DMG_TEST_RELEASE_BASE_URL="+e.api.URL()+"/releases", + "DMG_TEST_CHECK_INTERVAL=0s", "DMG_TEST_SWITCH_WAIT=0s", + "DMG_TEST_DEDUPE_WINDOW=0s", "DMG_TEST_PROBATION_WINDOW=0s", + "DMG_TEST_SMOKE_TIMEOUT=60s", "DMG_TEST_SEND_BACKOFF=0s", + "DMG_FAULT="+fault, "DMG_FAULT_TRACE="+trace, + // A -race binary sleeps 1 s at exit by default; every tick starts + // two of them (the updater and its smoke self-check), which made the + // matrix spend most of its time asleep. + "GORACE=atexit_sleep_ms=0", + ) +} + +func readTrace(path string) []string { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + var out []string + sc := bufio.NewScanner(f) + for sc.Scan() { + out = append(out, sc.Text()) + } + return out +} + +func (e *h1Env) dumpLogs() { + e.t.Helper() + for _, l := range e.logs { + e.t.Log(l) + } +} + +// state loads state.json; it must always parse (U9 for the full reader). +func (e *h1Env) state() *updatestate.State { + e.t.Helper() + st, corrupt, err := updatestate.Load(e.L.StateFile()) + if err != nil || corrupt || st == nil { + e.dumpLogs() + e.t.Fatalf("state.json unreadable: err=%v corrupt=%v nil=%v", err, corrupt, st == nil) + } + return st +} + +// assertBootstrapRead is U9: the frozen bootstrap contract reads only +// schema/current/previous, and must resolve something launchable. +func (e *h1Env) assertBootstrapRead() { + e.t.Helper() + data, err := os.ReadFile(e.L.StateFile()) + if err != nil { + e.t.Fatalf("U9: %v", err) + } + var frozen struct { + Schema int `json:"schema"` + Current string `json:"current"` + Previous string `json:"previous"` + } + if err := json.Unmarshal(data, &frozen); err != nil || frozen.Schema < 1 { + e.t.Fatalf("U9: frozen reader can't parse state.json: %v (%s)", err, data) + } + for _, v := range []string{frozen.Current, frozen.Previous} { + pv, err := updatestate.ParseVersion(v) + if err == nil && updatestate.VersionDirComplete(e.L.VersionDir(pv)) == nil { + return + } + } + if len(seedVersions(e.L)) == 0 { + e.t.Fatalf("U9: nothing launchable: state %s, no seed", data) + } +} + +// assertHygiene is U6. +func (e *h1Env) assertHygiene(st *updatestate.State) { + e.t.Helper() + allowed := map[string]bool{st.Current: true, st.Previous: true, h1Base: true} + if st.Probation != nil { + allowed[st.Probation.Version] = true + } + if st.PendingSwitch != "" { + allowed[st.PendingSwitch] = true + } + entries, _ := os.ReadDir(e.L.VersionsDir()) + for _, en := range entries { + if updatestate.IsSweepable(en.Name()) || !allowed[en.Name()] { + e.dumpLogs() + e.t.Fatalf("U6: leftover versions\\%s (state %+v)", en.Name(), st) + } + } + sentries, _ := os.ReadDir(e.L.StateDir()) + for _, en := range sentries { + if strings.HasPrefix(en.Name(), ".state.json.tmp-") { + e.t.Fatalf("U6: leftover %s", en.Name()) + } + } + lk, err := updatestate.AcquireLock(e.L.LockFile()) + if err != nil { + e.t.Fatalf("U6: update lock not acquirable: %v", err) + } + lk.Release() + if len(st.Outbox) != 0 { + e.t.Fatalf("U6: undelivered events after convergence: %+v", st.Outbox) + } +} + +func (e *h1Env) sawEvent(event, from, to string) bool { + e.api.mu.Lock() + defer e.api.mu.Unlock() + for _, ev := range e.api.events { + if ev["status"] == "update" && ev["update_event"] == event && + (from == "" || ev["update_from"] == from) && (to == "" || ev["update_to"] == to) { + if ev["device_id"] != "dev-h1" || ev["invocation_method"] != "update" { + e.t.Fatalf("event fields: %v", ev) + } + return true + } + } + return false +} + +// installDir writes a complete versions\ with small payloads. +func (e *h1Env) installDir(v string) { + pv := mustV(v) + dir := e.L.VersionDir(pv) + must(e.t, os.MkdirAll(dir, 0o755)) + m := updatestate.Manifest{Version: v, Files: map[string]string{}} + for _, f := range []string{updatestate.AgentFile, updatestate.LauncherFile, updatestate.LinuxFile} { + b := []byte(f + v) + must(e.t, os.WriteFile(filepath.Join(dir, f), b, 0o755)) + m.Files[f] = sum(b) + } + must(e.t, updatestate.WriteManifest(dir, m)) +} + +func (e *h1Env) writeSeedDir(v string) { + dir := e.L.SeedDir(mustV(v)) + must(e.t, os.MkdirAll(dir, 0o755)) + for _, f := range []string{updatestate.AgentFile, updatestate.LauncherFile, updatestate.LinuxFile} { + must(e.t, os.WriteFile(filepath.Join(dir, f), []byte(f+v), 0o755)) + } +} + +// holdInstanceMu serializes the global lock target swap below. +var holdInstanceMu sync.Mutex + +// holdInstance takes this install's scan instance mutex in the test +// process, as a running scan would. +func (e *h1Env) holdInstance() (release func()) { + e.t.Helper() + holdInstanceMu.Lock() + restore := lock.SetTargetForTest(e.lock) + lk, err := lock.Acquire(nil) + restore() + holdInstanceMu.Unlock() + if err != nil { + e.t.Fatalf("hold instance mutex: %v", err) + } + return lk.Release +} + +func scenarios() []scenario { + return []scenario{ + { + name: "update", + setup: func(*h1Env) {}, + converged: func(_ *h1Env, st *updatestate.State) bool { + return st.Current == h1Target && st.Probation == nil + }, + event: EventOK, from: h1Base, to: h1Target, + }, + { + // previous (1.18.0), the seed (1.17.0) and the updater (N) all + // differ, so GC mistakes on previous are visible. + name: "update-distinct-previous", + setup: func(e *h1Env) { + must(e.t, os.RemoveAll(filepath.Join(e.L.InstallRoot, "seed"))) + e.writeSeedDir("1.17.0") + e.installDir("1.18.0") + must(e.t, updatestate.Save(e.L.StateFile(), &updatestate.State{Schema: 1, Current: h1Base, Previous: "1.18.0"})) + }, + converged: func(_ *h1Env, st *updatestate.State) bool { + return st.Current == h1Target && st.Probation == nil + }, + event: EventOK, from: h1Base, to: h1Target, + }, + { + // A probation on N+1 with two crashed scans: the tick rolls back. + name: "rollback", + setup: func(e *h1Env) { + pv := mustV(h1Target) + e.api.setTarget(h1Target) + dir := e.L.VersionDir(pv) + must(e.t, os.MkdirAll(dir, 0o755)) + m := updatestate.Manifest{Version: h1Target, Files: map[string]string{}} + for _, f := range []string{updatestate.AgentFile, updatestate.LauncherFile, updatestate.LinuxFile} { + b := []byte("crashing-" + f) + must(e.t, os.WriteFile(filepath.Join(dir, f), b, 0o755)) + m.Files[f] = sum(b) + } + must(e.t, updatestate.WriteManifest(dir, m)) + since := time.Now().Add(-time.Minute).UTC() + must(e.t, updatestate.Save(e.L.StateFile(), &updatestate.State{ + Schema: 1, Current: h1Target, Previous: h1Base, + Probation: &updatestate.Probation{Version: h1Target, Since: since, SmokeOK: true}, + })) + for i := 0; i < 2; i++ { + code := 0xC0000005 + must(e.t, updatestate.AppendRun(e.L.RunFile(pv), updatestate.RunRecord{RunID: fmt.Sprintf("crash-%d", i), Version: h1Target, ExitCode: &code, At: time.Now().UTC()})) + } + }, + converged: func(_ *h1Env, st *updatestate.State) bool { + q, ok := st.Quarantine[h1Target] + return st.Current == h1Base && st.Probation == nil && ok && q.Reason == ReasonBadRuns + }, + event: EventRolledBack, from: h1Target, to: h1Base, + neverCurrent: h1Target, + }, + } +} + +// traceScenario runs sc cleanly to convergence and returns each tick's +// checkpoints. +func traceScenario(t *testing.T, sc scenario) [][]string { + t.Helper() + e := newH1Env(t) + sc.setup(e) + var ticks [][]string + for i := 0; i < cleanTicks+2; i++ { + trace := filepath.Join(t.TempDir(), "trace") + if code := e.tick("", trace); code != ExitOK { + e.dumpLogs() + t.Fatalf("clean %s tick %d exit %d", sc.name, i, code) + } + ticks = append(ticks, readTrace(trace)) + if sc.converged(e, e.state()) { + if !e.sawEvent(sc.event, sc.from, sc.to) { + t.Fatalf("clean %s: no %s", sc.name, sc.event) + } + return ticks + } + } + e.dumpLogs() + t.Fatalf("clean %s run did not converge", sc.name) + return nil +} + +func TestKillMatrix(t *testing.T) { + if testing.Short() { + t.Skip("kill matrix is long") + } + actions := []string{"kill", "err", "hang", "diskfull", "panic"} + for _, sc := range scenarios() { + ticks := traceScenario(t, sc) + total := 0 + for ti, cps := range ticks { + seen := map[string]bool{} + for _, cp := range cps { + if seen[cp] { + continue // DMG_FAULT acts on the first hit + } + seen[cp] = true + for _, action := range actions { + total++ + name := fmt.Sprintf("%s/tick%d/%s/%s", sc.name, ti, cp, action) + t.Run(name, func(t *testing.T) { + t.Parallel() + runFaultCase(t, sc, ti, cp+":"+action) + }) + } + } + } + t.Logf("%s: %d ticks traced, %d fault cases", sc.name, len(ticks), total) + } +} + +func runFaultCase(t *testing.T, sc scenario, faultTick int, fault string) { + e := newH1Env(t) + sc.setup(e) + quarantinedSeen := false + pendingIDs := map[string]bool{} // outbox execution_ids ever persisted (U3) + check := func() *updatestate.State { + st := e.state() + e.assertBootstrapRead() + e.assertPointersIntact(st) + for _, ev := range st.Outbox { + pendingIDs[ev.ExecutionID] = true + } + if sc.neverCurrent != "" { + if _, q := st.Quarantine[sc.neverCurrent]; q { + quarantinedSeen = true + } + if quarantinedSeen && st.Current == sc.neverCurrent { + e.dumpLogs() + t.Fatalf("U8: quarantined %s is current again", sc.neverCurrent) + } + } + return st + } + for i := 0; i < faultTick; i++ { + e.tick("", "") + check() + } + // The faulted tick also can't deliver events (U3: they must survive). + e.api.mu.Lock() + e.api.eventStatus = http.StatusServiceUnavailable + e.api.mu.Unlock() + trace := filepath.Join(t.TempDir(), "fault-trace") + code := e.tick(fault, trace) + e.api.mu.Lock() + e.api.eventStatus = 0 + e.api.mu.Unlock() + cp := fault[:strings.LastIndexByte(fault, ':')] + if !slices.Contains(readTrace(trace), cp) { + e.dumpLogs() + t.Fatalf("fault %s never reached its checkpoint", fault) + } + switch { + case strings.HasSuffix(fault, ":kill") && code != 137: + t.Fatalf("kill: exit %d, want 137", code) + case strings.HasSuffix(fault, ":panic") && code != 2: + t.Fatalf("panic: exit %d, want 2 (a crash)", code) + case strings.HasSuffix(fault, ":hang") && code == 0: + t.Fatalf("hang: process was not killed (exit %d)", code) + } + before := check() + // U5: a tick while a scan holds the instance mutex never switches. + release := e.holdInstance() + e.tick("", "") + release() + if after := check(); after.Current != before.Current && after.Current == h1Target { + e.dumpLogs() + t.Fatalf("U5: switched to %s while a scan held the instance mutex", after.Current) + } + for i := 0; i < cleanTicks; i++ { + e.tick("", "") + st := check() + if sc.converged(e, st) && len(st.Outbox) == 0 { + if !e.sawEvent(sc.event, sc.from, sc.to) { + e.dumpLogs() + t.Fatalf("U2/U3: converged without %s %s->%s", sc.event, sc.from, sc.to) + } + e.assertHygiene(st) + e.assertEventsDelivered(pendingIDs) + e.assertUniqueExecutionIDs() + if os.Getenv("DMG_SU_DUMP") == "1" { + e.dumpLogs() + } + return + } + } + e.dumpLogs() + t.Fatalf("U2: not converged within %d clean ticks after %s: %+v", cleanTicks, fault, e.state()) +} + +// assertPointersIntact: current and previous always name a complete +// version dir, or a seed (GC must never delete what state points at). +func (e *h1Env) assertPointersIntact(st *updatestate.State) { + e.t.Helper() + seeds := map[string]bool{} + for _, s := range seedVersions(e.L) { + seeds[s.String()] = true + } + for _, v := range []string{st.Current, st.Previous} { + if v == "" || seeds[v] { + continue + } + pv, _ := updatestate.ParseVersion(v) + if err := updatestate.VersionDirComplete(e.L.VersionDir(pv)); err != nil { + e.dumpLogs() + e.t.Fatalf("GC removed a pointed-at version %s: %v (state %+v)", v, err, st) + } + } +} + +// assertEventsDelivered is U3: every event that was ever pending reached +// the backend (2xx) by convergence. +func (e *h1Env) assertEventsDelivered(ids map[string]bool) { + e.t.Helper() + got := map[string]bool{} + e.api.mu.Lock() + for _, ev := range e.api.events { + got[fmt.Sprint(ev["execution_id"])] = true + } + e.api.mu.Unlock() + for id := range ids { + if !got[id] { + e.dumpLogs() + e.t.Fatalf("U3: pending event %s was cleared without a 2xx", id) + } + } +} + +// assertUniqueExecutionIDs is D9: an execution_id is only ever reused by +// retries of the same event. +func (e *h1Env) assertUniqueExecutionIDs() { + e.t.Helper() + seen := map[string]string{} + e.api.mu.Lock() + defer e.api.mu.Unlock() + for _, ev := range e.api.events { + id := fmt.Sprint(ev["execution_id"]) + body := fmt.Sprint(ev["update_event"], ev["update_from"], ev["update_to"], ev["error_message"]) + if prev, ok := seen[id]; ok && prev != body { + e.t.Fatalf("D9: execution_id %s reused by different events: %q / %q", id, prev, body) + } + seen[id] = body + } +} + +// TestStateCorruptionMatrix is the updater half of the U0 bootstrap matrix: +// every state.json / version-dir corruption must converge to the target and +// be reported when the state itself was bad. (The `bootstrap run` half +// belongs to the bootstrap, B1.) +func TestStateCorruptionMatrix(t *testing.T) { + cases := []struct { + name string + corrupt func(e *h1Env) + event string // expected besides update_ok, "" = none + }{ + {"missing", func(e *h1Env) { must(t, os.Remove(e.L.StateFile())) }, EventStateCorrupt}, + {"empty", func(e *h1Env) { must(t, os.WriteFile(e.L.StateFile(), nil, 0o644)) }, EventStateCorrupt}, + {"garbage", func(e *h1Env) { must(t, os.WriteFile(e.L.StateFile(), []byte("\x00{nope"), 0o644)) }, EventStateCorrupt}, + {"higher schema (read, not rebuilt)", func(e *h1Env) { + must(t, os.WriteFile(e.L.StateFile(), []byte(`{"schema":2,"current":"1.19.0","v2":true}`), 0o644)) + }, ""}, + {"current dir missing the launcher", func(e *h1Env) { + must(t, os.Remove(filepath.Join(e.L.VersionDir(mustV(h1Base)), updatestate.LauncherFile))) + }, ""}, + {"huge", func(e *h1Env) { + must(t, os.WriteFile(e.L.StateFile(), []byte(`{"schema":1,"current":"1.19.0","pad":"`+strings.Repeat("x", 2<<20)+`"}`), 0o644)) + }, EventStateCorrupt}, + {"traversal current", func(e *h1Env) { + must(t, os.WriteFile(e.L.StateFile(), []byte(`{"schema":1,"current":"..\\x"}`), 0o644)) + }, EventStateCorrupt}, + {"current dir missing", func(e *h1Env) { must(t, os.RemoveAll(e.L.VersionDir(mustV(h1Base)))) }, ""}, + {"current dir missing agent", func(e *h1Env) { + must(t, os.Remove(filepath.Join(e.L.VersionDir(mustV(h1Base)), updatestate.AgentFile))) + }, ""}, + {"previous missing", func(e *h1Env) { + must(t, updatestate.Save(e.L.StateFile(), &updatestate.State{Schema: 1, Current: h1Base, Previous: "1.18.5"})) + }, ""}, + {"seed missing", func(e *h1Env) { must(t, os.RemoveAll(filepath.Join(e.L.InstallRoot, "seed"))) }, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + e := newH1Env(t) + tc.corrupt(e) + for i := 0; i < cleanTicks; i++ { + e.tick("", "") + st := e.state() + if st.Current == h1Target && st.Probation == nil && len(st.Outbox) == 0 { + if tc.event != "" && !e.sawEvent(tc.event, "", "") { + t.Fatalf("no %s", tc.event) + } + if !e.sawEvent(EventOK, "", h1Target) { + t.Fatal("no update_ok") + } + e.assertHygiene(st) + return + } + } + e.dumpLogs() + t.Fatalf("not converged: %+v", e.state()) + }) + } +} + +// The matrix runs ~1500 subprocess ticks; a -race child sleeps 1 s at exit +// unless GORACE says otherwise, which once pushed the package past its CI +// timeout. Every child the harness starts must turn that sleep off. +func TestHarnessChildrenSkipRaceExitSleep(t *testing.T) { + e := newH1Env(t) + env := e.childEnv("", filepath.Join(t.TempDir(), "trace")) + if !slices.Contains(env, "GORACE=atexit_sleep_ms=0") { + t.Fatalf("child env lacks GORACE=atexit_sleep_ms=0") + } +} + +// D28: S6.mutex fires with the scan instance mutex held, so +// S6.mutex:sleep keeps a scan out for d (harness U-F3). +func TestSleepAtS6MutexHoldsTheInstanceMutex(t *testing.T) { + e := newH1Env(t) + trace := filepath.Join(t.TempDir(), "trace") + done := make(chan int, 1) + go func() { done <- e.tick("S6.mutex:sleep3s", trace) }() + deadline := time.Now().Add(2 * time.Minute) + for !slices.Contains(readTrace(trace), "S6.mutex") { + if time.Now().After(deadline) { + t.Fatal("S6.mutex never reached") + } + time.Sleep(20 * time.Millisecond) + } + holdInstanceMu.Lock() + restore := lock.SetTargetForTest(e.lock) + lk, err := lock.Acquire(nil) + restore() + holdInstanceMu.Unlock() + if err == nil { + lk.Release() + t.Fatal("a scan took the instance mutex while the updater slept at S6.mutex") + } + if code := <-done; code != ExitOK { + e.dumpLogs() + t.Fatalf("tick exit %d", code) + } + if e.state().Current != h1Target { + t.Fatal("switch did not complete after the sleep") + } +} diff --git a/internal/selfupdate/knobs.go b/internal/selfupdate/knobs.go new file mode 100644 index 00000000..94a10a44 --- /dev/null +++ b/internal/selfupdate/knobs.go @@ -0,0 +1,37 @@ +package selfupdate + +import "time" + +// knob names a time constant that selfupdate_testkeys builds may override. +type knob int + +const ( + knobProbationWindow knob = iota // smoke-only promotion / inconclusive window (72 h) + knobSwitchWait // S6 wait for the scan instance mutex (10 min) + knobDedupeWindow // event dedupe window (24 h) + knobCheckInterval // minimum spacing of S3 target checks (6 h) + knobSmokeTimeout // S5 self-check limit (60 s) + knobSendBackoff // first retry delay of an unsent event (1 h) +) + +var knobDefaults = map[knob]time.Duration{ + knobProbationWindow: 72 * time.Hour, + knobSwitchWait: 10 * time.Minute, + knobDedupeWindow: 24 * time.Hour, + knobCheckInterval: 6 * time.Hour, + knobSmokeTimeout: 60 * time.Second, + knobSendBackoff: time.Hour, +} + +// knobOverrides is set by in-package tests only. +var knobOverrides = map[knob]time.Duration{} + +func duration(k knob) time.Duration { + if d, ok := knobOverrides[k]; ok { + return d + } + if d, ok := testDuration(k); ok { + return d + } + return knobDefaults[k] +} diff --git a/internal/selfupdate/main_test.go b/internal/selfupdate/main_test.go new file mode 100644 index 00000000..58e56edd --- /dev/null +++ b/internal/selfupdate/main_test.go @@ -0,0 +1,98 @@ +package selfupdate + +import ( + "encoding/json" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/proctest" +) + +// envHelper makes the test binary act as a staged agent: ` self-check` +// prints a SelfCheck whose version is the name of the directory holding the +// binary (versions\\...). DMG_SU_SMOKE_FAIL=[,] fails it, and +// DMG_SU_SMOKE_HANG=1 hangs it. +const envHelper = "DMG_SU_TEST_HELPER" + +// envRole selects a helper role registered in helperRoles (the kill matrix +// registers "updater": one selfupdate.Run tick as a subprocess). +const envRole = "DMG_SU_TEST_ROLE" + +var helperRoles = map[string]func() int{} + +func TestMain(m *testing.M) { + if os.Getenv(envHelper) == "1" && len(os.Args) > 1 && os.Args[1] == "self-check" { + proctest.Guard() + os.Exit(helperSelfCheck()) + } + if role := os.Getenv(envRole); helperRoles[role] != nil { + if role == "sleep" { + proctest.GuardDeadline() // TestSmokeKillsGrandchildren: the job must kill it + } else { + proctest.Guard() + } + os.Exit(helperRoles[role]()) + } + os.Exit(proctest.Main(m)) +} + +func init() { + // "sleep": a grandchild that outlives a hung self-check unless a job + // kills it. + helperRoles["sleep"] = func() int { time.Sleep(time.Hour); return 0 } +} + +func helperSelfCheck() int { + exe, _ := os.Executable() + v := filepath.Base(filepath.Dir(exe)) + if pidFile := os.Getenv("DMG_SU_SMOKE_GRANDCHILD"); pidFile != "" { + cmd := exec.Command(exe) + cmd.Env = append(os.Environ(), envRole+"=sleep", envHelper+"=0") + if err := cmd.Start(); err == nil { + _ = os.WriteFile(pidFile, []byte(strconv.Itoa(cmd.Process.Pid)), 0o600) + } + } + if os.Getenv("DMG_SU_SMOKE_HANG") == "1" { + time.Sleep(time.Hour) + } + for _, bad := range strings.Split(os.Getenv("DMG_SU_SMOKE_FAIL"), ",") { + if bad == v { + return 3 + } + } + _ = json.NewEncoder(os.Stdout).Encode(SelfCheck{Version: v, DeviceID: "dev-1", ConfigOK: true}) + return 0 +} + +// copySelf copies the test binary to dst. +func copySelf(t testing.TB, dst string) { + t.Helper() + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + in, err := os.Open(self) + if err != nil { + t.Fatal(err) + } + defer in.Close() + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + t.Fatal(err) + } + out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755) + if err != nil { + t.Fatal(err) + } + if _, err := io.Copy(out, in); err != nil { + t.Fatal(err) + } + if err := out.Close(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/selfupdate/proxy.go b/internal/selfupdate/proxy.go new file mode 100644 index 00000000..85702d6d --- /dev/null +++ b/internal/selfupdate/proxy.go @@ -0,0 +1,193 @@ +package selfupdate + +import ( + "net" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// machineProxy is the WinHTTP default (machine) proxy; nil means none. The +// SYSTEM updater has no user proxy env, so after the env vars this is what +// corporate proxy-only fleets rely on (spec Contracts: Proxy). +type machineProxy struct { + servers map[string]string // scheme ("" = any) -> host:port + bypass []string +} + +// NewHTTPClient returns the updater's client: the environment proxy vars +// first, then the WinHTTP machine proxy. +func NewHTTPClient() *http.Client { + tr := http.DefaultTransport.(*http.Transport).Clone() + tr.Proxy = proxyFunc(envProxyFromOS(), loadMachineProxy()) + tr.TLSHandshakeTimeout = 30 * time.Second + tr.ResponseHeaderTimeout = 60 * time.Second + // Events share this client; no request follows https -> http. + return &http.Client{Transport: tr, CheckRedirect: refuseDowngrade(nil)} +} + +// envProxy is the environment's proxy configuration. +type envProxy struct { + // https/http: the env defines a proxy for that scheme; its decision + // (NO_PROXY included) is final for requests of that scheme. + https, http bool + noProxy string + fn func(*http.Request) (*url.URL, error) +} + +func envProxyFromOS() envProxy { + get := func(names ...string) string { + for _, n := range names { + if v := strings.TrimSpace(os.Getenv(n)); v != "" { + return v + } + } + return "" + } + return envProxy{ + https: get("HTTPS_PROXY", "https_proxy") != "", + http: get("HTTP_PROXY", "http_proxy") != "", + noProxy: get("NO_PROXY", "no_proxy"), + fn: http.ProxyFromEnvironment, + } +} + +// proxyFunc, per request scheme: an env proxy for that scheme decides alone +// (NO_PROXY included); without one, NO_PROXY still sends listed hosts +// direct, then the WinHTTP machine proxy applies with its own bypass list. +func proxyFunc(env envProxy, mp *machineProxy) func(*http.Request) (*url.URL, error) { + return func(req *http.Request) (*url.URL, error) { + if (req.URL.Scheme == "https" && env.https) || (req.URL.Scheme == "http" && env.http) { + return env.fn(req) + } + if mp == nil || noProxyMatch(env.noProxy, req.URL.Hostname()) { + return nil, nil + } + return mp.proxyFor(req.URL), nil + } +} + +// noProxyMatch applies NO_PROXY: "*", host names (matching the host and +// its subdomains, with or without a leading dot), IPs and CIDRs; ports are +// ignored. +func noProxyMatch(list, host string) bool { + host = strings.ToLower(strings.TrimSuffix(host, ".")) + ip := net.ParseIP(host) + for _, e := range strings.Split(list, ",") { + e = strings.ToLower(strings.TrimSpace(e)) + if e == "" { + continue + } + if e == "*" { + return true + } + if _, n, err := net.ParseCIDR(e); err == nil { + if ip != nil && n.Contains(ip) { + return true + } + continue + } + if h, _, err := net.SplitHostPort(e); err == nil { + e = h + } + if eip := net.ParseIP(e); eip != nil { + if ip != nil && eip.Equal(ip) { + return true + } + continue + } + e = strings.TrimPrefix(strings.TrimPrefix(e, "*"), ".") + if host == e || strings.HasSuffix(host, "."+e) { + return true + } + } + return false +} + +// parseWinHTTPProxy parses WINHTTP_PROXY_INFO strings: lpszProxy is a list +// of "[scheme=][scheme://]host[:port]" entries separated by ';' or spaces, +// lpszProxyBypass a list of host patterns ("", "*.corp", "10.*"). +func parseWinHTTPProxy(proxy, bypass string) *machineProxy { + mp := &machineProxy{servers: map[string]string{}} + for _, e := range splitList(proxy) { + scheme := "" + if i := strings.IndexByte(e, '='); i >= 0 { + scheme, e = strings.ToLower(e[:i]), e[i+1:] + } + if i := strings.Index(e, "://"); i >= 0 { + e = e[i+3:] + } + e = strings.TrimRight(e, "/") + if e == "" { + continue + } + if _, ok := mp.servers[scheme]; !ok { + mp.servers[scheme] = e + } + } + if len(mp.servers) == 0 { + return nil + } + mp.bypass = splitList(bypass) + return mp +} + +func splitList(s string) []string { + return strings.FieldsFunc(s, func(r rune) bool { return r == ';' || r == ' ' || r == '\t' || r == ',' }) +} + +func (mp *machineProxy) proxyFor(u *url.URL) *url.URL { + host := strings.ToLower(u.Hostname()) + for _, b := range mp.bypass { + if bypassMatch(strings.ToLower(b), host) { + return nil + } + } + server, ok := mp.servers[u.Scheme] + if !ok { + if server, ok = mp.servers[""]; !ok { + if server, ok = mp.servers["http"]; !ok { + return nil + } + } + } + return &url.URL{Scheme: "http", Host: server} +} + +func bypassMatch(pattern, host string) bool { + if pattern == "" { + return !strings.Contains(host, ".") || host == "localhost" || net.ParseIP(host).IsLoopback() + } + if i := strings.Index(pattern, "://"); i >= 0 { + pattern = pattern[i+3:] + } + if h, _, err := net.SplitHostPort(pattern); err == nil { + pattern = h + } + return wildcardMatch(pattern, host) +} + +// wildcardMatch matches '*' against any run of characters. +func wildcardMatch(pattern, s string) bool { + for len(pattern) > 0 { + if pattern[0] == '*' { + pattern = strings.TrimLeft(pattern, "*") + if pattern == "" { + return true + } + for i := 0; i <= len(s); i++ { + if wildcardMatch(pattern, s[i:]) { + return true + } + } + return false + } + if s == "" || pattern[0] != s[0] { + return false + } + pattern, s = pattern[1:], s[1:] + } + return s == "" +} diff --git a/internal/selfupdate/proxy_other.go b/internal/selfupdate/proxy_other.go new file mode 100644 index 00000000..0bd18c4c --- /dev/null +++ b/internal/selfupdate/proxy_other.go @@ -0,0 +1,5 @@ +//go:build !windows + +package selfupdate + +func loadMachineProxy() *machineProxy { return nil } diff --git a/internal/selfupdate/proxy_windows.go b/internal/selfupdate/proxy_windows.go new file mode 100644 index 00000000..b1d9996f --- /dev/null +++ b/internal/selfupdate/proxy_windows.go @@ -0,0 +1,44 @@ +//go:build windows + +package selfupdate + +import ( + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + modwinhttp = windows.NewLazySystemDLL("winhttp.dll") + procWinHttpGetDefaultProxyConfiguration = modwinhttp.NewProc("WinHttpGetDefaultProxyConfiguration") + procGlobalFree = windows.NewLazySystemDLL("kernel32.dll").NewProc("GlobalFree") +) + +const winhttpAccessTypeNamedProxy = 3 + +// winhttpProxyInfo is WINHTTP_PROXY_INFO. +type winhttpProxyInfo struct { + accessType uint32 + proxy *uint16 + proxyBypass *uint16 +} + +// loadMachineProxy reads the WinHTTP default proxy (netsh winhttp set proxy). +func loadMachineProxy() *machineProxy { + if procWinHttpGetDefaultProxyConfiguration.Find() != nil { + return nil + } + var info winhttpProxyInfo + r, _, _ := procWinHttpGetDefaultProxyConfiguration.Call(uintptr(unsafe.Pointer(&info))) + defer func() { + for _, p := range []*uint16{info.proxy, info.proxyBypass} { + if p != nil { + _, _, _ = procGlobalFree.Call(uintptr(unsafe.Pointer(p))) + } + } + }() + if r == 0 || info.accessType != winhttpAccessTypeNamedProxy || info.proxy == nil { + return nil + } + return parseWinHTTPProxy(windows.UTF16PtrToString(info.proxy), windows.UTF16PtrToString(info.proxyBypass)) +} diff --git a/internal/selfupdate/release.go b/internal/selfupdate/release.go new file mode 100644 index 00000000..35fa2fd6 --- /dev/null +++ b/internal/selfupdate/release.go @@ -0,0 +1,355 @@ +// Package selfupdate is the Windows MSI self-updater (`agent self-update +// --system`, run by the SYSTEM update task through the bootstrap). It asks +// agent-api latest-binary for the tenant's target, verifies every artefact +// (SSHSIG over the checksum before download, sha256 after, Authenticode +// publisher), stages it as an immutable versions\ directory and switches +// the state.json pointer the bootstrap reads. See internal/updatestate for +// the on-disk contract. +package selfupdate + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/updatestate" +) + +const ( + metaTimeout = 30 * time.Second + maxMetaBytes = 64 << 10 + downloadTimeout = 5 * time.Minute + maxAssetBytes = 128 << 20 + + agentAssetBase = "stepsecurity-dev-machine-guard" + launcherAssetBase = "stepsecurity-dev-machine-guard-task" + + defaultReleaseBaseURL = "https://github.com/step-security/dev-machine-guard/releases/download" +) + +// minWindowsSelfUpdateVersion is the floor F (spec §6): the first release +// that contains this updater. A target below it would land a version with no +// SYSTEM updater, freezing the device, so it is refused. Set at release cut. +const minWindowsSelfUpdateVersion = "1.18.0" + +// Floor returns F. +func Floor() updatestate.Version { + v, err := updatestate.ParseVersion(minWindowsSelfUpdateVersion) + if err != nil { + panic(err) + } + return v +} + +// Asset is one file of a release: the name it gets in versions\, the +// release asset name, and its signed checksum. +type Asset struct { + File string // updatestate.AgentFile etc. + Remote string // release asset name + Checksum string // lowercase hex sha256 + Signed string // signed_checksum transport value +} + +// Release is a latest-binary target with all three assets. +type Release struct { + Version updatestate.Version + Assets []Asset +} + +// Client talks to latest-binary and downloads release assets. +type Client struct { + HTTP *http.Client + APIEndpoint string + CustomerID string + APIKey string + AgentVersion string + Arch string // native windows arch: amd64 | arm64 + LagBehind int + CooldownHours int + MirrorURL string // optional HTTPS mirror (config update_mirror_url) + // ReleaseBaseURL and SigningKey default to the GitHub releases and the + // pinned release key; only tests and selfupdate_testkeys builds change them. + ReleaseBaseURL string + SigningKey string +} + +func (c *Client) releaseBase() string { + if c.ReleaseBaseURL != "" { + return c.ReleaseBaseURL + } + if v := testReleaseBaseURL(); v != "" { + return v + } + return defaultReleaseBaseURL +} + +// errDowngrade refuses an https -> http redirect. +var errDowngrade = errors.New("refusing a redirect from https to http") + +// httpClient is c.HTTP with redirects that downgrade https to http refused +// (on top of any redirect policy the client already has). +func (c *Client) httpClient() *http.Client { + hc := *c.HTTP + hc.CheckRedirect = refuseDowngrade(hc.CheckRedirect) + return &hc +} + +func refuseDowngrade(prev func(*http.Request, []*http.Request) error) func(*http.Request, []*http.Request) error { + return func(req *http.Request, via []*http.Request) error { + for _, v := range via { + if v.URL.Scheme == "https" && req.URL.Scheme != "https" { + return errDowngrade + } + } + if prev != nil { + return prev(req, via) + } + if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + return nil + } +} + +func (c *Client) signingKey() string { + if c.SigningKey != "" { + return c.SigningKey + } + if v := testSSHSIGKey(); v != "" { + return v + } + return releasePublicKeyB64 +} + +type latestBinaryResponse struct { + Version string `json:"version"` + Checksum string `json:"checksum"` + SignedChecksum string `json:"signed_checksum"` + LauncherChecksum string `json:"launcher_checksum"` + SignedLauncherChecksum string `json:"signed_launcher_checksum"` + LinuxChecksum string `json:"linux_checksum"` + SignedLinuxChecksum string `json:"signed_linux_checksum"` +} + +// ErrCheck wraps every latest-binary failure (update_check_failed). +var ErrCheck = errors.New("latest-binary check failed") + +// Latest resolves the target from one windows latest-binary response: +// agent, launcher and dmg-linux all come from the same resolution, so they +// always name one version. A missing checksum or signature for any asset is +// left empty for VerifySignatures to reject (update_verify_failed) before +// anything is downloaded. +func (c *Client) Latest(ctx context.Context) (*Release, error) { + win, err := c.fetch(ctx, "windows") + if err != nil { + return nil, err + } + v, err := updatestate.ParseVersion(win.Version) + if err != nil { + return nil, fmt.Errorf("%w: target %q is not a strict x.y.z release", ErrCheck, win.Version) + } + vs := v.String() + return &Release{Version: v, Assets: []Asset{ + {File: updatestate.AgentFile, Remote: fmt.Sprintf("%s-%s-windows_%s.exe", agentAssetBase, vs, c.Arch), + Checksum: strings.ToLower(win.Checksum), Signed: win.SignedChecksum}, + {File: updatestate.LauncherFile, Remote: fmt.Sprintf("%s-%s-windows_%s.exe", launcherAssetBase, vs, c.Arch), + Checksum: strings.ToLower(win.LauncherChecksum), Signed: win.SignedLauncherChecksum}, + {File: updatestate.LinuxFile, Remote: fmt.Sprintf("%s-%s-linux_%s", agentAssetBase, vs, c.Arch), + Checksum: strings.ToLower(win.LinuxChecksum), Signed: win.SignedLinuxChecksum}, + }}, nil +} + +func (c *Client) fetch(ctx context.Context, goos string) (*latestBinaryResponse, error) { + q := url.Values{} + q.Set("os", goos) + q.Set("arch", c.Arch) + if c.LagBehind > 0 || c.CooldownHours > 0 { + q.Set("lag_behind", fmt.Sprint(c.LagBehind)) + q.Set("cooldown_hours", fmt.Sprint(c.CooldownHours)) + } + endpoint := fmt.Sprintf("%s/v1/%s/developer-mdm-agent/latest-binary?%s", + strings.TrimRight(c.APIEndpoint, "/"), url.PathEscape(c.CustomerID), q.Encode()) + + ctx, cancel := context.WithTimeout(ctx, metaTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCheck, err) + } + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("X-Agent-Version", c.AgentVersion) + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCheck, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: latest-binary (%s) returned HTTP %d", ErrCheck, goos, resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxMetaBytes+1)) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCheck, err) + } + if len(body) > maxMetaBytes { + return nil, fmt.Errorf("%w: latest-binary response too large", ErrCheck) + } + var meta latestBinaryResponse + if err := json.Unmarshal(body, &meta); err != nil { + return nil, fmt.Errorf("%w: parse latest-binary response: %w", ErrCheck, err) + } + return &meta, nil +} + +// ErrVerify wraps every signature, checksum or publisher failure +// (update_verify_failed). +var ErrVerify = errors.New("verification failed") + +// VerifySignatures checks every asset's SSHSIG before anything is +// downloaded: one unverifiable asset rejects the whole release. +func (c *Client) VerifySignatures(r *Release) error { + for _, a := range r.Assets { + if a.Checksum == "" || a.Signed == "" { + return fmt.Errorf("%w: latest-binary gave no checksum or signature for %s", ErrVerify, a.File) + } + if !isHexSHA256(a.Checksum) { + return fmt.Errorf("%w: %s checksum is not a sha256", ErrVerify, a.File) + } + armored, err := decodeSignedChecksum(a.Signed) + if err != nil { + return fmt.Errorf("%w: %s signature: %w", ErrVerify, a.File, err) + } + if err := verifySSHSig(armored, []byte(a.Checksum), c.signingKey(), signatureNamespace); err != nil { + return fmt.Errorf("%w: %s signature: %w", ErrVerify, a.File, err) + } + } + return nil +} + +func isHexSHA256(s string) bool { + b, err := hex.DecodeString(s) + return err == nil && len(b) == sha256.Size +} + +// ErrDownload wraps a download that failed from every source +// (update_download_failed). +var ErrDownload = errors.New("download failed") + +// Download fetches asset a of version v into dir\: GitHub first, +// then the mirror. The written file's sha256 must equal the signed checksum. +// Local write failures (disk full) are returned unwrapped by ErrDownload so +// the caller reports them as stage failures. +func (c *Client) Download(ctx context.Context, v updatestate.Version, a Asset, dir string) error { + dst := filepath.Join(dir, a.File) + primary := fmt.Sprintf("%s/v%s/%s", strings.TrimRight(c.releaseBase(), "/"), v, a.Remote) + err := c.downloadURL(ctx, primary, dst, a.Checksum) + if err == nil || !errors.Is(err, ErrDownload) || c.MirrorURL == "" { + return err + } + mirror, merr := c.mirrorAssetURL(v, a) + if merr != nil { + return fmt.Errorf("%w: %w (mirror: %w)", ErrDownload, err, merr) + } + if err2 := c.downloadURL(ctx, mirror, dst, a.Checksum); err2 != nil { + if !errors.Is(err2, ErrDownload) { + return err2 + } + return fmt.Errorf("%w: github: %w; mirror: %w", ErrDownload, err, err2) + } + return nil +} + +func (c *Client) mirrorAssetURL(v updatestate.Version, a Asset) (string, error) { + u, err := url.Parse(c.MirrorURL) + insecureOK := testAllowInsecureMirror() && u != nil && u.Scheme == "http" + if err != nil || (u.Scheme != "https" && !insecureOK) || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return "", errors.New("invalid HTTPS update mirror") + } + return strings.TrimRight(u.String(), "/") + "/" + v.String() + "/" + a.Remote, nil +} + +func (c *Client) downloadURL(ctx context.Context, assetURL, dst, want string) error { + ctx, cancel := context.WithTimeout(ctx, downloadTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetURL, nil) + if err != nil { + return fmt.Errorf("%w: %w", ErrDownload, err) + } + resp, err := c.httpClient().Do(req) + if err != nil { + return fmt.Errorf("%w: %w", ErrDownload, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%w: %s returned HTTP %d", ErrDownload, assetURL, resp.StatusCode) + } + if resp.ContentLength > maxAssetBytes { + return fmt.Errorf("%w: %s exceeds the size limit", ErrDownload, assetURL) + } + // #nosec G302 G304 -- dst is inside the updater's own staging directory; release executables are 0755. + f, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755) + if err != nil { + return err + } + h := sha256.New() + n, copyErr := io.Copy(io.MultiWriter(f, h), io.LimitReader(resp.Body, maxAssetBytes+1)) + syncErr := f.Sync() + closeErr := f.Close() + switch { + case copyErr != nil && isLocalWriteErr(copyErr): + return copyErr + case copyErr != nil: + return fmt.Errorf("%w: %s: %w", ErrDownload, assetURL, copyErr) + case n > maxAssetBytes: + return fmt.Errorf("%w: %s exceeds the size limit", ErrDownload, assetURL) + case syncErr != nil: + return syncErr + case closeErr != nil: + return closeErr + } + if got := hex.EncodeToString(h.Sum(nil)); got != want { + return fmt.Errorf("%w: %s sha256 %s, want %s", ErrVerify, filepath.Base(dst), got, want) + } + return nil +} + +// isLocalWriteErr tells a failed write to the staging file (disk full, +// quota) from a failed read of the response body. +func isLocalWriteErr(err error) bool { + var pe *os.PathError + return errors.As(err, &pe) +} + +// decodeSignedChecksum recovers the armored SSHSIG block from the API's +// signed_checksum field, which is base64-wrapped for single-line JSON. A +// value that already carries the armor header is accepted as is. +func decodeSignedChecksum(s string) (string, error) { + if strings.Contains(s, sigArmorBegin) { + return s, nil + } + compact := strings.Map(func(r rune) rune { + if r == '\n' || r == '\r' || r == ' ' || r == '\t' { + return -1 + } + return r + }, s) + decoded, err := base64.StdEncoding.DecodeString(compact) + if err != nil { + return "", fmt.Errorf("base64-decode transport wrapper: %w", err) + } + armored := string(decoded) + if !strings.Contains(armored, sigArmorBegin) { + return "", errors.New("decoded signed_checksum is not an armored SSH signature block") + } + return armored, nil +} diff --git a/internal/selfupdate/release_test.go b/internal/selfupdate/release_test.go new file mode 100644 index 00000000..ba8750a9 --- /dev/null +++ b/internal/selfupdate/release_test.go @@ -0,0 +1,423 @@ +package selfupdate + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/step-security/dev-machine-guard/internal/updatestate" +) + +func newReleaseFixture(t *testing.T) (*fakeAPI, *Client) { + t.Helper() + f := newFakeAPI(t) + f.addRelease("1.20.0", "arm64", []byte("agent-1.20.0"), []byte("task-1.20.0"), []byte("linux-1.20.0")) + f.setTarget("1.20.0") + return f, f.client("arm64") +} + +func TestLatestRequestsNativeArchWithAuth(t *testing.T) { + f, c := newReleaseFixture(t) + c.LagBehind, c.CooldownHours = 1, 24 + rel, err := c.Latest(context.Background()) + if err != nil { + t.Fatal(err) + } + if rel.Version.String() != "1.20.0" || len(rel.Assets) != 3 { + t.Fatalf("release = %+v", rel) + } + wantRemote := map[string]string{ + updatestate.AgentFile: "stepsecurity-dev-machine-guard-1.20.0-windows_arm64.exe", + updatestate.LauncherFile: "stepsecurity-dev-machine-guard-task-1.20.0-windows_arm64.exe", + updatestate.LinuxFile: "stepsecurity-dev-machine-guard-1.20.0-linux_arm64", + } + for _, a := range rel.Assets { + if wantRemote[a.File] != a.Remote { + t.Errorf("%s remote = %s, want %s", a.File, a.Remote, wantRemote[a.File]) + } + } + // D11: one resolution = one version; dmg-linux comes from the same + // windows response. + if len(f.requests) != 1 { + t.Fatalf("latest-binary requests = %d, want 1", len(f.requests)) + } + for i, goos := range []string{"windows"} { + r := f.requests[i] + q := r.URL.Query() + if q.Get("os") != goos || q.Get("arch") != "arm64" || q.Get("lag_behind") != "1" || q.Get("cooldown_hours") != "24" { + t.Errorf("query %d = %s", i, r.URL.RawQuery) + } + if r.Header.Get("Authorization") != "Bearer key" || r.Header.Get("X-Agent-Version") != "1.19.0" { + t.Errorf("headers %d = %v", i, r.Header) + } + } +} + +func TestLatestRejectsNonStrictVersions(t *testing.T) { + for _, v := range []string{"1.20.0-rc.1", "v1.20.0", "1.20", "../1.20.0", `..\x`, "01.2.3", "1.2.3+meta", ""} { + t.Run(v, func(t *testing.T) { + f, c := newReleaseFixture(t) + f.setTarget(v) + if _, err := c.Latest(context.Background()); !errors.Is(err, ErrCheck) { + t.Fatalf("Latest(%q) err = %v, want ErrCheck", v, err) + } + }) + } +} + +func TestLatestFailures(t *testing.T) { + cases := map[string]func(f *fakeAPI){ + "http 500": func(f *fakeAPI) { f.metaStatus = 500 }, + "http 422": func(f *fakeAPI) { f.metaStatus = 422 }, + "garbage": func(f *fakeAPI) { f.metaBody = "" }, + "oversized": func(f *fakeAPI) { f.metaBody = `{"version":"` + strings.Repeat("1", maxMetaBytes) + `"}` }, + } + for name, mut := range cases { + t.Run(name, func(t *testing.T) { + f, c := newReleaseFixture(t) + mut(f) + if _, err := c.Latest(context.Background()); !errors.Is(err, ErrCheck) { + t.Fatalf("err = %v, want ErrCheck", err) + } + }) + } +} + +// D11: a response without the dmg-linux fields is a verify failure, not a +// silent skip and not a check failure. +// DMG-I5-1: a response that names a valid version but lacks a checksum or +// signature for any asset is a verification failure (spec S4), not a check +// failure, and nothing is downloaded. +func TestMissingSignedFieldsFailVerification(t *testing.T) { + for name, body := range map[string]string{ + "no signed_checksum": `{"version":"1.20.0","checksum":"` + strings.Repeat("a", 64) + `","launcher_checksum":"` + strings.Repeat("b", 64) + `","signed_launcher_checksum":"x"}`, + "no launcher fields": `{"version":"1.20.0","checksum":"` + strings.Repeat("a", 64) + `","signed_checksum":"x"}`, + "no checksum at all": `{"version":"1.20.0"}`, + } { + t.Run(name, func(t *testing.T) { + f, c := newReleaseFixture(t) + f.metaBody = body + rel, err := c.Latest(context.Background()) + if err != nil { + t.Fatalf("Latest = %v, want a release that fails verification", err) + } + if err := c.VerifySignatures(rel); !errors.Is(err, ErrVerify) { + t.Fatalf("VerifySignatures = %v, want ErrVerify", err) + } + }) + } +} + +func TestMissingLinuxFieldsFailVerification(t *testing.T) { + f, c := newReleaseFixture(t) + f.noLinuxFields = true + rel, err := c.Latest(context.Background()) + if err != nil { + t.Fatalf("Latest = %v, want a release whose verification fails", err) + } + if err := c.VerifySignatures(rel); !errors.Is(err, ErrVerify) || !strings.Contains(err.Error(), updatestate.LinuxFile) { + t.Fatalf("VerifySignatures = %v, want ErrVerify naming %s", err, updatestate.LinuxFile) + } +} + +func TestVerifySignatures(t *testing.T) { + f, c := newReleaseFixture(t) + rel, err := c.Latest(context.Background()) + if err != nil { + t.Fatal(err) + } + if err := c.VerifySignatures(rel); err != nil { + t.Fatalf("good release rejected: %v", err) + } + + other := newTestSigner(t) + wrongKey := *c + wrongKey.SigningKey = other.pubB64 + if err := wrongKey.VerifySignatures(rel); !errors.Is(err, ErrVerify) { + t.Fatalf("wrong key: err = %v", err) + } + + f.badSig["stepsecurity-dev-machine-guard-task-1.20.0-windows_arm64.exe"] = true + rel2, err := c.Latest(context.Background()) + if err != nil { + t.Fatal(err) + } + if err := c.VerifySignatures(rel2); !errors.Is(err, ErrVerify) { + t.Fatalf("bad launcher signature: err = %v", err) + } + + rel2.Assets[1].Signed = "" + if err := c.VerifySignatures(rel2); !errors.Is(err, ErrVerify) { + t.Fatalf("missing signature: err = %v", err) + } + // A non-hex checksum carrying a signature over itself must be refused by + // the sha256 shape check, not by anything later. + for _, bad := range []string{"nothex", strings.Repeat("a", 63), strings.Repeat("zz", 32)} { + rel3, _ := c.Latest(context.Background()) + rel3.Assets[1].Checksum = bad + rel3.Assets[1].Signed = f.signer.transport(bad) + if err := c.VerifySignatures(rel3); !errors.Is(err, ErrVerify) || !strings.Contains(err.Error(), "is not a sha256") { + t.Fatalf("checksum %q: err = %v, want the sha256 shape error", bad, err) + } + } +} + +func TestReleaseKeyIsPinnedByDefault(t *testing.T) { + c := &Client{} + if testSSHSIGKey() == "" && c.signingKey() != releasePublicKeyB64 { + t.Fatalf("default signing key = %q", c.signingKey()) + } + if testReleaseBaseURL() == "" && c.releaseBase() != defaultReleaseBaseURL { + t.Fatalf("default release base = %q", c.releaseBase()) + } +} + +func TestDownloadGitHubThenMirror(t *testing.T) { + f, c := newReleaseFixture(t) + rel, err := c.Latest(context.Background()) + if err != nil { + t.Fatal(err) + } + v := rel.Version + agent := rel.Assets[0] + + dir := t.TempDir() + if err := c.Download(context.Background(), v, agent, dir); err != nil { + t.Fatalf("github download: %v", err) + } + if b, _ := os.ReadFile(filepath.Join(dir, updatestate.AgentFile)); string(b) != "agent-1.20.0" { + t.Fatalf("content = %q", b) + } + + f.githubDown = true + if err := c.Download(context.Background(), v, agent, t.TempDir()); !errors.Is(err, ErrDownload) { + t.Fatalf("github down, no mirror: err = %v", err) + } + c.MirrorURL = f.URL() + "/mirror" + if err := c.Download(context.Background(), v, agent, t.TempDir()); err != nil { + t.Fatalf("mirror fallback: %v", err) + } + if f.mirrorHits[agent.Remote] != 1 { + t.Fatalf("mirror hits = %d", f.mirrorHits[agent.Remote]) + } + f.mirrorDown = true + if err := c.Download(context.Background(), v, agent, t.TempDir()); !errors.Is(err, ErrDownload) { + t.Fatalf("both down: err = %v", err) + } +} + +func TestDownloadRejectsInsecureMirror(t *testing.T) { + f, c := newReleaseFixture(t) + rel, _ := c.Latest(context.Background()) + f.githubDown = true + u, _ := url.Parse(f.URL()) + u.Scheme = "http" + c.MirrorURL = u.String() + "/mirror" + err := c.Download(context.Background(), rel.Version, rel.Assets[0], t.TempDir()) + if testAllowInsecureMirror() { + t.Skip("insecure mirror allowed by DMG_TEST_ALLOW_HTTP_MIRROR") + } + if err == nil || !strings.Contains(err.Error(), "invalid HTTPS update mirror") { + t.Fatalf("err = %v", err) + } +} + +func TestDownloadChecksumMismatchIsVerifyFailure(t *testing.T) { + _, c := newReleaseFixture(t) + rel, _ := c.Latest(context.Background()) + a := rel.Assets[2] + a.Checksum = strings.Repeat("ab", 32) + if err := c.Download(context.Background(), rel.Version, a, t.TempDir()); !errors.Is(err, ErrVerify) { + t.Fatalf("err = %v, want ErrVerify", err) + } +} + +func TestDownloadSizeLimit(t *testing.T) { + f, c := newReleaseFixture(t) + big := make([]byte, maxAssetBytes+1) + f.addRelease("1.20.0", "arm64", big, []byte("t"), []byte("l")) + rel, err := c.Latest(context.Background()) + if err != nil { + t.Fatal(err) + } + if err := c.Download(context.Background(), rel.Version, rel.Assets[0], t.TempDir()); !errors.Is(err, ErrDownload) { + t.Fatalf("err = %v, want ErrDownload (size limit)", err) + } +} + +func TestDecodeSignedChecksum(t *testing.T) { + if _, err := decodeSignedChecksum("!!!"); err == nil { + t.Fatal("garbage accepted") + } + if _, err := decodeSignedChecksum("aGVsbG8="); err == nil { + t.Fatal("non-armored payload accepted") + } + if got, err := decodeSignedChecksum(fixtureSig); err != nil || got != fixtureSig { + t.Fatalf("raw armor: %v", err) + } +} + +func TestFloorParses(t *testing.T) { + if Floor().String() != minWindowsSelfUpdateVersion { + t.Fatal(Floor()) + } +} + +func TestProxyParsing(t *testing.T) { + mp := parseWinHTTPProxy("http=proxy:3128;https=secure:8443", ";*.corp.example;10.*") + mustProxy := func(raw, want string) { + t.Helper() + u, _ := url.Parse(raw) + got := mp.proxyFor(u) + gs := "" + if got != nil { + gs = got.Host + } + if gs != want { + t.Errorf("proxyFor(%s) = %q, want %q", raw, gs, want) + } + } + mustProxy("https://api.stepsecurity.io/x", "secure:8443") + mustProxy("http://example.com/", "proxy:3128") + mustProxy("https://intranet/", "") + mustProxy("https://a.corp.example/", "") + mustProxy("https://10.1.2.3/", "") + mustProxy("https://127.0.0.1/", "") + + single := parseWinHTTPProxy("http://gw.example:8080", "") + u, _ := url.Parse("https://github.com/") + if p := single.proxyFor(u); p == nil || p.Host != "gw.example:8080" { + t.Fatalf("single proxy = %v", p) + } + if parseWinHTTPProxy("", "") != nil || parseWinHTTPProxy(" ; ", "") != nil { + t.Fatal("empty proxy list parsed as a proxy") + } +} + +func TestProxyFuncPrefersEnvironment(t *testing.T) { + envURL, _ := url.Parse("http://env-proxy:1") + mp := parseWinHTTPProxy("machine:2", "*.bypass.corp") + get := func(env envProxy, raw string) string { + t.Helper() + req, _ := http.NewRequest(http.MethodGet, raw, nil) + u, err := proxyFunc(env, mp)(req) + if err != nil { + t.Fatal(err) + } + if u == nil { + return "direct" + } + return u.Host + } + viaEnv := func(*http.Request) (*url.URL, error) { return envURL, nil } + envDirect := func(*http.Request) (*url.URL, error) { return nil, nil } + + if got := get(envProxy{https: true, fn: viaEnv}, "https://api.example/"); got != "env-proxy:1" { + t.Fatalf("env proxy not preferred: %s", got) + } + // R-C1-4: the env config said "direct" (NO_PROXY) - the machine proxy + // must not override it. + if got := get(envProxy{https: true, fn: envDirect}, "https://mirror.corp/"); got != "direct" { + t.Fatalf("env NO_PROXY overridden by the machine proxy: %s", got) + } + // No env proxy, but NO_PROXY lists the host: direct. + noEnv := envProxy{fn: envDirect, noProxy: "mirror.corp,.internal,10.0.0.0/8"} + for raw, want := range map[string]string{ + "https://mirror.corp/x": "direct", + "https://a.internal/": "direct", + "https://10.1.2.3/": "direct", + "https://api.example/": "machine:2", + "https://x.bypass.corp/": "direct", + "https://notmirror.corp/": "machine:2", + } { + if got := get(noEnv, raw); got != want { + t.Errorf("%s: %s, want %s", raw, got, want) + } + } + // D20: only HTTP_PROXY in the env: an https request still uses the + // WinHTTP machine proxy (the SYSTEM updater only talks https). + httpOnly := envProxy{http: true, fn: viaEnv} + if got := get(httpOnly, "https://api.example/"); got != "machine:2" { + t.Fatalf("https with only HTTP_PROXY set: %s, want the machine proxy", got) + } + if got := get(httpOnly, "http://api.example/"); got != "env-proxy:1" { + t.Fatalf("http with HTTP_PROXY set: %s", got) + } + if got := get(envProxy{fn: envDirect, noProxy: "*"}, "https://api.example/"); got != "direct" { + t.Fatalf("NO_PROXY=* ignored: %s", got) + } + req, _ := http.NewRequest(http.MethodGet, "https://api.example/", nil) + if u, _ := proxyFunc(envProxy{fn: envDirect}, nil)(req); u != nil { + t.Fatalf("proxy without config: %v", u) + } + if NewHTTPClient() == nil { + t.Fatal("nil client") + } +} + +func TestNativeArch(t *testing.T) { + if a := NativeArch(); a != "amd64" && a != "arm64" && a != "386" && a != "riscv64" && a != "s390x" && a != "ppc64le" && a != "loong64" { + t.Fatalf("NativeArch = %q", a) + } +} + +// R-C1-3: an https endpoint redirecting to plain http is a failure, never +// followed, for downloads (GitHub, mirror) and for latest-binary. +func TestRedirectDowngradeRefused(t *testing.T) { + var plainHits atomic.Int32 + plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + plainHits.Add(1) + _, _ = w.Write([]byte("agent-1.20.0")) + })) + defer plain.Close() + redirector := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, plain.URL+r.URL.Path, http.StatusFound) + })) + defer redirector.Close() + + _, c := newReleaseFixture(t) + rel, err := c.Latest(context.Background()) + if err != nil { + t.Fatal(err) + } + c.HTTP = redirector.Client() + c.ReleaseBaseURL = redirector.URL + "/releases" + if err := c.Download(context.Background(), rel.Version, rel.Assets[0], t.TempDir()); !errors.Is(err, ErrDownload) { + t.Fatalf("github redirect to http: %v", err) + } + c.ReleaseBaseURL = redirector.URL + "/gone" + c.MirrorURL = redirector.URL + "/mirror" + if err := c.Download(context.Background(), rel.Version, rel.Assets[0], t.TempDir()); !errors.Is(err, ErrDownload) { + t.Fatalf("mirror redirect to http: %v", err) + } + c.APIEndpoint = redirector.URL + if _, err := c.Latest(context.Background()); !errors.Is(err, ErrCheck) { + t.Fatalf("latest-binary redirect to http: %v", err) + } + if n := plainHits.Load(); n != 0 { + t.Fatalf("followed %d downgrade redirects", n) + } + // An https -> https redirect is still followed. + c2 := *c + _, fc := newReleaseFixture(t) + hop := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, fc.ReleaseBaseURL+strings.TrimPrefix(r.URL.Path, "/hop"), http.StatusFound) + })) + defer hop.Close() + c2.HTTP = fc.HTTP + c2.ReleaseBaseURL = hop.URL + "/hop" + c2.MirrorURL = "" + tr := fc.HTTP.Transport.(*http.Transport).Clone() + tr.TLSClientConfig.RootCAs.AddCert(hop.Certificate()) + c2.HTTP = &http.Client{Transport: tr} + if err := c2.Download(context.Background(), rel.Version, rel.Assets[0], t.TempDir()); err != nil { + t.Fatalf("https redirect: %v", err) + } +} diff --git a/internal/selfupdate/smoke.go b/internal/selfupdate/smoke.go new file mode 100644 index 00000000..1a330315 --- /dev/null +++ b/internal/selfupdate/smoke.go @@ -0,0 +1,92 @@ +package selfupdate + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/updatestate" +) + +// SelfCheckProbeTimeout bounds the self-check's device-id probe; smokeMargin +// is the headroom the smoke limit keeps above it (startup, config load). +const ( + SelfCheckProbeTimeout = 10 * time.Second + smokeMargin = 30 * time.Second +) + +// ErrSmokeTimeout means the self-check didn't answer within the smoke +// limit: retried, not held against the version, until it repeats. +var ErrSmokeTimeout = errors.New("self-check timed out") + +// SelfCheck is the `agent self-check` output (spec Contracts: CLI). +type SelfCheck struct { + Version string `json:"version"` + DeviceID string `json:"device_id"` + ConfigOK bool `json:"config_ok"` +} + +// limitedBuffer keeps at most max bytes. The buffer is a named field so no +// io.ReaderFrom leaks out: io.Copy (os/exec's pipe copier) must go through +// Write. +type limitedBuffer struct { + buf bytes.Buffer + max int +} + +func (b *limitedBuffer) Write(p []byte) (int, error) { + if room := b.max - b.buf.Len(); room > 0 { + if len(p) > room { + b.buf.Write(p[:room]) + } else { + b.buf.Write(p) + } + } + return len(p), nil +} + +func (b *limitedBuffer) Len() int { return b.buf.Len() } +func (b *limitedBuffer) Bytes() []byte { return b.buf.Bytes() } +func (b *limitedBuffer) String() string { return b.buf.String() } + +// RunSmoke runs ` self-check` with the smoke time limit and checks +// that it exits 0 reporting version want, a loaded config and a device id. +func RunSmoke(ctx context.Context, exe string, want updatestate.Version) error { + ctx, cancel := context.WithTimeout(ctx, duration(knobSmokeTimeout)) + defer cancel() + // #nosec G204 -- exe is a verified, manifest-checked version dir binary. + cmd := exec.CommandContext(ctx, exe, "self-check") + stdout := &limitedBuffer{max: 64 << 10} + stderr := &limitedBuffer{max: 4 << 10} + cmd.Stdout, cmd.Stderr = stdout, stderr + cmd.WaitDelay = 5 * time.Second + prepareSmoke(cmd) + if err := runSmokeCmd(cmd); err != nil { + if ctx.Err() != nil { + return fmt.Errorf("%w after %s", ErrSmokeTimeout, duration(knobSmokeTimeout)) + } + return fmt.Errorf("self-check: %w: %s", err, strings.TrimSpace(stderr.String())) + } + return checkSelfCheck(stdout.Bytes(), want) +} + +func checkSelfCheck(out []byte, want updatestate.Version) error { + var sc SelfCheck + if err := json.Unmarshal(bytes.TrimSpace(out), &sc); err != nil { + return fmt.Errorf("self-check output is not JSON: %w", err) + } + switch { + case sc.Version != want.String(): + return fmt.Errorf("self-check reports version %q, want %s", sc.Version, want) + case !sc.ConfigOK: + return fmt.Errorf("self-check could not load the config") + case strings.TrimSpace(sc.DeviceID) == "" || sc.DeviceID == "unknown": + return fmt.Errorf("self-check resolved no device id") + } + return nil +} diff --git a/internal/selfupdate/smoke_test.go b/internal/selfupdate/smoke_test.go new file mode 100644 index 00000000..1dbfef4f --- /dev/null +++ b/internal/selfupdate/smoke_test.go @@ -0,0 +1,102 @@ +package selfupdate + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/proctest" +) + +func TestRunSmoke(t *testing.T) { + t.Setenv(envHelper, "1") + exe := filepath.Join(t.TempDir(), "1.20.0", "agent.exe") + copySelf(t, exe) + ctx := context.Background() + if err := RunSmoke(ctx, exe, mustV("1.20.0")); err != nil { + t.Fatalf("good smoke: %v", err) + } + if err := RunSmoke(ctx, exe, mustV("1.21.0")); err == nil || !strings.Contains(err.Error(), "version") { + t.Fatalf("wrong version: %v", err) + } + t.Setenv("DMG_SU_SMOKE_FAIL", "1.20.0") + if err := RunSmoke(ctx, exe, mustV("1.20.0")); err == nil { + t.Fatal("failing self-check passed") + } + t.Setenv("DMG_SU_SMOKE_FAIL", "") + t.Setenv("DMG_SU_SMOKE_HANG", "1") + knobOverrides = map[knob]time.Duration{knobSmokeTimeout: 2 * time.Second} + defer func() { knobOverrides = map[knob]time.Duration{} }() + start := time.Now() + if err := RunSmoke(ctx, exe, mustV("1.20.0")); !errors.Is(err, ErrSmokeTimeout) { + t.Fatalf("hung self-check: %v", err) + } + if time.Since(start) > 30*time.Second { + t.Fatal("smoke timeout not enforced") + } +} + +func TestCheckSelfCheck(t *testing.T) { + v := mustV("1.20.0") + for out, ok := range map[string]bool{ + `{"version":"1.20.0","device_id":"d","config_ok":true}`: true, + `{"version":"1.20.0","device_id":"","config_ok":true}`: false, + `{"version":"1.20.0","device_id":"unknown","config_ok":true}`: false, + `{"version":"1.20.0","device_id":"d","config_ok":false}`: false, + `not json`: false, + } { + if err := checkSelfCheck([]byte(out), v); (err == nil) != ok { + t.Errorf("%s: err = %v", out, err) + } + } + var b limitedBuffer + b.max = 4 + _, _ = b.Write([]byte("abcdef")) + if b.String() != "abcd" { + t.Fatalf("limitedBuffer = %q", b.String()) + } +} + +// FR1-1: the smoke limit exceeds the self-check's worst-case internal wait +// (its device-id probe) by a clear margin. +func TestSmokeBudgetExceedsSelfCheckWaits(t *testing.T) { + if knobDefaults[knobSmokeTimeout] < SelfCheckProbeTimeout+smokeMargin { + t.Fatalf("smoke %s < self-check probe %s + margin %s", knobDefaults[knobSmokeTimeout], SelfCheckProbeTimeout, smokeMargin) + } +} + +func init() { + // "flood": writes 2 MiB to stdout and stderr. + helperRoles["flood"] = func() int { + chunk := []byte(strings.Repeat("x", 1<<20)) + for i := 0; i < 2; i++ { + _, _ = os.Stdout.Write(chunk) + _, _ = os.Stderr.Write(chunk) + } + return 0 + } +} + +// Copilot 4356483290: os/exec copies a child's pipe with io.Copy, which +// must not bypass the cap through an io.ReaderFrom. +func TestLimitedBufferCapsExecPipe(t *testing.T) { + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cmd := proctest.Command(t, exe) + cmd.Env = append(cmd.Env, envRole+"=flood") + stdout := &limitedBuffer{max: 64 << 10} + stderr := &limitedBuffer{max: 4 << 10} + cmd.Stdout, cmd.Stderr = stdout, stderr + if err := cmd.Run(); err != nil { + t.Fatal(err) + } + if stdout.Len() != 64<<10 || stderr.Len() != 4<<10 { + t.Fatalf("captured stdout %d, stderr %d bytes; want 65536, 4096", stdout.Len(), stderr.Len()) + } +} diff --git a/internal/selfupdate/smoke_windows_test.go b/internal/selfupdate/smoke_windows_test.go new file mode 100644 index 00000000..e95241de --- /dev/null +++ b/internal/selfupdate/smoke_windows_test.go @@ -0,0 +1,44 @@ +//go:build windows + +package selfupdate + +import ( + "context" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +// D15/R-C2-7: a hung self-check and everything it spawned die with the +// smoke's job. +func TestSmokeKillsGrandchildren(t *testing.T) { + t.Setenv(envHelper, "1") + t.Setenv("DMG_SU_SMOKE_HANG", "1") + pidFile := filepath.Join(t.TempDir(), "grandchild.pid") + t.Setenv("DMG_SU_SMOKE_GRANDCHILD", pidFile) + knobOverrides = map[knob]time.Duration{knobSmokeTimeout: 3 * time.Second} + defer func() { knobOverrides = map[knob]time.Duration{} }() + exe := filepath.Join(t.TempDir(), "1.20.0", "agent.exe") + copySelf(t, exe) + if err := RunSmoke(context.Background(), exe, mustV("1.20.0")); err == nil { + t.Fatal("hung smoke passed") + } + b, err := os.ReadFile(pidFile) + if err != nil { + t.Fatalf("grandchild never started: %v", err) + } + pid, _ := strconv.Atoi(string(b)) + h, err := windows.OpenProcess(windows.SYNCHRONIZE|windows.PROCESS_TERMINATE, false, uint32(pid)) + if err != nil { + return // already gone + } + defer func() { _ = windows.CloseHandle(h) }() + if ev, _ := windows.WaitForSingleObject(h, 10_000); ev != windows.WAIT_OBJECT_0 { + _ = windows.TerminateProcess(h, 1) + t.Fatal("the self-check's child outlived the smoke") + } +} diff --git a/internal/selfupdate/sshsig.go b/internal/selfupdate/sshsig.go new file mode 100644 index 00000000..d48d575b --- /dev/null +++ b/internal/selfupdate/sshsig.go @@ -0,0 +1,175 @@ +package selfupdate + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "encoding/binary" + "fmt" + "io" + "strings" +) + +// Dep-free SSHSIG verification (the format `ssh-keygen -Y sign` produces; +// see OpenSSH PROTOCOL.sshsig). The loaders verify release checksums by +// shelling out to `ssh-keygen -Y verify`; the binary verifies the same +// signatures natively so the self-update path needs no external tools — +// stdlib crypto/ed25519 plus hand-rolled wire parsing (AGENTS.md: +// stdlib first). + +const ( + sigArmorBegin = "-----BEGIN SSH SIGNATURE-----" + sigArmorEnd = "-----END SSH SIGNATURE-----" + sshSigMagic = "SSHSIG" + sshSigVersion = 1 + keyTypeEd = "ssh-ed25519" + + // signatureNamespace pins `ssh-keygen -Y sign -n ` — a signature made + // by the same key for any other purpose does not validate here. Must + // match SIGNATURE_NAMESPACE in the loader scripts. + signatureNamespace = "stepsecurity-mdm-checksum" +) + +// releasePublicKeyB64 is the pinned Ed25519 release-signing key: the base64 +// SSH wire blob from the `ssh-ed25519 releases@stepsecurity.io` line +// the loader scripts embed as PUBLIC_KEY_SSH. Comparing wire blobs pins both +// the algorithm and the key in one equality check. +const releasePublicKeyB64 = "AAAAC3NzaC1lZDI1NTE5AAAAILN+WG4lOH/x6MysYOf1oY0PKXLLu9d3ZvQDcvq5Cboi" + +func readSSHString(r *bytes.Reader) ([]byte, error) { + var n uint32 + if err := binary.Read(r, binary.BigEndian, &n); err != nil { + return nil, err + } + if int64(n) > int64(r.Len()) { + return nil, fmt.Errorf("truncated ssh string (%d > %d remaining)", n, r.Len()) + } + b := make([]byte, n) + if _, err := io.ReadFull(r, b); err != nil { + return nil, err + } + return b, nil +} + +func appendSSHString(dst, s []byte) []byte { + var n [4]byte + // #nosec G115 -- inputs are protocol strings we compose ourselves + // (namespace, hash name, a 64-byte digest), always far below uint32. + binary.BigEndian.PutUint32(n[:], uint32(len(s))) + return append(append(dst, n[:]...), s...) +} + +// verifySSHSig checks that `armored` is a valid SSHSIG over `message`, made +// by the key whose SSH wire blob base64-encodes to allowedKeyB64, in the +// given namespace. Errors are diagnostic (safe to log; carry no secrets). +func verifySSHSig(armored string, message []byte, allowedKeyB64, namespace string) error { + beg := strings.Index(armored, sigArmorBegin) + end := strings.Index(armored, sigArmorEnd) + // The END marker must start at or after the END of the BEGIN marker — + // the two markers share the "-----" run, so a crafted blob like + // "-----BEGIN SSH SIGNATUREEND SSH SIGNATURE-----" can make END match + // inside BEGIN's tail; slicing with that index would panic. + if beg == -1 || end == -1 || end < beg+len(sigArmorBegin) { + return fmt.Errorf("not an armored SSH signature block") + } + b64 := strings.Map(func(r rune) rune { + if r == '\n' || r == '\r' || r == ' ' || r == '\t' { + return -1 + } + return r + }, armored[beg+len(sigArmorBegin):end]) + blob, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return fmt.Errorf("decode signature body: %w", err) + } + + r := bytes.NewReader(blob) + magic := make([]byte, len(sshSigMagic)) + if _, err := io.ReadFull(r, magic); err != nil || string(magic) != sshSigMagic { + return fmt.Errorf("missing SSHSIG magic preamble") + } + var version uint32 + if err := binary.Read(r, binary.BigEndian, &version); err != nil || version != sshSigVersion { + return fmt.Errorf("unsupported SSHSIG version %d", version) + } + pubkeyBlob, err := readSSHString(r) + if err != nil { + return fmt.Errorf("read public key: %w", err) + } + ns, err := readSSHString(r) + if err != nil { + return fmt.Errorf("read namespace: %w", err) + } + reserved, err := readSSHString(r) + if err != nil { + return fmt.Errorf("read reserved: %w", err) + } + hashAlg, err := readSSHString(r) + if err != nil { + return fmt.Errorf("read hash algorithm: %w", err) + } + sigBlob, err := readSSHString(r) + if err != nil { + return fmt.Errorf("read signature: %w", err) + } + + if string(ns) != namespace { + return fmt.Errorf("signature namespace %q, want %q", ns, namespace) + } + allowed, err := base64.StdEncoding.DecodeString(allowedKeyB64) + if err != nil { + return fmt.Errorf("decode pinned key: %w", err) + } + if !bytes.Equal(pubkeyBlob, allowed) { + return fmt.Errorf("signing key is not the pinned release key") + } + + // Pubkey wire: string "ssh-ed25519" || string key(32). + pr := bytes.NewReader(pubkeyBlob) + keyType, err := readSSHString(pr) + if err != nil || string(keyType) != keyTypeEd { + return fmt.Errorf("unsupported key type %q", keyType) + } + key, err := readSSHString(pr) + if err != nil || len(key) != ed25519.PublicKeySize { + return fmt.Errorf("malformed ed25519 public key") + } + + // Signature wire: string "ssh-ed25519" || string sig(64). + sr := bytes.NewReader(sigBlob) + sigType, err := readSSHString(sr) + if err != nil || string(sigType) != keyTypeEd { + return fmt.Errorf("unsupported signature type %q", sigType) + } + sig, err := readSSHString(sr) + if err != nil || len(sig) != ed25519.SignatureSize { + return fmt.Errorf("malformed ed25519 signature") + } + + var hashed []byte + switch string(hashAlg) { + case "sha256": + h := sha256.Sum256(message) + hashed = h[:] + case "sha512": + h := sha512.Sum512(message) + hashed = h[:] + default: + return fmt.Errorf("unsupported hash algorithm %q", hashAlg) + } + + // Signed blob per PROTOCOL.sshsig: MAGIC || namespace || reserved || + // hash_algorithm || H(message), each as an ssh string except the magic. + signed := []byte(sshSigMagic) + signed = appendSSHString(signed, ns) + signed = appendSSHString(signed, reserved) + signed = appendSSHString(signed, hashAlg) + signed = appendSSHString(signed, hashed) + + if !ed25519.Verify(ed25519.PublicKey(key), signed, sig) { + return fmt.Errorf("ed25519 signature verification failed") + } + return nil +} diff --git a/internal/selfupdate/sshsig_sign_test.go b/internal/selfupdate/sshsig_sign_test.go new file mode 100644 index 00000000..f4c706d3 --- /dev/null +++ b/internal/selfupdate/sshsig_sign_test.go @@ -0,0 +1,68 @@ +package selfupdate + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha512" + "encoding/base64" + "strings" + "testing" +) + +// testSigner produces SSHSIG signatures like `ssh-keygen -Y sign -n +// stepsecurity-mdm-checksum` with a throwaway key. +type testSigner struct { + priv ed25519.PrivateKey + pubB64 string +} + +func newTestSigner(t testing.TB) *testSigner { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + blob := appendSSHString(appendSSHString(nil, []byte(keyTypeEd)), pub) + return &testSigner{priv: priv, pubB64: base64.StdEncoding.EncodeToString(blob)} +} + +// armored signs msg in namespace ns. +func (s *testSigner) armored(msg, ns string) string { + pubBlob, _ := base64.StdEncoding.DecodeString(s.pubB64) + h := sha512.Sum512([]byte(msg)) + signed := []byte(sshSigMagic) + signed = appendSSHString(signed, []byte(ns)) + signed = appendSSHString(signed, nil) + signed = appendSSHString(signed, []byte("sha512")) + signed = appendSSHString(signed, h[:]) + sig := ed25519.Sign(s.priv, signed) + sigBlob := appendSSHString(appendSSHString(nil, []byte(keyTypeEd)), sig) + + blob := []byte(sshSigMagic) + blob = append(blob, 0, 0, 0, 1) + blob = appendSSHString(blob, pubBlob) + blob = appendSSHString(blob, []byte(ns)) + blob = appendSSHString(blob, nil) + blob = appendSSHString(blob, []byte("sha512")) + blob = appendSSHString(blob, sigBlob) + b64 := base64.StdEncoding.EncodeToString(blob) + var lines []string + for len(b64) > 70 { + lines = append(lines, b64[:70]) + b64 = b64[70:] + } + lines = append(lines, b64) + return sigArmorBegin + "\n" + strings.Join(lines, "\n") + "\n" + sigArmorEnd + "\n" +} + +// transport wraps the armored block the way latest-binary does. +func (s *testSigner) transport(checksum string) string { + return base64.StdEncoding.EncodeToString([]byte(s.armored(checksum, signatureNamespace))) +} + +func TestTestSignerRoundTrip(t *testing.T) { + s := newTestSigner(t) + if err := verifySSHSig(s.armored("abc", signatureNamespace), []byte("abc"), s.pubB64, signatureNamespace); err != nil { + t.Fatalf("own signature rejected: %v", err) + } +} diff --git a/internal/selfupdate/sshsig_test.go b/internal/selfupdate/sshsig_test.go new file mode 100644 index 00000000..fd5471d6 --- /dev/null +++ b/internal/selfupdate/sshsig_test.go @@ -0,0 +1,83 @@ +package selfupdate + +import ( + "strings" + "testing" +) + +// Fixtures generated with a throwaway ed25519 key: +// +// ssh-keygen -t ed25519 -f testkey -N "" -C test@fixture +// printf '%s' "" > msg && ssh-keygen -Y sign -f testkey -n stepsecurity-mdm-checksum msg +const ( + fixtureKeyB64 = "AAAAC3NzaC1lZDI1NTE5AAAAIPyrd8mRqV8sf32kNxdbqNcM7O6nnkpZBZuUkRzgV4Vi" + + fixtureMessage = "b9c566a0abef0c004c8550a4c06e7702c1ee4cedeb98a441b4362a8d38d8a215" + fixtureSig = `-----BEGIN SSH SIGNATURE----- +U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg/Kt3yZGpXyx/faQ3F1uo1wzs7q +eeSlkFm5SRHOBXhWIAAAAZc3RlcHNlY3VyaXR5LW1kbS1jaGVja3N1bQAAAAAAAAAGc2hh +NTEyAAAAUwAAAAtzc2gtZWQyNTUxOQAAAEA54k4Rj3+JfaX1jh5KQF2RaWrtSxeQBDPCBt +1ONaiZ63fUUNmX2tH3KkkJo4Ul/v/GhTS+zCZI/tT8FWeGjG4D +-----END SSH SIGNATURE-----` +) + +func TestVerifySSHSig(t *testing.T) { + tests := []struct { + name string + armored string + message string + key string + namespace string + wantErr string // substring; "" = must verify + }{ + { + name: "valid signature verifies", armored: fixtureSig, + message: fixtureMessage, key: fixtureKeyB64, namespace: signatureNamespace, + }, + { + name: "wrong namespace rejected", armored: fixtureSig, + message: fixtureMessage, key: fixtureKeyB64, namespace: "other-namespace", + wantErr: "namespace", + }, + { + name: "signature by non-pinned key rejected", armored: fixtureSig, + message: fixtureMessage, key: releasePublicKeyB64, namespace: signatureNamespace, + wantErr: "not the pinned release key", + }, + { + name: "tampered message rejected", armored: fixtureSig, + message: fixtureMessage[:len(fixtureMessage)-1] + "0", key: fixtureKeyB64, namespace: signatureNamespace, + wantErr: "verification failed", + }, + { + name: "garbage armor rejected", armored: "base64 -- Encode/decode file as base64. Call:", + message: fixtureMessage, key: fixtureKeyB64, namespace: signatureNamespace, + wantErr: "not an armored SSH signature", + }, + { + name: "truncated blob rejected", armored: fixtureSig[:120] + "\n-----END SSH SIGNATURE-----", + message: fixtureMessage, key: fixtureKeyB64, namespace: signatureNamespace, + wantErr: "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := verifySSHSig(tc.armored, []byte(tc.message), tc.key, tc.namespace) + if tc.name == "truncated blob rejected" { + if err == nil { + t.Fatal("truncated blob verified, want any error") + } + return + } + if tc.wantErr == "" { + if err != nil { + t.Fatalf("verify error: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("error = %v, want substring %q", err, tc.wantErr) + } + }) + } +} diff --git a/internal/selfupdate/system_other.go b/internal/selfupdate/system_other.go new file mode 100644 index 00000000..44be0478 --- /dev/null +++ b/internal/selfupdate/system_other.go @@ -0,0 +1,31 @@ +//go:build !windows + +package selfupdate + +import ( + "os/exec" + + "golang.org/x/sys/unix" +) + +func prepareSmoke(*exec.Cmd) {} + +func runSmokeCmd(cmd *exec.Cmd) error { return cmd.Run() } + +// EnsureConfigACL is Windows-only; elsewhere config.json keeps its mode. +func EnsureConfigACL(string) (bool, error) { return false, nil } + +// SetRunningVersion is Windows-only (HKLM RunningVersion). +func SetRunningVersion(string) error { return nil } + +// FreeBytes reports the bytes available to this process on path's volume. +func FreeBytes(path string) (uint64, error) { + var s unix.Statfs_t + if err := unix.Statfs(path, &s); err != nil { + return 0, err + } + return uint64(s.Bavail) * uint64(s.Bsize), nil // #nosec G115 -- statfs sizes are non-negative +} + +// InstallerBusy is Windows-only. +func InstallerBusy() bool { return false } diff --git a/internal/selfupdate/system_windows.go b/internal/selfupdate/system_windows.go new file mode 100644 index 00000000..d99fd788 --- /dev/null +++ b/internal/selfupdate/system_windows.go @@ -0,0 +1,169 @@ +//go:build windows + +package selfupdate + +import ( + "fmt" + "os" + "os/exec" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + + "github.com/step-security/dev-machine-guard/internal/winproc" +) + +func prepareSmoke(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW} +} + +// runSmokeCmd runs the self-check inside a kill-on-close job (spec S5 "in a +// job"): the child starts suspended, joins the job, then resumes, so +// nothing it spawns escapes; a timeout terminates the whole job, and +// closing the job on return (or on our own death) kills any leftovers. +func runSmokeCmd(cmd *exec.Cmd) error { + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + return fmt.Errorf("smoke job: %w", err) + } + defer func() { _ = windows.CloseHandle(job) }() + info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{ + BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE}, + } + if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, + uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil { + return fmt.Errorf("smoke job limits: %w", err) + } + cmd.Cancel = func() error { + if windows.TerminateJobObject(job, 1) == nil { + return nil + } + return cmd.Process.Kill() + } + if err := winproc.StartInJob(cmd, job); err != nil { + return fmt.Errorf("smoke job: %w", err) + } + return cmd.Wait() +} + +// configFileSDDL matches `configure`'s icacls hardening: SYSTEM F, +// Administrators F, Users R, inheritance off. +const configFileSDDL = "D:PAI(A;;FA;;;SY)(A;;FA;;;BA)(A;;FR;;;BU)" + +const ( + maskFullAccess = 0x001F01FF + maskRead = 0x00120089 +) + +// EnsureConfigACL re-asserts the machine config.json DACL (D4: the SYSTEM +// updater trusts config.json only because users can't write it). It +// reports whether it had drifted. A missing file is not drift. +func EnsureConfigACL(path string) (bool, error) { + fi, err := os.Lstat(path) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + if !fi.Mode().IsRegular() { + return false, fmt.Errorf("%s is not a regular file", path) + } + if ok, err := hasConfigACL(path); err != nil || ok { + return false, err + } + sd, err := windows.SecurityDescriptorFromString(configFileSDDL) + if err != nil { + return false, err + } + dacl, _, err := sd.DACL() + if err != nil { + return false, err + } + if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, nil, dacl, nil); err != nil { + return true, fmt.Errorf("set ACL on %s: %w", path, err) + } + return true, nil +} + +func hasConfigACL(path string) (bool, error) { + sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + return false, fmt.Errorf("read ACL of %s: %w", path, err) + } + control, _, err := sd.Control() + if err != nil || control&windows.SE_DACL_PROTECTED == 0 { + return false, nil + } + acl, _, err := sd.DACL() + if err != nil || acl == nil || acl.AceCount != 3 { + return false, nil + } + want := map[windows.WELL_KNOWN_SID_TYPE]windows.ACCESS_MASK{ + windows.WinLocalSystemSid: maskFullAccess, + windows.WinBuiltinAdministratorsSid: maskFullAccess, + windows.WinBuiltinUsersSid: maskRead, + } + for i := uint32(0); i < uint32(acl.AceCount); i++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(acl, i, &ace); err != nil || ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + return false, nil + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + matched := false + for kind, mask := range want { + if sid.IsWellKnown(kind) && ace.Mask == mask { + delete(want, kind) + matched = true + break + } + } + if !matched { + return false, nil + } + } + return len(want) == 0, nil +} + +// SetRunningVersion writes HKLM\Software\StepSecurity\RunningVersion. The +// MSI-owned AgentVersion value is never touched. +func SetRunningVersion(v string) error { + k, _, err := registry.CreateKey(registry.LOCAL_MACHINE, `Software\StepSecurity`, + registry.SET_VALUE|registry.WOW64_64KEY) + if err != nil { + return err + } + defer k.Close() + return k.SetStringValue("RunningVersion", v) +} + +// FreeBytes reports the bytes available to this process on path's volume. +func FreeBytes(path string) (uint64, error) { + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + var avail, total, free uint64 + if err := windows.GetDiskFreeSpaceEx(p, &avail, &total, &free); err != nil { + return 0, err + } + return avail, nil +} + +// InstallerBusy reports a Windows Installer transaction in progress: the +// Global\_MSIExecute mutex exists while msiexec runs an install. +func InstallerBusy() bool { + name, err := windows.UTF16PtrFromString(`Global\_MSIExecute`) + if err != nil { + return false + } + h, err := windows.OpenMutex(windows.SYNCHRONIZE, false, name) + if err != nil { + return false + } + _ = windows.CloseHandle(h) + return true +} diff --git a/internal/selfupdate/system_windows_test.go b/internal/selfupdate/system_windows_test.go new file mode 100644 index 00000000..f1b6c219 --- /dev/null +++ b/internal/selfupdate/system_windows_test.go @@ -0,0 +1,41 @@ +//go:build windows + +package selfupdate + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestEnsureConfigACL(t *testing.T) { + p := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(p, []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + if fixed, err := EnsureConfigACL(filepath.Join(t.TempDir(), "missing.json")); err != nil || fixed { + t.Fatalf("missing file: fixed=%v err=%v", fixed, err) + } + // A freshly created temp file inherits a non-protected DACL: drift. + fixed, err := EnsureConfigACL(p) + if err != nil || !fixed { + t.Fatalf("first pass: fixed=%v err=%v", fixed, err) + } + if fixed, err := EnsureConfigACL(p); err != nil || fixed { + t.Fatalf("second pass: fixed=%v err=%v", fixed, err) + } + // Users gaining Modify is drift again. + if out, err := exec.Command("icacls", p, "/grant", "*S-1-5-32-545:M").CombinedOutput(); err != nil { + t.Skipf("icacls: %v %s", err, out) + } + if fixed, err := EnsureConfigACL(p); err != nil || !fixed { + t.Fatalf("after grant: fixed=%v err=%v", fixed, err) + } +} + +func TestFreeBytesWindows(t *testing.T) { + if n, err := FreeBytes(t.TempDir()); err != nil || n == 0 { + t.Fatalf("FreeBytes = %d, %v", n, err) + } +} diff --git a/internal/selfupdate/testkeys_off.go b/internal/selfupdate/testkeys_off.go new file mode 100644 index 00000000..bc61a998 --- /dev/null +++ b/internal/selfupdate/testkeys_off.go @@ -0,0 +1,13 @@ +//go:build !selfupdate_testkeys + +package selfupdate + +import "time" + +// Release builds: every test-key and time-knob override is absent, so none +// of the DMG_TEST_* names exist in the binary. + +func testSSHSIGKey() string { return "" } +func testReleaseBaseURL() string { return "" } +func testAllowInsecureMirror() bool { return false } +func testDuration(knob) (time.Duration, bool) { return 0, false } diff --git a/internal/selfupdate/testkeys_on.go b/internal/selfupdate/testkeys_on.go new file mode 100644 index 00000000..7d37294c --- /dev/null +++ b/internal/selfupdate/testkeys_on.go @@ -0,0 +1,50 @@ +//go:build selfupdate_testkeys + +package selfupdate + +import ( + "os" + "strings" + "time" +) + +// Test-key builds (spec Contracts, build tag selfupdate_testkeys) take the +// SSHSIG key, the GitHub release base and the time constants from DMG_TEST_* +// env. Release builds use testkeys_off.go. + +// testSSHSIGKey accepts the base64 wire blob or a whole +// "ssh-ed25519 [comment]" public-key line. +func testSSHSIGKey() string { + v := strings.TrimSpace(os.Getenv("DMG_TEST_SSHSIG_PUBKEY")) + if f := strings.Fields(v); len(f) >= 2 && f[0] == keyTypeEd { + return f[1] + } + return v +} + +func testReleaseBaseURL() string { return strings.TrimSpace(os.Getenv("DMG_TEST_RELEASE_BASE_URL")) } + +// testAllowInsecureMirror lets a fake mirror on http://127.0.0.1 serve assets. +func testAllowInsecureMirror() bool { return os.Getenv("DMG_TEST_ALLOW_HTTP_MIRROR") == "1" } + +var knobEnv = map[knob]string{ + knobProbationWindow: "DMG_TEST_PROBATION_WINDOW", + knobSwitchWait: "DMG_TEST_SWITCH_WAIT", + knobDedupeWindow: "DMG_TEST_DEDUPE_WINDOW", + knobCheckInterval: "DMG_TEST_CHECK_INTERVAL", + knobSmokeTimeout: "DMG_TEST_SMOKE_TIMEOUT", + knobSendBackoff: "DMG_TEST_SEND_BACKOFF", +} + +// testDuration reads a Go duration knob such as DMG_TEST_PROBATION_WINDOW. +func testDuration(k knob) (time.Duration, bool) { + v := strings.TrimSpace(os.Getenv(knobEnv[k])) + if v == "" { + return 0, false + } + d, err := time.ParseDuration(v) + if err != nil || d < 0 { + return 0, false + } + return d, true +} diff --git a/internal/selfupdate/updater.go b/internal/selfupdate/updater.go new file mode 100644 index 00000000..d6b0886e --- /dev/null +++ b/internal/selfupdate/updater.go @@ -0,0 +1,1057 @@ +package selfupdate + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" + "github.com/step-security/dev-machine-guard/internal/faultinject" + "github.com/step-security/dev-machine-guard/internal/updatestate" +) + +// Spec constants (§3/§4, D5). +const ( + badRunsToRollback = 2 // K + inconclusiveMinRuns = 6 // D5 + switchDeferralsReport = 4 // update_switch_deferred after this many + updaterFailureLimit = 3 // update_updater_failed threshold + maxOutbox = 64 // pending events kept (oldest dropped) + maxSendBackoff = 24 * time.Hour + inconclusiveRetry = 7 * 24 * time.Hour + smokeTimeoutLimit = 3 // consecutive smoke timeouts before quarantine + freeSpaceMargin = 50 << 20 + runTimeout = 25 * time.Minute // under the task's 30 min limit + smokeReserveMargin = 30 * time.Second // on top of the smoke timeout +) + +// Event names (spec §7). +const ( + EventOK = "update_ok" + EventConfirmed = "update_confirmed" + EventRolledBack = "update_rolled_back" + EventDownloadFailed = "update_download_failed" + EventVerifyFailed = "update_verify_failed" + EventStageFailed = "update_stage_failed" + EventHealthFailed = "update_health_failed" + EventSwitchFailed = "update_switch_failed" + EventSwitchDeferred = "update_switch_deferred" + EventCheckFailed = "update_check_failed" + EventRefusedFloor = "update_refused_floor" + EventDisabled = "update_disabled" + EventPointerInvalid = "update_pointer_invalid" + EventACLDrift = "update_acl_drift" + EventStateCorrupt = "update_state_corrupt" + EventUpdaterFailed = "update_updater_failed" +) + +// Rollback and quarantine reasons (error_message of update_rolled_back). +const ( + ReasonBadRuns = "bad_runs" + ReasonSmokeFailed = "smoke_failed" + ReasonPointerInvalid = "pointer_invalid" + ReasonInconclusive = "inconclusive" +) + +// ErrEventRejected marks an event the backend refused (4xx); it is dropped +// rather than retried. +var ErrEventRejected = errors.New("update event rejected by the backend") + +// Deps are the updater's side effects, injected so the state machine runs +// unchanged in tests and as SYSTEM. +type Deps struct { + Layout updatestate.Layout + Self updatestate.Version // version of this process + SelfExe string + Client *Client + // AutoUpdate=false still checks and reports update_disabled. + AutoUpdate bool + // VerifyPublisher checks a staged exe's Authenticode signature and + // publisher (authenticode.SamePublisher against SelfExe). + VerifyPublisher func(path string) error + // Smoke runs ` self-check` and checks it reports version want. + Smoke func(ctx context.Context, exe string, want updatestate.Version) error + // AcquireInstance takes the scan instance mutex without blocking. + AcquireInstance func() (release func(), err error) + // EnsureACLs re-asserts versions\, state\ and config.json ACLs and + // returns what had drifted. + EnsureACLs func() (drifted []string, err error) + // RefreshDeviceID probes the hardware as SYSTEM and rewrites the + // device-id file (M-9). + RefreshDeviceID func(ctx context.Context) + // Send posts one update event. + Send func(ctx context.Context, ev updatestate.OutboxEvent) error + // SetRunningVersion records HKLM RunningVersion (inventory only). + SetRunningVersion func(v string) + // VerifySeed checks an MSI seed's executables (authenticode + // SameSeedPublisher); nil falls back to VerifyPublisher. + VerifySeed func(path string) error + // InstallerBusy reports a Windows Installer transaction in progress + // (Global\_MSIExecute held); nil means never. + InstallerBusy func() bool + // FreeBytes reports free space on the volume holding path. + FreeBytes func(path string) (uint64, error) + Now func() time.Time + Logf func(format string, args ...any) +} + +// Exit codes. Non-zero tells the bootstrap this updater failed (three in a +// row make it fall back to the seed's updater). +const ( + ExitOK = 0 + ExitFailed = 1 +) + +type tick struct { + d *Deps + L updatestate.Layout + st *updatestate.State + now time.Time + seeds []updatestate.Version + // diskIDs are the execution_ids of outbox events loaded from disk; + // target is this tick's latest-binary target ("" before S3). + diskIDs map[string]bool + target string +} + +// directSendNS namespaces the deterministic ids of directly sent events. +var directSendNS = uuid.MustParse("6f1c2d3e-8a4b-5c6d-9e0f-a1b2c3d4e5f6") + +// stagedKeep is how long GC keeps a verified, not-yet-switched staged +// version newer than current, so a tick that couldn't record its progress +// doesn't download the release again. +const stagedKeep = 24 * time.Hour + +// checkpoint is faultinject.Point; a var so unit tests can observe order. +var checkpoint = faultinject.Point + +// Run executes one updater tick (S0–S7). It never panics on bad state and +// always leaves a state the next tick converges from. +func Run(ctx context.Context, d *Deps) int { + ctx, cancel := context.WithTimeout(ctx, runTimeout) + defer cancel() + t := &tick{d: d, L: d.Layout, now: d.Now()} + + // S0 Lock. Creating state\ the first time is not ACL drift; existing + // dirs are re-asserted in S1. + if _, err := os.Stat(t.L.StateDir()); errors.Is(err, os.ErrNotExist) { + if _, err := updatestate.EnsureProtectedDir(t.L.StateDir()); err != nil { + d.Logf("self-update: create %s: %v", t.L.StateDir(), err) + return ExitFailed + } + } + lk, err := updatestate.AcquireLock(t.L.LockFile()) + if errors.Is(err, updatestate.ErrLocked) { + d.Logf("self-update: another updater holds the lock; exiting") + return ExitOK + } + if err != nil { + d.Logf("self-update: lock: %v", err) + return ExitFailed + } + defer lk.Release() + if err := checkpoint("S0.locked"); err != nil { + return t.fail(err) + } + if err := t.load(); err != nil { + d.Logf("self-update: read state: %v", err) + return ExitFailed + } + + // S1 Sweep. + t.sweep(ctx) + if err := checkpoint("S1.swept"); err != nil { + return t.finish(ctx, ExitFailed) + } + + // S2 Health. + closed, err := t.health(ctx) + if err != nil { + return t.finish(ctx, ExitFailed) + } + if err := checkpoint("S2.evaluated"); err != nil { + return t.finish(ctx, ExitFailed) + } + if err := t.adoptNewerSeed(); err != nil { + return t.finish(ctx, ExitFailed) + } + + return t.update(ctx, closed) +} + +func (t *tick) fail(err error) int { + t.d.Logf("self-update: %v", err) + return ExitFailed +} + +// load reads state.json; missing or corrupt state is rebuilt on the seed +// (or this updater's own version) and reported. +func (t *tick) load() error { + t.seeds = seedVersions(t.L) + st, corrupt, err := updatestate.Load(t.L.StateFile()) + if err != nil { + return err + } + if st != nil { + t.st = st + normalize(st) + t.diskIDs = map[string]bool{} + for _, ev := range st.Outbox { + t.diskIDs[ev.ExecutionID] = true + } + return nil + } + cur := t.d.Self + if len(t.seeds) > 0 { + cur = t.seeds[len(t.seeds)-1] + } + t.st = &updatestate.State{Schema: updatestate.SchemaVersion, Current: cur.String()} + normalize(t.st) + reason := "state.json missing" + if corrupt { + reason = "state.json unreadable or of an unknown schema" + } + t.d.Logf("self-update: %s; rebuilding on %s", reason, cur) + t.enqueue(EventStateCorrupt, "", cur.String(), reason, EventStateCorrupt) + return nil +} + +func normalize(st *updatestate.State) { + if st.Quarantine == nil { + st.Quarantine = map[string]updatestate.Quarantine{} + } + if st.Dedupe == nil { + st.Dedupe = map[string]time.Time{} + } +} + +// seedVersions is the MSI seed the bootstrap would fall back to +// (updatestate.Layout.FindSeed: the newest seed\ with an agent and a +// launcher), as a slice for the keep set. +func seedVersions(L updatestate.Layout) []updatestate.Version { + if v, ok := L.FindSeed(); ok { + return []updatestate.Version{v} + } + return nil +} + +func (t *tick) dedupeWindow() time.Duration { return duration(knobDedupeWindow) } + +// enqueue adds an event to the outbox unless dedupeKey was used within the +// dedupe window. An empty key never dedupes. +func (t *tick) enqueue(event, from, to, msg, dedupeKey string) { + enqueueTo(t.st, t.now, t.dedupeWindow(), event, from, to, msg, dedupeKey) +} + +func enqueueTo(st *updatestate.State, now time.Time, window time.Duration, event, from, to, msg, dedupeKey string) { + if dedupeKey != "" { + if last, ok := st.Dedupe[dedupeKey]; ok && now.Sub(last) < window && !now.Before(last) { + return + } + st.Dedupe[dedupeKey] = now + } + if len(msg) > 2048 { + msg = msg[:2048] + } + st.Outbox = append(st.Outbox, updatestate.OutboxEvent{ + ExecutionID: uuid.NewString(), Event: event, From: from, To: to, Error: msg, At: now.UTC(), + }) + if n := len(st.Outbox) - maxOutbox; n > 0 { + st.Outbox = st.Outbox[n:] + st.OutboxDropped += uint64(n) // #nosec G115 -- n > 0 + } +} + +// sweep is S1: temp/staging/trash cleanup, GC, ACLs, device id, dedupe and +// quarantine expiry. Never fatal. +func (t *tick) sweep(ctx context.Context) { + updatestate.SweepTemps(t.L.StateFile()) + // An MSI that ended without its adopt-seed commit or rollback action. + updatestate.SweepStaleAdoptSnapshot(t.L.AdoptSnapshotFile()) + if t.d.EnsureACLs != nil { + drifted, err := t.d.EnsureACLs() + if err != nil { + t.d.Logf("self-update: ACL re-assert: %v", err) + } + if len(drifted) > 0 { + t.enqueue(EventACLDrift, "", "", "ACL drift fixed on: "+strings.Join(drifted, ", "), EventACLDrift) + } + } + t.gc() + if t.d.RefreshDeviceID != nil { + t.d.RefreshDeviceID(ctx) + } + for k, at := range t.st.Dedupe { + if t.now.Sub(at) > 2*t.dedupeWindow() || at.After(t.now.Add(48*time.Hour)) { + delete(t.st.Dedupe, k) + } + } + for v, q := range t.st.Quarantine { + if limit := t.now.Add(inconclusiveRetry); q.Until.After(limit) { + // Set while the clock was ahead: never longer than D5's 7 days. + q.Until = limit.UTC() + t.st.Quarantine[v] = q + } + if !q.Until.IsZero() && t.now.After(q.Until) { + t.d.Logf("self-update: quarantine of %s expired", v) + delete(t.st.Quarantine, v) + } + } +} + +// reportUpdaterFailure runs only after this tick reached latest-binary: a +// fallback updater that can reach the backend shows the primary's failures +// were its own, not an outage. Otherwise the counter waits for a later tick. +func (t *tick) reportUpdaterFailure() { + if u := t.st.Updater; u.ConsecutiveFailures >= updaterFailureLimit && u.Version != "" && u.Version != t.d.Self.String() { + t.enqueue(EventUpdaterFailed, u.Version, t.d.Self.String(), + fmt.Sprintf("updater %s failed %d times in a row; running the %s updater", u.Version, u.ConsecutiveFailures, t.d.Self), + EventUpdaterFailed+":"+u.Version) + } +} + +const ( + restageLimit = 3 // restages of one version within restageWindow + restageWindow = 24 * time.Hour // ... before a restageWindow pause +) + +// restagePaused reports whether restaging v is paused; an expired pause is +// cleared. +func (t *tick) restagePaused(v string) bool { + until, ok := t.st.RestageBackoff[v] + if !ok { + return false + } + if t.now.Before(until) { + t.d.Logf("self-update: %s keeps losing files; restaging paused until %s", v, until.Format(time.RFC3339)) + return true + } + delete(t.st.RestageBackoff, v) + return false +} + +// countRestage records a restage of v; the restageLimit-th within +// restageWindow pauses further restages for restageWindow and reports one +// update_stage_failed (av_deleted). Not a quarantine: the version is fine, +// something keeps deleting its files; the bootstrap runs the fallback. +func (t *tick) countRestage(v string) { + if t.st.Restages == nil { + t.st.Restages = map[string]updatestate.Restage{} + } + r := t.st.Restages[v] + if r.Count == 0 || t.now.Sub(r.First) > restageWindow { + r = updatestate.Restage{First: t.now.UTC()} + } + r.Count++ + r.Last = t.now.UTC() + t.st.Restages[v] = r + if r.Count < restageLimit { + return + } + if t.st.RestageBackoff == nil { + t.st.RestageBackoff = map[string]time.Time{} + } + t.st.RestageBackoff[v] = t.now.Add(restageWindow).UTC() + t.enqueue(EventStageFailed, v, v, "av_deleted", EventStageFailed+":av_deleted:"+v) +} + +// resetIntactRestages forgets the restage count of a current version that +// has stayed intact for restageWindow since its last restage. +func (t *tick) resetIntactRestages(currentValid bool) { + r, ok := t.st.Restages[t.st.Current] + if ok && currentValid && t.now.Sub(r.Last) >= restageWindow { + delete(t.st.Restages, t.st.Current) + } +} + +// adoptNewerSeed does what the MSI's adopt-seed would, for a newer seed +// that adopt-seed couldn't adopt (it gives up after a bounded wait for the +// update lock): with no probation open and the seed newer than current and +// not quarantined, copy it into versions\ and make it current in one +// write that also carries update_ok (error_message seed_adopted). The copy +// and the pointer rule are updatestate's, shared with adopt-seed. +func (t *tick) adoptNewerSeed() error { + seed, ok := t.L.FindSeed() + cur, err := updatestate.ParseVersion(t.st.Current) + if !ok || err != nil || t.st.Probation != nil || updatestate.Compare(seed, cur) <= 0 || t.quarantined(seed.String()) { + return nil + } + if t.d.InstallerBusy != nil && t.d.InstallerBusy() { + t.d.Logf("self-update: Windows Installer is running; seed %s adoption waits for the next tick", seed) + return nil + } + verify := t.d.VerifySeed + if verify == nil { + verify = t.d.VerifyPublisher + } + note, err := t.L.CopySeedVersion(seed, t.now, verify) + if err != nil { + t.d.Logf("self-update: copying seed %s: %v", seed, err) + return nil // retried next tick; the seed stays the bootstrap's fallback + } + if note != "" { + t.d.Logf("self-update: %s", note) + } + if err := updatestate.VerifyVersionDir(t.L.VersionDir(seed), seed); err != nil { + t.d.Logf("self-update: versions\\%s unusable after copying the seed: %v", seed, err) + return nil + } + next := cloneState(t.st) + changed, why := t.L.AdoptSeedRule(next, seed, t.now) + if !changed { + return nil + } + enqueueTo(next, t.now, t.dedupeWindow(), EventOK, t.st.Current, seed.String(), "seed_adopted", "") + t.d.Logf("self-update: %s", why) + if err := t.save(next, "adopt"); err != nil { + return err + } + t.setRunning(seed.String()) + return nil +} + +// keep is the set of version dirs GC must not touch. +func (t *tick) keep() map[string]bool { + k := map[string]bool{t.st.Current: true, t.d.Self.String(): true} + if t.st.Previous != "" { + k[t.st.Previous] = true + } + if p := t.st.Probation; p != nil { + k[p.Version] = true + } + if t.st.PendingSwitch != "" { + k[t.st.PendingSwitch] = true // staged, waiting for the scan mutex + } + for _, s := range t.seeds { + k[s.String()] = true + } + return k +} + +// gc deletes staging and trash dirs and moves unneeded version dirs to trash +// before deleting them. A dir in use (AV, running image) fails to rename or +// delete and is retried next tick. +func (t *tick) gc() { + entries, err := os.ReadDir(t.L.VersionsDir()) + if err != nil { + return + } + keep := t.keep() + if v := t.recentStaged(entries); v != "" { + keep[v] = true + } + for _, e := range entries { + p := filepath.Join(t.L.VersionsDir(), e.Name()) + if updatestate.IsSweepable(e.Name()) { + if err := os.RemoveAll(p); err != nil { + t.d.Logf("self-update: sweep %s: %v (retry next tick)", e.Name(), err) + } + continue + } + if _, err := updatestate.ParseVersion(e.Name()); err != nil || keep[e.Name()] || !e.IsDir() { + continue + } + trash := filepath.Join(t.L.VersionsDir(), updatestate.TrashPrefix+e.Name()+"-"+uuid.NewString()[:8]) + if err := os.Rename(p, trash); err != nil { + t.d.Logf("self-update: GC %s: %v (retry next tick)", e.Name(), err) + continue + } + if err := os.RemoveAll(trash); err != nil { + t.d.Logf("self-update: GC %s: %v (retry next tick)", e.Name(), err) + } + } +} + +// recentStaged is the newest version dir above current that verifies, isn't +// quarantined and was staged within stagedKeep: a staged target whose +// switch didn't get recorded. "" if none. +func (t *tick) recentStaged(entries []os.DirEntry) string { + cur, err := updatestate.ParseVersion(t.st.Current) + if err != nil { + return "" + } + best := "" + var bestV updatestate.Version + for _, e := range entries { + v, err := updatestate.ParseVersion(e.Name()) + if err != nil || !e.IsDir() || updatestate.Compare(v, cur) <= 0 || t.quarantined(v.String()) { + continue + } + fi, err := os.Stat(filepath.Join(t.L.VersionDir(v), updatestate.ManifestName)) + if err != nil || t.now.Sub(fi.ModTime()) > stagedKeep || updatestate.VerifyVersionDir(t.L.VersionDir(v), v) != nil { + continue + } + if best == "" || updatestate.Compare(v, bestV) > 0 { + best, bestV = v.String(), v + } + } + return best +} + +func (t *tick) dirValid(v string) bool { + pv, err := updatestate.ParseVersion(v) + return err == nil && updatestate.VerifyVersionDir(t.L.VersionDir(pv), pv) == nil +} + +// health is S2. It reports whether a probation closed this tick. +func (t *tick) health(ctx context.Context) (closed bool, err error) { + p := t.st.Probation + if p == nil { + return false, nil + } + if p.Version != t.st.Current { + t.d.Logf("self-update: probation %s is not current %s; dropping it", p.Version, t.st.Current) + t.st.Probation = nil + return true, nil + } + if !t.dirValid(p.Version) { + return true, t.rollback(ReasonPointerInvalid, time.Time{}) + } + if t.now.Before(p.Since) { + // The clock went backwards since the switch: re-base, or every + // later run would look older than the probation and be ignored. + t.d.Logf("self-update: clock is before the probation start %s; re-basing", p.Since) + p.Since = t.now.UTC() + } + p.Ticks++ + healthy, neutral, bad := 0, 0, 0 + v, _ := updatestate.ParseVersion(p.Version) + for _, r := range updatestate.ReadRuns(t.L.RunFile(v)) { + if !inProbation(r, p) { + continue + } + switch updatestate.ClassifyRun(r) { + case updatestate.RunHealthy: + healthy++ + bad = 0 + case updatestate.RunNeutral: + neutral++ + case updatestate.RunBad: + bad++ + } + } + p.OK, p.Bad = healthy, bad + window := duration(knobProbationWindow) + switch { + case healthy > 0: + t.confirm("") + return true, nil + case bad >= badRunsToRollback: + return true, t.rollback(ReasonBadRuns, time.Time{}) + case t.now.Sub(p.Since) < window && p.Ticks < probationTicks(window): + return false, nil + case neutral+bad >= inconclusiveMinRuns: + // ≥6 runs and none Healthy (D5): scans don't start on this version. + return true, t.rollback(ReasonInconclusive, t.now.Add(inconclusiveRetry).UTC()) + } + // Window over with too few runs to judge: re-smoke, then promote on it. + if err := t.d.Smoke(context.WithoutCancel(ctx), filepath.Join(t.L.VersionDir(v), updatestate.AgentFile), v); err != nil { + if t.smokeTimedOut(v.String(), err) { + return false, nil // probation stays open; re-smoked next tick + } + t.d.Logf("self-update: re-smoke of %s failed: %v", v, err) + return true, t.rollback(ReasonSmokeFailed, time.Time{}) + } + delete(t.st.SmokeTimeouts, v.String()) + t.confirm("smoke_only") + return true, nil +} + +// inProbation reports whether run r belongs to probation p: by sequence +// (seq > since_seq) when both are recorded, so clock jumps can't drop +// runs; by time only for records or probations without one. +func inProbation(r updatestate.RunRecord, p *updatestate.Probation) bool { + if r.Seq > 0 && p.SinceSeq != nil { + return r.Seq > *p.SinceSeq + } + return !r.At.Before(p.Since) +} + +// probationTicks is the window in hourly update ticks. +func probationTicks(window time.Duration) int { + if n := int(window / time.Hour); n > 0 { + return n + } + return 1 +} + +// smokeTimedOut counts a smoke TIMEOUT for v and reports whether it should +// be retried (true) rather than held against v: only the +// smokeTimeoutLimit-th consecutive timeout counts as a failure. +func (t *tick) smokeTimedOut(v string, err error) bool { + if !errors.Is(err, ErrSmokeTimeout) { + return false + } + if t.st.SmokeTimeouts == nil { + t.st.SmokeTimeouts = map[string]int{} + } + t.st.SmokeTimeouts[v]++ + n := t.st.SmokeTimeouts[v] + if n < smokeTimeoutLimit { + t.d.Logf("self-update: smoke of %s timed out (%d of %d); retrying", v, n, smokeTimeoutLimit) + return true + } + delete(t.st.SmokeTimeouts, v) + return false +} + +func (t *tick) confirm(note string) { + v := t.st.Probation.Version + t.st.Probation = nil + t.d.Logf("self-update: %s confirmed %s", v, note) + t.enqueue(EventConfirmed, t.st.Previous, v, note, "") +} + +// rollback moves current back to previous (else the seed), quarantines the +// probation version and reports it, all in one atomic state write. +func (t *tick) rollback(reason string, until time.Time) error { + bad := t.st.Current + target := "" + for _, c := range t.rollbackCandidates() { + if c != bad && !t.quarantined(c) { + target = c + break + } + } + if target == "" { + target = t.d.Self.String() + } + next := cloneState(t.st) + next.Current = target + next.Previous = "" + next.Probation = nil + next.Quarantine[bad] = updatestate.Quarantine{Reason: reason, At: t.now.UTC(), Until: until} + enqueueTo(next, t.now, t.dedupeWindow(), EventRolledBack, bad, target, reason, "") + t.d.Logf("self-update: rolling back %s -> %s (%s)", bad, target, reason) + if err := t.save(next, "rollback"); err != nil { + return err + } + t.setRunning(target) + // The quarantined dir is garbage now; a scan still running it keeps it + // until a later tick. + t.gc() + return nil +} + +func (t *tick) rollbackCandidates() []string { + var c []string + if t.st.Previous != "" && t.dirValid(t.st.Previous) { + c = append(c, t.st.Previous) + } + for i := len(t.seeds) - 1; i >= 0; i-- { + c = append(c, t.seeds[i].String()) + } + if t.st.Previous != "" { + c = append(c, t.st.Previous) + } + return c +} + +func (t *tick) quarantined(v string) bool { + q, ok := t.st.Quarantine[v] + return ok && (q.Until.IsZero() || t.now.Before(q.Until)) +} + +// saveState is updatestate.SaveCheckpointed; tests make it fail. +var saveState = updatestate.SaveCheckpointed + +// save writes next with the given checkpoint prefix. On success t.st +// becomes next. After an error the write may still have landed (a late +// failure): a re-read decides by generation. If it landed, t.st becomes the +// state on disk; if not, t.st is left as it was and the error returned. +func (t *tick) save(next *updatestate.State, prefix string) error { + want := next.Generation + 1 + err := saveState(t.L.StateFile(), next, prefix) + if err == nil { + t.st = next + return nil + } + t.d.Logf("self-update: state write (%s): %v", prefix, err) + if disk, _, lerr := updatestate.Load(t.L.StateFile()); lerr == nil && disk != nil && disk.Generation == want { + normalize(disk) + t.st = disk + return nil + } + return err +} + +func (t *tick) setRunning(v string) { + if t.d.SetRunningVersion != nil { + t.d.SetRunningVersion(v) + } +} + +func cloneState(s *updatestate.State) *updatestate.State { + c := *s + if s.Probation != nil { + p := *s.Probation + if p.SinceSeq != nil { + v := *p.SinceSeq + p.SinceSeq = &v + } + c.Probation = &p + } + c.Quarantine = make(map[string]updatestate.Quarantine, len(s.Quarantine)) + for k, v := range s.Quarantine { + c.Quarantine[k] = v + } + c.Dedupe = make(map[string]time.Time, len(s.Dedupe)) + for k, v := range s.Dedupe { + c.Dedupe[k] = v + } + c.Outbox = append([]updatestate.OutboxEvent(nil), s.Outbox...) + if s.SmokeTimeouts != nil { + c.SmokeTimeouts = make(map[string]int, len(s.SmokeTimeouts)) + for k, v := range s.SmokeTimeouts { + c.SmokeTimeouts[k] = v + } + } + return &c +} + +// update is S3–S7, then finish. +func (t *tick) update(ctx context.Context, probationClosed bool) int { + st := t.st + currentValid := t.dirValid(st.Current) + t.resetIntactRestages(currentValid) + due := st.LastCheck.IsZero() || t.now.Sub(st.LastCheck) >= duration(knobCheckInterval) || + st.LastCheck.After(t.now) || probationClosed || !currentValid + if st.Probation != nil || !due { + return t.finish(ctx, ExitOK) + } + + // S3 Target. + rel, err := t.d.Client.Latest(ctx) + t.st.LastCheck = t.now.UTC() + if err != nil { + t.d.Logf("self-update: %v", err) + t.enqueue(EventCheckFailed, st.Current, "", err.Error(), EventCheckFailed) + return t.finish(ctx, ExitFailed) + } + t.reportUpdaterFailure() + if err := checkpoint("S3.target"); err != nil { + return t.finish(ctx, ExitFailed) + } + target := rel.Version.String() + t.target = target + if t.st.PendingSwitch != "" && t.st.PendingSwitch != target { + // The deferred target was abandoned; its dir and count go with it. + t.st.PendingSwitch, t.st.SwitchDeferrals = "", 0 + } + switch { + case !t.d.AutoUpdate: + if target != st.Current { + t.enqueue(EventDisabled, st.Current, target, "auto_update is off", EventDisabled+":"+target) + } + return t.finish(ctx, ExitOK) + case updatestate.Compare(rel.Version, Floor()) < 0: + t.enqueue(EventRefusedFloor, st.Current, target, + fmt.Sprintf("target %s is below the self-update floor %s", target, Floor()), EventRefusedFloor+":"+target) + return t.finish(ctx, ExitOK) + case t.quarantined(target): + t.d.Logf("self-update: target %s is quarantined (%s)", target, t.st.Quarantine[target].Reason) + return t.finish(ctx, ExitOK) + case target == st.Current && currentValid: + return t.finish(ctx, ExitOK) + } + + // A damaged current (an AV deleting a file) is restaged in place, but + // not endlessly (DMG-I5-5). + if target == st.Current && t.restagePaused(target) { + return t.finish(ctx, ExitOK) + } + + // S4 Stage, leaving the smoke its reserved budget. + stageCtx := ctx + if dl, ok := ctx.Deadline(); ok { + var cancel context.CancelFunc + stageCtx, cancel = context.WithDeadline(ctx, dl.Add(-(duration(knobSmokeTimeout) + smokeReserveMargin))) + defer cancel() + } + if event, err := t.stage(stageCtx, rel); err != nil { + t.d.Logf("self-update: stage %s: %v", target, err) + t.enqueue(event, st.Current, target, err.Error(), event+":"+target) + code := ExitOK + if event != EventVerifyFailed { + code = ExitFailed + } + return t.finish(ctx, code) + } + + if target == st.Current { + // Current's dir was missing or damaged and is now restaged in place: + // the pointer already names it, so there is nothing to switch. + t.d.Logf("self-update: restaged current %s", target) + t.countRestage(target) + return t.finish(ctx, ExitOK) + } + + // S5 Smoke. + agent := filepath.Join(t.L.VersionDir(rel.Version), updatestate.AgentFile) + // The smoke runs on its own budget (its timeout), not the tick's. If the + // tick's budget is gone anyway, that is a retry, never the version's fault. + if err := t.d.Smoke(context.WithoutCancel(ctx), agent, rel.Version); err != nil { + if ctx.Err() != nil { + t.d.Logf("self-update: smoke of %s cut by the tick budget (%v); retrying next tick", target, err) + t.st.LastCheck = time.Time{} + return t.finish(context.WithoutCancel(ctx), ExitFailed) + } + if t.smokeTimedOut(target, err) { + t.st.LastCheck = time.Time{} // retry on the next tick + return t.finish(ctx, ExitFailed) + } + t.d.Logf("self-update: smoke of %s failed: %v", target, err) + t.st.Quarantine[target] = updatestate.Quarantine{Reason: ReasonSmokeFailed, At: t.now.UTC()} + if t.st.PendingSwitch == target { + t.st.PendingSwitch, t.st.SwitchDeferrals = "", 0 // let GC remove it + } + t.enqueue(EventHealthFailed, st.Current, target, err.Error(), "") + return t.finish(ctx, ExitOK) + } + delete(t.st.SmokeTimeouts, target) + if err := checkpoint("S5.smoked"); err != nil { + return t.finish(ctx, ExitFailed) + } + + // S6 Switch. + return t.switchTo(ctx, rel.Version) +} + +func (t *tick) switchTo(ctx context.Context, v updatestate.Version) int { + target, old := v.String(), t.st.Current + release, ok := t.waitInstance(ctx) + if !ok { + if t.st.PendingSwitch != target { + t.st.PendingSwitch, t.st.SwitchDeferrals = target, 0 + } + t.st.SwitchDeferrals++ + // Retry the switch next tick instead of after the check interval. + t.st.LastCheck = time.Time{} + t.d.Logf("self-update: a scan holds the instance mutex; switch to %s deferred (%d)", target, t.st.SwitchDeferrals) + if t.st.SwitchDeferrals >= switchDeferralsReport { + t.enqueue(EventSwitchDeferred, old, target, + fmt.Sprintf("scan instance mutex busy for %d update ticks", t.st.SwitchDeferrals), "") + t.st.SwitchDeferrals = 0 + } + return t.finish(ctx, ExitOK) + } + defer release() + if err := checkpoint("S6.mutex"); err != nil { + return t.finish(ctx, ExitFailed) + } + next := cloneState(t.st) + next.Previous = old + next.Current = target + sinceSeq := updatestate.MaxRunSeq(t.L.RunFile(v)) + next.Probation = &updatestate.Probation{Version: target, Since: t.now.UTC(), SmokeOK: true, SinceSeq: &sinceSeq} + next.SwitchDeferrals = 0 + next.PendingSwitch = "" + enqueueTo(next, t.now, t.dedupeWindow(), EventOK, old, target, "", "") + if err := t.save(next, "S6"); err != nil { + t.enqueue(EventSwitchFailed, old, target, err.Error(), EventSwitchFailed+":"+target) + return t.finish(ctx, ExitFailed) + } + t.setRunning(target) + release() + t.d.Logf("self-update: switched %s -> %s", old, target) + + // S7 Report. + t.flush(ctx) + if err := checkpoint("S7.reported"); err != nil { + return t.finish(ctx, ExitFailed) + } + return t.finish(ctx, ExitOK) +} + +// waitInstance polls the scan instance mutex for up to the switch wait. +func (t *tick) waitInstance(ctx context.Context) (func(), bool) { + deadline := time.Now().Add(duration(knobSwitchWait)) + for { + release, err := t.d.AcquireInstance() + if err == nil { + return release, true + } + if !time.Now().Before(deadline) || ctx.Err() != nil { + return nil, false + } + select { + case <-ctx.Done(): + return nil, false + case <-time.After(2 * time.Second): + } + } +} + +// stage is S4. It returns the event to report on failure. +func (t *tick) stage(ctx context.Context, rel *Release) (string, error) { + v := rel.Version + dir := t.L.VersionDir(v) + if _, err := os.Lstat(dir); err == nil { + if updatestate.VerifyVersionDir(dir, v) == nil { + t.d.Logf("self-update: reusing staged %s", v) + return "", nil + } + trash := filepath.Join(t.L.VersionsDir(), updatestate.TrashPrefix+v.String()+"-"+uuid.NewString()[:8]) + if err := os.Rename(dir, trash); err != nil { + return EventStageFailed, fmt.Errorf("move invalid %s aside: %w", dir, err) + } + _ = os.RemoveAll(trash) + } + if err := t.d.Client.VerifySignatures(rel); err != nil { + return EventVerifyFailed, err + } + if _, err := updatestate.EnsureProtectedDir(t.L.VersionsDir()); err != nil { + return EventStageFailed, err + } + if err := t.checkFreeSpace(); err != nil { + return EventStageFailed, err + } + staging, err := t.L.StagingDir(v) + if err != nil { + return EventStageFailed, err + } + committed := false + defer func() { + if !committed { + _ = os.RemoveAll(staging) + } + }() + m := updatestate.Manifest{Version: v.String(), Files: map[string]string{}} + for _, a := range rel.Assets { + if err := t.d.Client.Download(ctx, v, a, staging); err != nil { + switch { + case errors.Is(err, ErrVerify): + return EventVerifyFailed, err + case errors.Is(err, ErrDownload): + return EventDownloadFailed, err + } + return EventStageFailed, err + } + if err := checkpoint("S4.downloaded:" + a.File); err != nil { + return EventStageFailed, err + } + m.Files[a.File] = a.Checksum + } + for _, f := range []string{updatestate.AgentFile, updatestate.LauncherFile} { + if err := t.d.VerifyPublisher(filepath.Join(staging, f)); err != nil { + return EventVerifyFailed, fmt.Errorf("%w: %s: %w", ErrVerify, f, err) + } + } + if err := checkpoint("S4.verified"); err != nil { + return EventStageFailed, err + } + if err := updatestate.WriteManifest(staging, m); err != nil { + return EventStageFailed, err + } + if err := checkpoint("S4.manifest"); err != nil { + return EventStageFailed, err + } + if err := os.Rename(staging, dir); err != nil { + return EventStageFailed, err + } + committed = true + if err := checkpoint("S4.renamed"); err != nil { + return EventStageFailed, err + } + return "", nil +} + +// checkFreeSpace requires 3x the running version's files plus 50 MB. +func (t *tick) checkFreeSpace() error { + if t.d.FreeBytes == nil { + return nil + } + var size int64 + for _, f := range updatestate.VersionFiles { + if fi, err := os.Stat(filepath.Join(filepath.Dir(t.d.SelfExe), f)); err == nil { + size += fi.Size() + } + } + need := uint64(3*size) + freeSpaceMargin // #nosec G115 -- file sizes are non-negative + free, err := t.d.FreeBytes(t.L.VersionsDir()) + if err != nil { + return nil // unknown: let the download find out + } + if free < need { + return fmt.Errorf("not enough disk space: %d bytes free, %d needed", free, need) + } + return nil +} + +// finish persists the state (with any new events), sends the outbox and +// persists again without the delivered events. Both writes have fault +// checkpoints (persist.*, final.*). +func (t *tick) finish(ctx context.Context, code int) int { + if err := t.save(t.st, "persist"); err != nil { + // The tick's events are not on disk: send them now (same + // execution_ids) rather than lose them; the save is retried by later + // ticks. + t.d.Logf("self-update: state not written; sending this tick's events directly") + t.deterministicIDs() + t.flush(ctx) + return ExitFailed + } + if t.flush(ctx) { + if err := t.save(t.st, "final"); err != nil { + return ExitFailed + } + } + return code +} + +// deterministicIDs gives events that never reached disk an execution_id +// derived from (event, from, to, target, state generation): while state.json +// stays unwritable, every tick repeats the same events under the same ids +// and the backend keeps one row each (D9: one id per event instance). +func (t *tick) deterministicIDs() { + for i, ev := range t.st.Outbox { + if t.diskIDs[ev.ExecutionID] { + continue + } + key := fmt.Sprintf("%s|%s|%s|%s|%d", ev.Event, ev.From, ev.To, t.target, t.st.Generation) + t.st.Outbox[i].ExecutionID = uuid.NewSHA1(directSendNS, []byte(key)).String() + } +} + +// flush sends due pending events oldest first. Delivered and rejected +// (400/422) events are removed; anything else stays with an exponential +// backoff (1 h, 2 h, ... capped at 24 h). It never blocks the update +// outcome, which is already on disk. It reports whether the outbox changed. +func (t *tick) flush(ctx context.Context) bool { + if len(t.st.Outbox) == 0 || t.d.Send == nil { + return false + } + if t.st.OutboxDropped > 0 { + t.d.Logf("self-update: %d events were dropped from a full outbox", t.st.OutboxDropped) + } + changed := false + var keep []updatestate.OutboxEvent + for _, ev := range t.st.Outbox { + if ev.NextAttempt.After(t.now) && !ev.NextAttempt.After(t.now.Add(maxSendBackoff)) { + keep = append(keep, ev) + continue + } + err := t.d.Send(ctx, ev) + changed = true + switch { + case err == nil: + case errors.Is(err, ErrEventRejected): + t.d.Logf("self-update: %s dropped: %v", ev.Event, err) + default: + ev.Attempts++ + backoff := maxSendBackoff + if ev.Attempts <= 5 { + backoff = min(time.Duration(1<<(ev.Attempts-1))*duration(knobSendBackoff), maxSendBackoff) + } + ev.NextAttempt = t.now.Add(backoff).UTC() + t.d.Logf("self-update: %s not sent (attempt %d, retry after %s): %v", ev.Event, ev.Attempts, backoff, err) + keep = append(keep, ev) + } + } + t.st.Outbox = keep + return changed +} diff --git a/internal/selfupdate/updater_test.go b/internal/selfupdate/updater_test.go new file mode 100644 index 00000000..94046db4 --- /dev/null +++ b/internal/selfupdate/updater_test.go @@ -0,0 +1,1379 @@ +package selfupdate + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/step-security/dev-machine-guard/internal/launcher" + "io" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/step-security/dev-machine-guard/internal/proctest" + "github.com/step-security/dev-machine-guard/internal/updatestate" +) + +// env is one simulated MSI install plus a fake backend. +type env struct { + t *testing.T + L updatestate.Layout + api *fakeAPI + now time.Time + self string + auto bool + pubErr error + smokeErr map[string]error + busy int // AcquireInstance fails this many more times + sendErr error + drift []string + + mu sync.Mutex + sent []updatestate.OutboxEvent // since the last events() call + all []updatestate.OutboxEvent +} + +const testArch = "amd64" + +func payload(kind, v string) []byte { return []byte(kind + "-" + v) } + +func newEnv(t *testing.T) *env { + t.Helper() + root := t.TempDir() + e := &env{ + t: t, L: updatestate.Layout{InstallRoot: filepath.Join(root, "pf"), DataRoot: filepath.Join(root, "pd")}, + api: newFakeAPI(t), now: time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC), + self: "1.19.0", auto: true, smokeErr: map[string]error{}, + } + for _, v := range []string{"1.17.0", "1.18.0", "1.19.0", "1.20.0", "1.21.0"} { + e.api.addRelease(v, testArch, payload("agent", v), payload("task", v), payload("linux", v)) + } + e.api.setTarget("1.20.0") + e.writeSeed("1.19.0") + knobOverrides = map[knob]time.Duration{knobSwitchWait: 0} + t.Cleanup(func() { knobOverrides = map[knob]time.Duration{} }) + return e +} + +func (e *env) writeSeed(v string) { + pv, _ := updatestate.ParseVersion(v) + dir := e.L.SeedDir(pv) + must(e.t, os.MkdirAll(dir, 0o755)) + for _, f := range []struct{ name, kind string }{{updatestate.AgentFile, "agent"}, {updatestate.LauncherFile, "task"}, {updatestate.LinuxFile, "linux"}} { + must(e.t, os.WriteFile(filepath.Join(dir, f.name), payload(f.kind, v), 0o755)) + } +} + +// installVersion writes a complete, manifest-checked versions\. +func (e *env) installVersion(v string) { + pv, _ := updatestate.ParseVersion(v) + dir := e.L.VersionDir(pv) + must(e.t, os.MkdirAll(dir, 0o755)) + m := updatestate.Manifest{Version: v, Files: map[string]string{}} + for _, f := range []struct{ name, kind string }{{updatestate.AgentFile, "agent"}, {updatestate.LauncherFile, "task"}, {updatestate.LinuxFile, "linux"}} { + b := payload(f.kind, v) + must(e.t, os.WriteFile(filepath.Join(dir, f.name), b, 0o755)) + m.Files[f.name] = sum(b) + } + must(e.t, updatestate.WriteManifest(dir, m)) +} + +func (e *env) writeState(st *updatestate.State) { + must(e.t, os.MkdirAll(e.L.StateDir(), 0o755)) + must(e.t, updatestate.Save(e.L.StateFile(), st)) +} + +func (e *env) state() *updatestate.State { + st, corrupt, err := updatestate.Load(e.L.StateFile()) + if err != nil || corrupt || st == nil { + e.t.Fatalf("state: %v corrupt=%v nil=%v", err, corrupt, st == nil) + } + return st +} + +func (e *env) deps() *Deps { + self, _ := updatestate.ParseVersion(e.self) + return &Deps{ + Layout: e.L, Self: self, SelfExe: filepath.Join(e.L.VersionDir(self), updatestate.AgentFile), + Client: e.api.client(testArch), + AutoUpdate: e.auto, + VerifyPublisher: func(p string) error { + if _, err := os.Stat(p); err != nil { + return err + } + return e.pubErr + }, + Smoke: func(_ context.Context, exe string, want updatestate.Version) error { + if _, err := os.Stat(exe); err != nil { + return err + } + return e.smokeErr[want.String()] + }, + AcquireInstance: func() (func(), error) { + if e.busy > 0 { + e.busy-- + return nil, errors.New("held") + } + return func() {}, nil + }, + EnsureACLs: func() ([]string, error) { d := e.drift; e.drift = nil; return d, nil }, + Send: func(_ context.Context, ev updatestate.OutboxEvent) error { + if e.sendErr != nil { + return e.sendErr + } + e.mu.Lock() + e.sent = append(e.sent, ev) + e.all = append(e.all, ev) + e.mu.Unlock() + return nil + }, + Now: func() time.Time { return e.now }, + Logf: func(f string, a ...any) { e.t.Logf("[updater] "+f, a...) }, + } +} + +func (e *env) tick() int { e.t.Helper(); return Run(context.Background(), e.deps()) } + +// events returns and clears the sent event names. +func (e *env) events() []string { + e.mu.Lock() + defer e.mu.Unlock() + var out []string + for _, ev := range e.sent { + out = append(out, ev.Event) + } + e.sent = nil + return out +} + +func (e *env) lastSent(event string) updatestate.OutboxEvent { + e.mu.Lock() + defer e.mu.Unlock() + for i := len(e.all) - 1; i >= 0; i-- { + if e.all[i].Event == event { + return e.all[i] + } + } + e.t.Fatalf("no %s sent", event) + return updatestate.OutboxEvent{} +} + +// addRun appends a finalized run record for v. +func (e *env) addRun(v string, started bool, exit int) { + pv, _ := updatestate.ParseVersion(v) + must(e.t, updatestate.AppendRun(e.L.RunFile(pv), updatestate.RunRecord{RunID: uuid.NewString(), Version: v, Started: started, ExitCode: &exit, At: e.now})) +} + +func must(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} + +func wantEvents(t *testing.T, got []string, want ...string) { + t.Helper() + if !slices.Equal(got, want) { + t.Fatalf("events = %v, want %v", got, want) + } +} + +// assertHygiene is U6 on the versions dir. +func assertHygiene(t *testing.T, e *env) { + t.Helper() + st := e.state() + allowed := map[string]bool{st.Current: true, st.Previous: true, e.self: true} + if st.Probation != nil { + allowed[st.Probation.Version] = true + } + if st.PendingSwitch != "" { + allowed[st.PendingSwitch] = true + } + for _, s := range seedVersions(e.L) { + allowed[s.String()] = true + } + entries, _ := os.ReadDir(e.L.VersionsDir()) + for _, en := range entries { + if updatestate.IsSweepable(en.Name()) || !allowed[en.Name()] { + t.Errorf("leftover in versions: %s", en.Name()) + } + } +} + +func baseState(cur, prev string) *updatestate.State { + return &updatestate.State{Schema: 1, Current: cur, Previous: prev} +} + +func TestFreshInstallUpdatesThenConfirms(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + e.writeState(baseState("1.19.0", "")) + + if code := e.tick(); code != ExitOK { + t.Fatalf("tick = %d", code) + } + wantEvents(t, e.events(), EventOK) + ok := e.lastSent(EventOK) + _ = ok + st := e.state() + if st.Current != "1.20.0" || st.Previous != "1.19.0" || st.Probation == nil || st.Probation.Version != "1.20.0" || len(st.Outbox) != 0 { + t.Fatalf("after switch: %+v", st) + } + if err := updatestate.VerifyVersionDir(e.L.VersionDir(mustV("1.20.0")), mustV("1.20.0")); err != nil { + t.Fatal(err) + } + + // Probation open: no target check, no events. + e.now = e.now.Add(time.Hour) + e.tick() + wantEvents(t, e.events()) + + e.addRun("1.20.0", true, 0) + e.now = e.now.Add(time.Hour) + e.tick() + wantEvents(t, e.events(), EventConfirmed) + if st := e.state(); st.Probation != nil || st.Current != "1.20.0" { + t.Fatalf("after confirm: %+v", st) + } + assertHygiene(t, e) +} + +func mustV(s string) updatestate.Version { + v, err := updatestate.ParseVersion(s) + if err != nil { + panic(err) + } + return v +} + +func TestMissingStateRebuildsOnSeed(t *testing.T) { + e := newEnv(t) + e.api.setTarget("1.19.0") + e.tick() + wantEvents(t, e.events(), EventStateCorrupt) + if st := e.state(); st.Current != "1.19.0" { + t.Fatalf("current = %s", st.Current) + } + // versions\1.19.0 was absent, so it is staged from the release. + if err := updatestate.VerifyVersionDir(e.L.VersionDir(mustV("1.19.0")), mustV("1.19.0")); err != nil { + t.Fatalf("seed version not staged: %v", err) + } +} + +func TestCorruptStateIsReported(t *testing.T) { + e := newEnv(t) + must(t, os.MkdirAll(e.L.StateDir(), 0o755)) + must(t, os.WriteFile(e.L.StateFile(), []byte("{garbage"), 0o644)) + e.api.setTarget("1.19.0") + if code := e.tick(); code != ExitOK { + t.Fatalf("tick = %d", code) + } + wantEvents(t, e.events(), EventStateCorrupt) + if ev := e.lastSentOrNil(EventStateCorrupt); ev == nil || ev.Error == "" { + t.Fatal("no reason on update_state_corrupt") + } +} + +func (e *env) lastSentOrNil(event string) *updatestate.OutboxEvent { + e.mu.Lock() + defer e.mu.Unlock() + for i := len(e.all) - 1; i >= 0; i-- { + if e.all[i].Event == event { + ev := e.all[i] + return &ev + } + } + return nil +} + +func probationState(t *testing.T, e *env) { + t.Helper() + e.installVersion("1.19.0") + e.installVersion("1.20.0") + st := baseState("1.20.0", "1.19.0") + st.Probation = &updatestate.Probation{Version: "1.20.0", Since: e.now.Add(-time.Minute), SmokeOK: true} + st.LastCheck = e.now + e.writeState(st) +} + +func TestBadRunsRollBackAndQuarantineForever(t *testing.T) { + e := newEnv(t) + probationState(t, e) + e.addRun("1.20.0", false, 2) + e.tick() + wantEvents(t, e.events()) + e.addRun("1.20.0", false, 0xC0000005) + e.tick() + got := e.events() + if len(got) == 0 || got[0] != EventRolledBack { + t.Fatalf("events = %v", got) + } + rb := e.lastSent(EventRolledBack) + if rb.From != "1.20.0" || rb.To != "1.19.0" || rb.Error != ReasonBadRuns { + t.Fatalf("rollback event = %+v", rb) + } + st := e.state() + if st.Current != "1.19.0" || st.Probation != nil || st.Quarantine["1.20.0"].Reason != ReasonBadRuns || !st.Quarantine["1.20.0"].Until.IsZero() { + t.Fatalf("after rollback: %+v", st) + } + // U8: the target stays 1.20.0 for days; it is never current again and + // never re-staged. + for i := 0; i < 5; i++ { + e.now = e.now.Add(7 * time.Hour) + e.tick() + if st := e.state(); st.Current != "1.19.0" { + t.Fatalf("quarantined version came back: %+v", st) + } + } + if _, err := os.Stat(e.L.VersionDir(mustV("1.20.0"))); !os.IsNotExist(err) { + t.Fatalf("quarantined version dir not collected: %v", err) + } + assertHygiene(t, e) +} + +func TestNeutralRunsNeverRollBack(t *testing.T) { + e := newEnv(t) + probationState(t, e) + for i := 0; i < 5; i++ { + e.addRun("1.20.0", false, 1) + } + e.tick() + if st := e.state(); st.Probation == nil || st.Current != "1.20.0" { + t.Fatalf("neutral runs closed the probation: %+v", st) + } +} + +func TestInconclusiveProbationQuarantinesForAWeek(t *testing.T) { + e := newEnv(t) + probationState(t, e) + for i := 0; i < 6; i++ { + e.addRun("1.20.0", false, 1) + } + e.now = e.now.Add(73 * time.Hour) + e.tick() + rb := e.lastSent(EventRolledBack) + if rb.Error != ReasonInconclusive { + t.Fatalf("rollback = %+v", rb) + } + st := e.state() + q := st.Quarantine["1.20.0"] + if st.Current != "1.19.0" || q.Until.IsZero() || q.Until.Sub(e.now) != 7*24*time.Hour { + t.Fatalf("after inconclusive: %+v %+v", st, q) + } + e.events() + // After the quarantine expires the target is taken again. + e.now = e.now.Add(7*24*time.Hour + time.Minute) + e.tick() + if st := e.state(); st.Current != "1.20.0" { + t.Fatalf("not retried after expiry: %+v", st) + } + wantEvents(t, e.events(), EventOK) +} + +func TestSmokeOnlyPromotion(t *testing.T) { + e := newEnv(t) + probationState(t, e) + e.now = e.now.Add(73 * time.Hour) + e.tick() + c := e.lastSent(EventConfirmed) + if c.To != "1.20.0" || c.Error != "smoke_only" { + t.Fatalf("confirm = %+v", c) + } +} + +func TestReSmokeFailureRollsBack(t *testing.T) { + e := newEnv(t) + probationState(t, e) + e.smokeErr["1.20.0"] = errors.New("boom") + e.now = e.now.Add(73 * time.Hour) + e.tick() + if rb := e.lastSent(EventRolledBack); rb.Error != ReasonSmokeFailed { + t.Fatalf("rollback = %+v", rb) + } +} + +func TestPointerInvalidDuringProbation(t *testing.T) { + e := newEnv(t) + probationState(t, e) + must(t, os.Remove(filepath.Join(e.L.VersionDir(mustV("1.20.0")), updatestate.AgentFile))) + e.tick() + if rb := e.lastSent(EventRolledBack); rb.Error != ReasonPointerInvalid || rb.To != "1.19.0" { + t.Fatalf("rollback = %+v", rb) + } +} + +func TestSmokeFailureQuarantinesWithoutSwitch(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + e.writeState(baseState("1.19.0", "")) + e.smokeErr["1.20.0"] = errors.New("exit 3") + e.tick() + wantEvents(t, e.events(), EventHealthFailed) + st := e.state() + if st.Current != "1.19.0" || st.Quarantine["1.20.0"].Reason != ReasonSmokeFailed { + t.Fatalf("state = %+v", st) + } + e.now = e.now.Add(7 * time.Hour) + e.tick() + wantEvents(t, e.events()) + assertHygiene(t, e) +} + +func TestPolicyOutcomes(t *testing.T) { + cases := []struct { + name string + setup func(e *env) + event string + code int + staged bool + }{ + {"disabled", func(e *env) { e.auto = false }, EventDisabled, ExitOK, false}, + {"below floor", func(e *env) { e.api.setTarget("1.17.0") }, EventRefusedFloor, ExitOK, false}, + {"check failed", func(e *env) { e.api.metaStatus = 500 }, EventCheckFailed, ExitFailed, false}, + {"bad signature", func(e *env) { + e.api.badSig["stepsecurity-dev-machine-guard-1.20.0-windows_amd64.exe"] = true + }, EventVerifyFailed, ExitOK, false}, + {"wrong publisher", func(e *env) { e.pubErr = errors.New("publisher differs") }, EventVerifyFailed, ExitOK, false}, + {"sha mismatch", func(e *env) { + e.api.checksumOver["stepsecurity-dev-machine-guard-1.20.0-linux_amd64"] = sum([]byte("other")) + }, EventVerifyFailed, ExitOK, false}, + {"download failed", func(e *env) { e.api.githubDown = true }, EventDownloadFailed, ExitFailed, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + e.writeState(baseState("1.19.0", "")) + tc.setup(e) + if code := e.tick(); code != tc.code { + t.Fatalf("tick = %d, want %d", code, tc.code) + } + wantEvents(t, e.events(), tc.event) + if st := e.state(); st.Current != "1.19.0" { + t.Fatalf("current moved: %+v", st) + } + if _, err := os.Stat(e.L.VersionDir(mustV("1.20.0"))); err == nil { + t.Fatal("target staged") + } + if tc.name == "bad signature" { + for remote, n := range e.api.githubHits { + if n > 0 { + t.Fatalf("downloaded %s before verifying signatures", remote) + } + } + } + // Deduped within the window, reported again after it. + e.now = e.now.Add(7 * time.Hour) + e.tick() + wantEvents(t, e.events()) + e.now = e.now.Add(25 * time.Hour) + e.tick() + wantEvents(t, e.events(), tc.event) + assertHygiene(t, e) + }) + } +} + +func TestCheckIntervalGatesNetwork(t *testing.T) { + e := newEnv(t) + e.installVersion("1.20.0") + e.self = "1.20.0" + e.writeState(baseState("1.20.0", "")) + e.tick() + n := len(e.api.latestRequests) + e.now = e.now.Add(time.Hour) + e.tick() + if len(e.api.latestRequests) != n { + t.Fatal("target checked again within the 6 h interval") + } + e.now = e.now.Add(6 * time.Hour) + e.tick() + if len(e.api.latestRequests) == n { + t.Fatal("target not checked after the interval") + } +} + +func TestDowngradeAboveFloor(t *testing.T) { + e := newEnv(t) + e.installVersion("1.21.0") + e.self = "1.21.0" + e.writeState(baseState("1.21.0", "1.19.0")) + e.tick() + ok := e.lastSent(EventOK) + if ok.From != "1.21.0" || ok.To != "1.20.0" { + t.Fatalf("update_ok = %+v", ok) + } +} + +func TestSwitchDeferredWhileScanRuns(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + e.writeState(baseState("1.19.0", "")) + e.busy = 100 + for i := 1; i <= 4; i++ { + e.now = e.now.Add(time.Hour) + e.tick() + if st := e.state(); st.Current != "1.19.0" { + t.Fatal("switched while the scan held the mutex") + } + } + wantEvents(t, e.events(), EventSwitchDeferred) + e.busy = 0 + e.now = e.now.Add(time.Hour) + e.tick() + wantEvents(t, e.events(), EventOK) + if st := e.state(); st.Current != "1.20.0" || st.SwitchDeferrals != 0 { + t.Fatalf("after release: %+v", st) + } +} + +func TestLockHeldExitsQuietly(t *testing.T) { + e := newEnv(t) + e.writeState(baseState("1.19.0", "")) + lk, err := updatestate.AcquireLock(e.L.LockFile()) + must(t, err) + defer lk.Release() + if code := e.tick(); code != ExitOK { + t.Fatalf("tick = %d", code) + } + if len(e.api.latestRequests) != 0 { + t.Fatal("second updater did work") + } +} + +func TestOutboxRetriedNextTick(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + e.writeState(baseState("1.19.0", "")) + e.sendErr = errors.New("backend down") + if code := e.tick(); code != ExitOK { + t.Fatalf("send failure changed the outcome: %d", code) + } + st := e.state() + if st.Current != "1.20.0" || len(st.Outbox) != 1 || st.Outbox[0].Event != EventOK { + t.Fatalf("state = %+v", st) + } + e.sendErr = nil + e.now = e.now.Add(time.Hour) + e.tick() + wantEvents(t, e.events(), EventOK) + if st := e.state(); len(st.Outbox) != 0 { + t.Fatalf("outbox not drained: %+v", st.Outbox) + } + // Rejected events are dropped, not retried forever. + e.drift = []string{"versions"} + e.sendErr = fmt.Errorf("%w: HTTP 400", ErrEventRejected) + e.now = e.now.Add(time.Hour) + e.tick() + if st := e.state(); len(st.Outbox) != 0 { + t.Fatalf("rejected event kept: %+v", st.Outbox) + } +} + +func TestACLDriftAndUpdaterFailureReported(t *testing.T) { + e := newEnv(t) + e.installVersion("1.20.0") + st := baseState("1.20.0", "") + st.Updater = updatestate.Updater{Version: "1.20.0", ConsecutiveFailures: 3} + e.writeState(st) + e.drift = []string{"versions"} + e.tick() + got := e.events() + slices.Sort(got) + wantEvents(t, got, EventACLDrift, EventUpdaterFailed) + uf := e.lastSent(EventUpdaterFailed) + if uf.From != "1.20.0" || uf.To != "1.19.0" { + t.Fatalf("update_updater_failed = %+v", uf) + } +} + +func TestGCKeepsPointersAndSweepsLeftovers(t *testing.T) { + e := newEnv(t) + for _, v := range []string{"1.18.0", "1.19.0", "1.20.0", "1.21.0"} { + e.installVersion(v) + } + // 1.21.0 was staged long ago (not a recent unrecorded stage): collect it. + old := e.now.Add(-3 * 24 * time.Hour) + must(t, os.Chtimes(filepath.Join(e.L.VersionDir(mustV("1.21.0")), updatestate.ManifestName), old, old)) + must(t, os.MkdirAll(filepath.Join(e.L.VersionsDir(), ".staging-1.21.0-x"), 0o755)) + must(t, os.MkdirAll(filepath.Join(e.L.VersionsDir(), ".trash-1.17.0-y"), 0o755)) + st := baseState("1.20.0", "1.19.0") + st.LastCheck = e.now + e.writeState(st) + e.self = "1.19.0" + e.tick() + entries, _ := os.ReadDir(e.L.VersionsDir()) + var names []string + for _, en := range entries { + names = append(names, en.Name()) + } + if !slices.Equal(names, []string{"1.19.0", "1.20.0"}) { + t.Fatalf("versions = %v", names) + } +} + +// D9: every event gets its own execution_id; a retried event (carried in +// the outbox to a later tick) keeps its id. +func TestEventExecutionIDs(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + e.writeState(baseState("1.19.0", "")) + e.drift = []string{"versions"} + e.sendErr = errors.New("backend down") + e.tick() + pending := e.state().Outbox + if len(pending) != 2 { + t.Fatalf("outbox = %+v", pending) + } + if pending[0].ExecutionID == pending[1].ExecutionID || pending[0].ExecutionID == "" { + t.Fatalf("events share an execution_id: %+v", pending) + } + e.sendErr = nil + e.now = e.now.Add(time.Hour) + e.tick() + ids := map[string]bool{} + for _, ev := range e.all { + ids[ev.ExecutionID] = true + if ev.Event == EventRolledBack && ev.From == "" { + t.Fatal("update_rolled_back without update_from") + } + } + for _, p := range pending { + if !ids[p.ExecutionID] { + t.Fatalf("retry of %s used a new execution_id", p.Event) + } + } + if len(ids) != len(e.all) { + t.Fatalf("delivered events reuse ids: %+v", e.all) + } +} + +func TestMissingLinuxFieldsReportVerifyFailed(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + e.writeState(baseState("1.19.0", "")) + e.api.noLinuxFields = true + if code := e.tick(); code != ExitOK { + t.Fatalf("tick = %d", code) + } + wantEvents(t, e.events(), EventVerifyFailed) + if len(e.api.githubHits) != 0 { + t.Fatalf("downloaded before verification: %v", e.api.githubHits) + } +} + +// D11: the fallback updater reports update_updater_failed only on a tick +// where it reached latest-binary itself; during an outage the primary's +// failures prove nothing. +func TestUpdaterFailedOnlyAfterReachingBackend(t *testing.T) { + e := newEnv(t) + e.installVersion("1.20.0") + st := baseState("1.20.0", "") + st.Updater = updatestate.Updater{Version: "1.20.0", ConsecutiveFailures: 3} + e.writeState(st) + e.api.metaStatus = 503 + e.tick() + if got := e.events(); slices.Contains(got, EventUpdaterFailed) { + t.Fatalf("reported during an outage: %v", got) + } + e.api.metaStatus = 0 + e.now = e.now.Add(time.Hour) // no target check due: still not reported + e.tick() + if got := e.events(); slices.Contains(got, EventUpdaterFailed) { + t.Fatalf("reported without reaching the backend: %v", got) + } + e.now = e.now.Add(6 * time.Hour) + e.tick() + if got := e.events(); !slices.Contains(got, EventUpdaterFailed) { + t.Fatalf("not reported once the backend answered: %v", got) + } +} + +// failingSaves makes every state write fail without touching the disk +// (landed=false) or write and then fail (landed=true), like a late error. +func failingSaves(t *testing.T, landed bool) { + t.Helper() + old := saveState + saveState = func(path string, st *updatestate.State, prefix string) error { + if landed { + if err := old(path, st, prefix); err != nil { + return err + } + } + return errors.New("injected state write failure") + } + t.Cleanup(func() { saveState = old }) +} + +// R-C2-1: a tick whose state write never lands still reports its events +// (sent directly) instead of dropping them. +func TestUnlandedWriteSendsEventsDirectly(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + e.writeState(baseState("1.19.0", "")) + e.pubErr = errors.New("publisher differs") + failingSaves(t, false) + if code := e.tick(); code != ExitFailed { + t.Fatalf("tick = %d, want ExitFailed when state can't be written", code) + } + wantEvents(t, e.events(), EventVerifyFailed) +} + +func TestSwitchWriteFailureReported(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + e.writeState(baseState("1.19.0", "")) + failingSaves(t, false) + e.tick() + got := e.events() + if !slices.Contains(got, EventSwitchFailed) || slices.Contains(got, EventOK) { + t.Fatalf("events = %v, want update_switch_failed and no update_ok", got) + } + if st := e.state(); st.Current != "1.19.0" { + t.Fatalf("current moved: %s", st.Current) + } +} + +// A write that landed but returned an error (late failure) is recognised +// by its generation: the switch counts, and no false switch_failed is sent. +func TestLandedWriteWithErrorIsNotAFailure(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + e.writeState(baseState("1.19.0", "")) + failingSaves(t, true) + e.tick() + got := e.events() + if slices.Contains(got, EventSwitchFailed) || !slices.Contains(got, EventOK) { + t.Fatalf("events = %v, want update_ok only", got) + } + if st := e.state(); st.Current != "1.20.0" { + t.Fatalf("current = %s", st.Current) + } +} + +// R-C2-3: a retryable send failure keeps the event with a backoff; the +// outbox is capped at 64, dropping the oldest and counting the drops. +func TestOutboxBackoffAndCap(t *testing.T) { + e := newEnv(t) + e.installVersion("1.20.0") + e.self = "1.20.0" + st := baseState("1.20.0", "") + st.LastCheck = e.now + for i := 0; i < 70; i++ { + enqueueTo(st, e.now, time.Hour, EventCheckFailed, "1.20.0", "", fmt.Sprint(i), "") + } + if len(st.Outbox) != 64 || st.OutboxDropped != 6 || st.Outbox[0].Error != "6" { + t.Fatalf("cap: len=%d dropped=%d first=%q", len(st.Outbox), st.OutboxDropped, st.Outbox[0].Error) + } + st.Outbox = st.Outbox[:1] + e.writeState(st) + calls := 0 + d := e.deps() + d.Send = func(context.Context, updatestate.OutboxEvent) error { calls++; return errors.New("HTTP 429") } + Run(context.Background(), d) + if calls != 1 { + t.Fatalf("first attempt calls = %d", calls) + } + pending := e.state().Outbox + if len(pending) != 1 || pending[0].Attempts != 1 || !pending[0].NextAttempt.After(e.now) { + t.Fatalf("after a 429: %+v", pending) + } + // Within the backoff: not retried. + e.now = e.now.Add(30 * time.Minute) + Run(context.Background(), d) + if calls != 1 { + t.Fatalf("retried inside the backoff (calls=%d)", calls) + } + e.now = e.now.Add(2 * time.Hour) + Run(context.Background(), d) + if calls != 2 || e.state().Outbox[0].Attempts != 2 { + t.Fatalf("not retried after the backoff: calls=%d %+v", calls, e.state().Outbox) + } +} + +// R-C2-4: a switch made while the clock ran a year ahead, then corrected +// backwards: crash runs recorded after the correction still roll back. +func TestProbationSurvivesBackwardsClock(t *testing.T) { + e := newEnv(t) + probationState(t, e) + st := e.state() + st.Probation.Since = e.now.Add(365 * 24 * time.Hour) + e.writeState(st) + for i := 0; i < 3 && e.state().Current == "1.20.0"; i++ { + e.addRun("1.20.0", false, 0xC0000005) + e.tick() + e.now = e.now.Add(time.Hour) + } + if st := e.state(); st.Current != "1.19.0" || st.Quarantine["1.20.0"].Reason != ReasonBadRuns { + t.Fatalf("crashing version kept after a clock correction: %+v", st) + } +} + +// The probation also closes after window/1h update ticks, whatever the +// clock says. +func TestProbationClosesByTickCount(t *testing.T) { + e := newEnv(t) + probationState(t, e) + st := e.state() + st.Probation.Ticks = 71 + e.writeState(st) + e.tick() + if c := e.lastSent(EventConfirmed); c.Error != "smoke_only" { + t.Fatalf("confirm = %+v", c) + } +} + +// A quarantine expiry set by a clock far ahead is clamped to 7 days. +func TestQuarantineUntilClamped(t *testing.T) { + e := newEnv(t) + e.installVersion("1.20.0") + e.self = "1.20.0" + st := baseState("1.20.0", "") + st.LastCheck = e.now + st.Quarantine = map[string]updatestate.Quarantine{"1.21.0": {Reason: ReasonInconclusive, At: e.now, Until: e.now.Add(2 * 365 * 24 * time.Hour)}} + e.writeState(st) + e.tick() + if u := e.state().Quarantine["1.21.0"].Until; u.After(e.now.Add(7 * 24 * time.Hour)) { + t.Fatalf("until not clamped: %v", u) + } +} + +// D15/R-C2-5: six runs with no started marker are inconclusive even when +// one of them was Bad; that is not a smoke-only promotion. +func TestInconclusiveCountsBadRuns(t *testing.T) { + e := newEnv(t) + probationState(t, e) + e.addRun("1.20.0", false, 0xC0000005) + for i := 0; i < 5; i++ { + e.addRun("1.20.0", false, 1) + } + e.now = e.now.Add(73 * time.Hour) + e.tick() + if rb := e.lastSentOrNil(EventRolledBack); rb == nil || rb.Error != ReasonInconclusive { + t.Fatalf("want an inconclusive rollback, got %v (events %v)", rb, e.events()) + } +} + +// D15/R-C2-6: the smoke has its own budget: the tick's context ending +// doesn't cut it, and a smoke that fails because the tick budget ran out is +// retried, never a quarantine. +func TestSmokeBudgetCutoffRetries(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + e.writeState(baseState("1.19.0", "")) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + d := e.deps() + smokeCtxAlive := false + d.Smoke = func(sctx context.Context, _ string, _ updatestate.Version) error { + cancel() // the tick's budget ends while the smoke runs + smokeCtxAlive = sctx.Err() == nil + return fmt.Errorf("self-check timed out: %w", context.DeadlineExceeded) + } + Run(ctx, d) + if !smokeCtxAlive { + t.Fatal("the smoke context was cut by the tick budget") + } + st := e.state() + if _, q := st.Quarantine["1.20.0"]; q { + t.Fatalf("budget cutoff quarantined the target: %+v", st.Quarantine) + } + if slices.Contains(e.events(), EventHealthFailed) { + t.Fatal("budget cutoff reported update_health_failed") + } + if !st.LastCheck.IsZero() { + t.Fatal("the target check is not retried on the next tick") + } +} + +// D15/R-C2-8: deferred switches reuse the staged versions\ instead of +// re-downloading it every tick. +func TestDeferralReusesStagedDir(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + e.writeState(baseState("1.19.0", "")) + e.busy = 5 + for i := 0; i < 5; i++ { + e.tick() + e.now = e.now.Add(time.Hour) + } + e.tick() + if st := e.state(); st.Current != "1.20.0" { + t.Fatalf("not switched: %+v", st) + } + for remote, n := range e.api.githubHits { + if n != 1 { + t.Fatalf("%s downloaded %d times", remote, n) + } + } +} + +// The deferral counter belongs to one target: a count left by an abandoned +// target doesn't make the next target's first deferral a report. +func TestDeferralCounterIsPerTarget(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + st := baseState("1.19.0", "") + st.SwitchDeferrals = 3 + st.PendingSwitch = "1.21.0" + e.writeState(st) + e.busy = 1 + e.tick() + if got := e.events(); slices.Contains(got, EventSwitchDeferred) { + t.Fatalf("first deferral of a new target reported: %v", got) + } + if st := e.state(); st.SwitchDeferrals != 1 || st.PendingSwitch != "1.20.0" { + t.Fatalf("deferral state: %d %q", st.SwitchDeferrals, st.PendingSwitch) + } +} + +// D15 design fix: during a probation the updater is the OLDER previous; a +// newer schema written by the probation version must not make it rebuild +// on the seed or drop the newer fields. +func TestOlderUpdaterKeepsNewerSchemaState(t *testing.T) { + e := newEnv(t) + probationState(t, e) + since := e.now.Add(-time.Minute).UTC().Format(time.RFC3339Nano) + body := `{"schema":2,"current":"1.20.0","previous":"1.19.0","future":{"k":[1]}, + "probation":{"version":"1.20.0","since":"` + since + `","ok":0,"bad":0,"smoke_ok":true,"p2":"x"}, + "quarantine":{"1.18.5":{"reason":"bad_runs","at":"` + since + `","q2":1}},"last_check":"` + since + `"}` + must(t, os.WriteFile(e.L.StateFile(), []byte(body), 0o644)) + e.addRun("1.20.0", false, 2) + e.tick() + if got := e.events(); slices.Contains(got, EventStateCorrupt) { + t.Fatalf("rebuilt a newer-schema state: %v", got) + } + var out map[string]any + data, _ := os.ReadFile(e.L.StateFile()) + _ = json.Unmarshal(data, &out) + p, _ := out["probation"].(map[string]any) + q, _ := out["quarantine"].(map[string]any)["1.18.5"].(map[string]any) + if out["schema"] != float64(2) || out["current"] != "1.20.0" || fmt.Sprint(out["future"]) != "map[k:[1]]" || + p == nil || p["p2"] != "x" || p["bad"] != float64(1) || q == nil || q["q2"] != float64(1) { + t.Fatalf("newer state not preserved: %s", data) + } +} + +// D15 test gap (planted bug "GC deletes previous"): with previous, the seed +// and the running updater all different, GC keeps previous. +func TestGCKeepsPreviousDistinctFromSeedAndUpdater(t *testing.T) { + e := newEnv(t) + must(t, os.RemoveAll(filepath.Join(e.L.InstallRoot, "seed"))) + e.writeSeed("1.17.0") + for _, v := range []string{"1.18.0", "1.21.0"} { + e.installVersion(v) + } + e.self = "1.21.0" + st := baseState("1.21.0", "1.18.0") + st.LastCheck = e.now + e.writeState(st) + e.tick() + if err := updatestate.VerifyVersionDir(e.L.VersionDir(mustV("1.18.0")), mustV("1.18.0")); err != nil { + t.Fatalf("GC removed previous: %v", err) + } + if err := updatestate.VerifyVersionDir(e.L.VersionDir(mustV("1.21.0")), mustV("1.21.0")); err != nil { + t.Fatalf("GC removed current: %v", err) + } +} + +// D15 test gap (planted bug "dedupe key without the event class"): a new +// event class for the same target inside the dedupe window is reported. +func TestDedupeNeverHidesANewEventClass(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + e.writeState(baseState("1.19.0", "")) + e.pubErr = errors.New("publisher differs") + e.tick() + wantEvents(t, e.events(), EventVerifyFailed) + e.pubErr = nil + e.api.githubDown = true + e.now = e.now.Add(7 * time.Hour) + e.tick() + wantEvents(t, e.events(), EventDownloadFailed) + e.now = e.now.Add(7 * time.Hour) + e.tick() + wantEvents(t, e.events()) // same class, same target, same window: deduped +} + +// R-F1-8: with 12 abandoned open records in the run file, two crashes are +// still seen and the probation version is rolled back. +func TestRollbackDespiteAbandonedRecords(t *testing.T) { + e := newEnv(t) + probationState(t, e) + pv := mustV("1.20.0") + stale := time.Now().Add(-48 * time.Hour) + exited := proctest.Command(t, os.Args[0], "-test.run=^$") + must(t, exited.Run()) + for i := 0; i < 12; i++ { + must(t, updatestate.AppendRun(e.L.RunFile(pv), updatestate.RunRecord{RunID: fmt.Sprintf("dead-%d", i), Version: "1.20.0", StartedAt: stale, OwnerPID: exited.Process.Pid})) + } + for i := 0; i < 2 && e.state().Current == "1.20.0"; i++ { + id := fmt.Sprintf("crash-%d", i) + must(t, updatestate.AppendRun(e.L.RunFile(pv), updatestate.RunRecord{RunID: id, Version: "1.20.0"})) + must(t, updatestate.FinalizeRun(e.L.RunFile(pv), id, 2, false, e.now)) + e.tick() + } + if rb := e.lastSentOrNil(EventRolledBack); rb == nil || rb.Error != ReasonBadRuns { + t.Fatalf("crashing version not rolled back: %+v", e.state()) + } +} + +// R-C2-11: a deferred target that then fails its smoke is quarantined and +// no longer pending, so GC removes its dir (U6). +func TestQuarantinedPendingTargetIsCollected(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + e.writeState(baseState("1.19.0", "")) + e.busy = 1 + e.tick() // staged, switch deferred + if st := e.state(); st.PendingSwitch != "1.20.0" { + t.Fatalf("not pending: %+v", st) + } + e.smokeErr["1.20.0"] = errors.New("exit 3") + e.now = e.now.Add(time.Hour) + e.tick() + st := e.state() + if st.PendingSwitch != "" || st.Quarantine["1.20.0"].Reason != ReasonSmokeFailed { + t.Fatalf("after smoke failure: pending=%q quarantine=%+v", st.PendingSwitch, st.Quarantine) + } + e.now = e.now.Add(time.Hour) + e.tick() + if _, err := os.Stat(e.L.VersionDir(mustV("1.20.0"))); !os.IsNotExist(err) { + t.Fatalf("quarantined dir kept: %v", err) + } + assertHygiene(t, e) +} + +// R-C2-12: while state.json can't be written, repeats of the same event +// carry one deterministic execution_id (the backend collapses them), and a +// release already staged in versions\ is not downloaded again. +func TestPersistentWriteFailureIsQuiet(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + e.writeState(baseState("1.19.0", "")) + failingSaves(t, false) + for i := 0; i < 3; i++ { + e.tick() + e.now = e.now.Add(7 * time.Hour) + } + ids := map[string]bool{} + for _, ev := range e.all { + if ev.Event == EventSwitchFailed { + ids[ev.ExecutionID] = true + } + } + if len(ids) != 1 { + t.Fatalf("update_switch_failed sent with %d distinct execution_ids, want 1", len(ids)) + } + for remote, n := range e.api.githubHits { + if n != 1 { + t.Fatalf("%s downloaded %d times", remote, n) + } + } +} + +// B4(b): a bootstrap updater-record write between ticks bumps generation; +// the next tick's landed-with-error write is still recognised as landed. +func TestBootstrapRecordThenLandedWrite(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + e.writeState(baseState("1.19.0", "")) + before := e.state().Generation + must(t, updatestate.SaveUpdaterRecord(e.L.StateFile(), "boot", func(string, int) (string, int) { return "1.19.0", 2 })) + if g := e.state().Generation; g != before+1 { + t.Fatalf("bootstrap record: generation %d, want %d", g, before+1) + } + failingSaves(t, true) + e.tick() + got := e.events() + if slices.Contains(got, EventSwitchFailed) || !slices.Contains(got, EventOK) { + t.Fatalf("events = %v", got) + } + if st := e.state(); st.Current != "1.20.0" || st.Updater.ConsecutiveFailures != 2 { + t.Fatalf("state = %+v", st) + } +} + +// FR1-1: a smoke TIMEOUT is retried; only the third consecutive timeout +// for the target quarantines it. +func TestSmokeTimeoutsQuarantineOnlyAfterThree(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + e.writeState(baseState("1.19.0", "")) + e.smokeErr["1.20.0"] = fmt.Errorf("%w after 60s", ErrSmokeTimeout) + for i := 1; i <= 3; i++ { + e.tick() + _, q := e.state().Quarantine["1.20.0"] + if q != (i == 3) { + t.Fatalf("after %d timeouts: quarantined=%v", i, q) + } + e.now = e.now.Add(time.Hour) + } + if hf := e.lastSent(EventHealthFailed); hf.To != "1.20.0" { + t.Fatalf("health_failed = %+v", hf) + } +} + +// The end-of-probation re-smoke follows the same rule. +func TestReSmokeTimeoutsRollBackOnlyAfterThree(t *testing.T) { + e := newEnv(t) + probationState(t, e) + e.smokeErr["1.20.0"] = fmt.Errorf("%w after 60s", ErrSmokeTimeout) + e.now = e.now.Add(73 * time.Hour) + for i := 1; i <= 3; i++ { + e.tick() + rolled := e.state().Current == "1.19.0" + if rolled != (i == 3) { + t.Fatalf("after %d re-smoke timeouts: rolled back=%v", i, rolled) + } + e.now = e.now.Add(time.Hour) + } + if rb := e.lastSent(EventRolledBack); rb.Error != ReasonSmokeFailed { + t.Fatalf("rollback = %+v", rb) + } +} + +// FR1-6: runs are counted by sequence after the switch, not by time: two +// crashes finalized with timestamps before the probation start (the clock +// went back between the switch and the runs) still roll back. +func TestProbationCountsRunsBySequence(t *testing.T) { + e := newEnv(t) + probationState(t, e) + pv := mustV("1.20.0") + must(t, updatestate.AppendRun(e.L.RunFile(pv), updatestate.RunRecord{RunID: "before-switch", Version: "1.20.0"})) + st := e.state() + since := updatestate.MaxRunSeq(e.L.RunFile(pv)) + st.Probation.SinceSeq = &since + e.writeState(st) + early := st.Probation.Since.Add(-time.Hour) + for i := 0; i < 2 && e.state().Current == "1.20.0"; i++ { + id := fmt.Sprintf("crash-%d", i) + must(t, updatestate.AppendRun(e.L.RunFile(pv), updatestate.RunRecord{RunID: id, Version: "1.20.0"})) + must(t, updatestate.FinalizeRun(e.L.RunFile(pv), id, 2, false, early)) + e.tick() + } + if rb := e.lastSentOrNil(EventRolledBack); rb == nil || rb.Error != ReasonBadRuns { + t.Fatalf("runs before the probation's timestamp were dropped: %+v", e.state()) + } +} + +// The switch records the runs file's current sequence. +func TestSwitchRecordsSinceSeq(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + e.writeState(baseState("1.19.0", "")) + pv := mustV("1.20.0") + for _, id := range []string{"a", "b"} { + must(t, updatestate.AppendRun(e.L.RunFile(pv), updatestate.RunRecord{RunID: id, Version: "1.20.0"})) + } + e.tick() + if p := e.state().Probation; p == nil || p.SinceSeq == nil || *p.SinceSeq != 2 { + t.Fatalf("probation = %+v, want since_seq 2", p) + } +} + +// A target that never ran (empty runs file) records since_seq 0, and the +// seq rule still applies to its runs. +func TestProbationSeqWithFreshTarget(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + e.writeState(baseState("1.19.0", "")) + e.tick() // switch to 1.20.0 + st := e.state() + if st.Probation == nil || st.Probation.SinceSeq == nil || *st.Probation.SinceSeq != 0 { + t.Fatalf("probation = %+v", st.Probation) + } + pv := mustV("1.20.0") + early := st.Probation.Since.Add(-time.Hour) + for i := 0; i < 2 && e.state().Current == "1.20.0"; i++ { + id := fmt.Sprintf("crash-%d", i) + must(t, updatestate.AppendRun(e.L.RunFile(pv), updatestate.RunRecord{RunID: id, Version: "1.20.0"})) + must(t, updatestate.FinalizeRun(e.L.RunFile(pv), id, 2, false, early)) + e.now = e.now.Add(time.Hour) + e.tick() + } + if e.state().Current != "1.19.0" { + t.Fatalf("not rolled back: %+v", e.state()) + } +} + +// D25/R-C2-15: an MSI pushed a newer seed but its adopt-seed CA timed out +// on the update lock; the next updater tick adopts it and reports it. +func TestUpdaterAdoptsNewerSeed(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + st := baseState("1.19.0", "") + st.LastCheck = e.now // no target check this tick + e.writeState(st) + e.writeSeed("1.21.0") + // The MSI's adopt-seed CA runs while an update tick holds the lock and + // gives up after its bounded wait. + held, err := updatestate.AcquireLock(e.L.LockFile()) + must(t, err) + b := launcher.NewBootstrap(filepath.Join(e.L.InstallRoot, "stepsecurity-dev-machine-guard-bootstrap.exe"), func() bool { return true }) + b.Layout, b.Out, b.AdoptLockWait, b.LockPoll = e.L, io.Discard, 200*time.Millisecond, 20*time.Millisecond + b.SetRunningVersion = nil + if code := b.AdoptSeed([]string{"--seed-dir", e.L.SeedDir(mustV("1.21.0"))}); code != 0 { + t.Fatalf("adopt-seed = %d", code) + } + held.Release() + if got := e.state(); got.Current != "1.19.0" { + t.Fatalf("adopt-seed adopted despite the held lock: %+v", got) + } + e.tick() + got := e.state() + if got.Current != "1.21.0" || got.Previous != "1.19.0" || got.Probation != nil { + t.Fatalf("seed not adopted: %+v", got) + } + if err := updatestate.VerifyVersionDir(e.L.VersionDir(mustV("1.21.0")), mustV("1.21.0")); err != nil { + t.Fatalf("versions\\1.21.0: %v", err) + } + ok := e.lastSent(EventOK) + if ok.From != "1.19.0" || ok.To != "1.21.0" || ok.Error != "seed_adopted" { + t.Fatalf("event = %+v", ok) + } +} + +func TestUpdaterIgnoresOlderSeed(t *testing.T) { + e := newEnv(t) + e.installVersion("1.20.0") + e.self = "1.20.0" + st := baseState("1.20.0", "") + st.LastCheck = e.now + e.writeState(st) + e.tick() // seed is 1.19.0 (newEnv) + if got := e.state(); got.Current != "1.20.0" { + t.Fatalf("older seed adopted: %+v", got) + } + if ev := e.lastSentOrNil(EventOK); ev != nil { + t.Fatalf("event sent: %+v", ev) + } +} + +// DMG-I5-1 at the updater: a missing signed_checksum reports +// update_verify_failed with no download. +func TestMissingSignedChecksumIsVerifyFailed(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + e.writeState(baseState("1.19.0", "")) + e.api.metaBody = `{"version":"1.20.0","checksum":"` + strings.Repeat("a", 64) + `","launcher_checksum":"` + strings.Repeat("b", 64) + `","signed_launcher_checksum":"x","linux_checksum":"` + strings.Repeat("c", 64) + `","signed_linux_checksum":"y"}` + e.tick() + wantEvents(t, e.events(), EventVerifyFailed) + if len(e.api.githubHits) != 0 { + t.Fatalf("downloaded: %v", e.api.githubHits) + } +} + +// FR1-7: a version whose scans hang after posting started (watchdog-killed) +// is rolled back after K=2 such runs. +func TestHangAfterStartedRollsBack(t *testing.T) { + e := newEnv(t) + probationState(t, e) + pv := mustV("1.20.0") + for i := 0; i < 2 && e.state().Current == "1.20.0"; i++ { + id := fmt.Sprintf("hang-%d", i) + must(t, updatestate.AppendRun(e.L.RunFile(pv), updatestate.RunRecord{RunID: id, Version: "1.20.0"})) + must(t, updatestate.MarkRunStarted(e.L.RunFile(pv), id)) + must(t, updatestate.FinalizeRun(e.L.RunFile(pv), id, 124, true, e.now)) + e.tick() + } + if rb := e.lastSentOrNil(EventRolledBack); rb == nil || rb.Error != ReasonBadRuns { + t.Fatalf("hung version not rolled back: %+v", e.state()) + } +} + +// DMG-I5-2: a stale adopt-seed snapshot (an MSI that never committed or +// rolled back) is swept by the updater; a fresh one is left for the MSI. +func TestUpdaterSweepsStaleAdoptSnapshot(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + st := baseState("1.19.0", "") + st.LastCheck = e.now + e.writeState(st) + snap := e.L.AdoptSnapshotFile() + must(t, updatestate.WriteAdoptSnapshot(snap, &updatestate.AdoptSnapshot{Seed: "1.19.0"})) + e.tick() + if _, err := os.Stat(snap); err != nil { + t.Fatalf("fresh snapshot swept: %v", err) + } + old := time.Now().Add(-2 * time.Hour) + must(t, os.Chtimes(snap, old, old)) + e.tick() + if _, err := os.Stat(snap); !os.IsNotExist(err) { + t.Fatalf("stale snapshot kept: %v", err) + } +} + +// FR1-8: no seed adoption while Windows Installer is mid-transaction; the +// next tick adopts. +func TestNoSeedAdoptionDuringMSI(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + st := baseState("1.19.0", "") + st.LastCheck = e.now + e.writeState(st) + e.writeSeed("1.21.0") + d := e.deps() + d.InstallerBusy = func() bool { return true } + Run(context.Background(), d) + if e.state().Current != "1.19.0" { + t.Fatal("adopted a seed while msiexec was running") + } + d.InstallerBusy = func() bool { return false } + Run(context.Background(), d) + if e.state().Current != "1.21.0" { + t.Fatalf("seed not adopted after the MSI finished: %+v", e.state()) + } +} + +// DMG-I5-5: an AV that keeps deleting a file from the confirmed current +// version gets at most 3 restages in 24 h, then one update_stage_failed +// (av_deleted) and a 24 h pause; the version is not quarantined. +func TestRestageBackoffAfterRepeatedDeletion(t *testing.T) { + e := newEnv(t) + e.installVersion("1.19.0") + e.installVersion("1.20.0") + e.self = "1.20.0" + e.writeState(baseState("1.20.0", "1.19.0")) + agent := filepath.Join(e.L.VersionDir(mustV("1.20.0")), updatestate.AgentFile) + downloads := func() int { return e.api.githubHits["stepsecurity-dev-machine-guard-1.20.0-windows_amd64.exe"] } + for i := 0; i < 6; i++ { + _ = os.Remove(agent) // the AV strikes again (already gone while paused) + e.tick() + e.now = e.now.Add(time.Hour) + } + if n := downloads(); n != 3 { + t.Fatalf("restaged %d times in 6 h, want 3", n) + } + var avEvents int + for _, ev := range e.all { + if ev.Event == EventStageFailed && ev.Error == "av_deleted" { + avEvents++ + } + } + st := e.state() + if avEvents != 1 || st.RestageBackoff["1.20.0"].IsZero() || st.Current != "1.20.0" { + t.Fatalf("events=%d backoff=%v current=%s", avEvents, st.RestageBackoff, st.Current) + } + if _, q := st.Quarantine["1.20.0"]; q { + t.Fatal("a healthy version was quarantined") + } + // After the backoff it is restaged again. + e.now = e.now.Add(25 * time.Hour) + e.tick() + if n := downloads(); n != 4 { + t.Fatalf("not restaged after the backoff (downloads %d)", n) + } + // Intact for 24 h: the counter resets. + e.now = e.now.Add(25 * time.Hour) + e.tick() + if r, ok := e.state().Restages["1.20.0"]; ok && r.Count != 0 { + t.Fatalf("restage counter not reset: %+v", r) + } +} diff --git a/internal/telemetry/exitcode.go b/internal/telemetry/exitcode.go new file mode 100644 index 00000000..501c8b93 --- /dev/null +++ b/internal/telemetry/exitcode.go @@ -0,0 +1,31 @@ +package telemetry + +import ( + "errors" + + "github.com/step-security/dev-machine-guard/internal/faultinject" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +// ErrPanic wraps a panic Run recovered from. The process must still end as +// a crash (exit 2), or the self-update health check would count a crashing +// version as healthy (panic after run-status "started") or neutral. +var ErrPanic = errors.New("panic in telemetry.Run") + +// ExitCode is the process exit code for Run's error: 2 for a recovered +// panic (the Go runtime's own panic code), 1 for any other failure. +func ExitCode(err error) int { + if errors.Is(err, ErrPanic) { + return 2 + } + return 1 +} + +// scanCheckpoint is a fault checkpoint inside the scan (selfupdate_faults +// builds only): a harness can make a version crash before or after it +// reports "started". An injected error is only logged. +func scanCheckpoint(log *progress.Logger, name string) { + if err := faultinject.Point(name); err != nil { + log.Debug("fault checkpoint %s: %v", name, err) + } +} diff --git a/internal/telemetry/main_test.go b/internal/telemetry/main_test.go new file mode 100644 index 00000000..9ab21f9a --- /dev/null +++ b/internal/telemetry/main_test.go @@ -0,0 +1,10 @@ +package telemetry + +import ( + "os" + "testing" + + "github.com/step-security/dev-machine-guard/internal/proctest" +) + +func TestMain(m *testing.M) { os.Exit(proctest.Main(m)) } diff --git a/internal/telemetry/panic_faults_test.go b/internal/telemetry/panic_faults_test.go new file mode 100644 index 00000000..f16f5202 --- /dev/null +++ b/internal/telemetry/panic_faults_test.go @@ -0,0 +1,149 @@ +//go:build selfupdate_faults + +package telemetry + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/cli" + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/lock" + "github.com/step-security/dev-machine-guard/internal/model" + "github.com/step-security/dev-machine-guard/internal/paths" + "github.com/step-security/dev-machine-guard/internal/progress" + "github.com/step-security/dev-machine-guard/internal/updatestate" + + "github.com/step-security/dev-machine-guard/internal/proctest" +) + +// FR1-2: a real panic in the scan, before or after run-status "started", +// is recovered by Run but must end the process as a crash (exit 2), so the +// run record classifies Bad, never Healthy or Neutral. +func TestScanPanicIsABadRun(t *testing.T) { + for _, cp := range []string{"scan.before-started", "scan.after-started"} { + t.Run(cp, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) + defer srv.Close() + defer withEnterpriseConfig(t, srv.URL)() + t.Setenv(paths.HomeEnvVar, t.TempDir()) + target := filepath.Join(t.TempDir(), "test.flock") + if runtime.GOOS == "windows" { + target = fmt.Sprintf("dmg-panic-test-%d-%d", os.Getpid(), time.Now().UnixNano()) + } + defer lock.SetTargetForTest(target)() + + runs := filepath.Join(t.TempDir(), "runs", "1.20.0.json") + if err := updatestate.AppendRun(runs, updatestate.RunRecord{RunID: "r1", Version: "1.20.0"}); err != nil { + t.Fatal(err) + } + t.Setenv(EnvRunRecord, runs) + t.Setenv(EnvRunID, "r1") + t.Setenv("DMG_FAULT", cp+":panic") + + mock := executor.NewMock() + mock.SetGOOS(model.PlatformLinux) + mock.SetFile("/sys/class/dmi/id/product_serial", []byte("SER-TEST-123\n")) + err := Run(mock, progress.NewLogger(progress.LevelError), &cli.Config{SearchDirs: []string{t.TempDir()}}) + if !errors.Is(err, ErrPanic) { + t.Fatalf("Run = %v, want ErrPanic", err) + } + code := ExitCode(err) + if err := updatestate.FinalizeRun(runs, "r1", code, false, time.Now()); err != nil { + t.Fatal(err) + } + rec := updatestate.ReadRuns(runs)[0] + if v := updatestate.ClassifyRun(rec); v != updatestate.RunBad { + t.Fatalf("panic %s: exit %d, started=%v → verdict %v, want Bad", cp, code, rec.Started, v) + } + }) + } +} + +// envHangScan names the env var that turns TestHelperHangScan into the +// hanging scan subprocess; its value is the fake backend URL. +const envHangScan = "DMG_TEST_HANG_SCAN" + +// TestHelperHangScan is a subprocess body: a real scan that blocks forever +// at scan.after-started. It does nothing in a normal test run. +func TestHelperHangScan(t *testing.T) { + endpoint := os.Getenv(envHangScan) + if endpoint == "" { + t.Skip("subprocess helper") + } + proctest.Guard() + defer withEnterpriseConfig(t, endpoint)() + defer lock.SetTargetForTest(os.Getenv(envHangScan + "_LOCK"))() + mock := executor.NewMock() + mock.SetGOOS(model.PlatformLinux) + mock.SetFile("/sys/class/dmi/id/product_serial", []byte("SER-TEST-123\n")) + _ = Run(mock, progress.NewLogger(progress.LevelError), &cli.Config{SearchDirs: []string{t.TempDir()}}) + os.Exit(3) // never reached: the scan hangs until killed +} + +// FR1-7: a real hang after run-status "started" (the scan blocks forever at +// scan.after-started) is ended by the bootstrap's watchdog and must count +// Bad, not Healthy. The hung scan runs in a subprocess that the "watchdog" +// kills and reaps, so nothing of it lives on in the test process. +func TestScanHangAfterStartedIsABadRun(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) + defer srv.Close() + target := filepath.Join(t.TempDir(), "test.flock") + if runtime.GOOS == "windows" { + target = fmt.Sprintf("dmg-hang-test-%d-%d", os.Getpid(), time.Now().UnixNano()) + } + runs := filepath.Join(t.TempDir(), "runs", "1.20.0.json") + if err := updatestate.AppendRun(runs, updatestate.RunRecord{RunID: "h1", Version: "1.20.0"}); err != nil { + t.Fatal(err) + } + cmd := proctest.Command(t, os.Args[0], "-test.run=^TestHelperHangScan$") + cmd.Env = append(cmd.Env, + envHangScan+"="+srv.URL, envHangScan+"_LOCK="+target, + paths.HomeEnvVar+"="+t.TempDir(), + EnvRunRecord+"="+runs, EnvRunID+"=h1", + "DMG_FAULT=scan.after-started:hang", + "GORACE=atexit_sleep_ms=0") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + reaped := false + reap := func() { + if !reaped { + reaped = true + _ = cmd.Process.Kill() + _ = cmd.Wait() + } + } + defer reap() // before the fixtures' own cleanup + deadline := time.Now().Add(60 * time.Second) + for !updatestate.ReadRuns(runs)[0].Started { + if time.Now().After(deadline) { + t.Fatal("the scan never posted started") + } + time.Sleep(20 * time.Millisecond) + } + // The bootstrap's watchdog fires: it kills the scan, exit 124, + // watchdog_killed. + reap() + if err := updatestate.FinalizeRun(runs, "h1", 124, true, time.Now()); err != nil { + t.Fatal(err) + } + if v := updatestate.ClassifyRun(updatestate.ReadRuns(runs)[0]); v != updatestate.RunBad { + t.Fatalf("hang after started: verdict %v, want Bad", v) + } + // Copilot 4356483353: the hung scan must not live on in the test + // process holding the scan lock. + defer lock.SetTargetForTest(target)() + l, err := lock.Acquire(nil) + if err != nil { + t.Fatalf("the hung scan still holds the scan lock: %v", err) + } + l.Release() +} diff --git a/internal/telemetry/run_status.go b/internal/telemetry/run_status.go index 1e9bf601..db80c158 100644 --- a/internal/telemetry/run_status.go +++ b/internal/telemetry/run_status.go @@ -87,17 +87,19 @@ func ReportFatal(reason string, timeout time.Duration) { // whether this run was triggered by an installed scheduler or a manual CLI // invocation; an empty string is tolerated (the backend treats it as // "unknown") so this stays callable from contexts that haven't detected it. +// +// It reports whether the backend accepted the post (2xx). func reportRunStatus(ctx context.Context, log *progress.Logger, - executionID, deviceID, status, errMsg, invocationMethod string) { + executionID, deviceID, status, errMsg, invocationMethod string) bool { if !config.IsEnterpriseMode() { - return + return false } if status != runStatusStarted && status != runStatusFailed { - return + return false } if executionID == "" { - return + return false } body := runStatusBody{ @@ -123,7 +125,7 @@ func reportRunStatus(ctx context.Context, log *progress.Logger, if status == runStatusStarted { attempts = runStatusStartedAttempts } - postRunStatusWithRetry(ctx, log, body, attempts) + return postRunStatusWithRetry(ctx, log, body, attempts) } // postProgress sends an idempotent in-flight progress snapshot. The backend @@ -155,12 +157,12 @@ func postProgress(ctx context.Context, log *progress.Logger, } func postRunStatusWithRetry(ctx context.Context, log *progress.Logger, - body runStatusBody, attempts int) { + body runStatusBody, attempts int) (accepted bool) { encoded, err := json.Marshal(body) if err != nil { log.Progress("run-status: marshal error: %v", err) - return + return false } endpoint := fmt.Sprintf("%s/v1/%s/developer-mdm-agent/telemetry/run-status", @@ -179,18 +181,20 @@ func postRunStatusWithRetry(ctx context.Context, log *progress.Logger, // noise line in the normal "scan finished successfully, // last progress post got cut off by cancelRun()" flow. log.Debug("run-status: parent context done, abandoning retries") - return + return false } } - if postRunStatusOnce(ctx, log, endpoint, encoded, body.Status, i, attempts) { - return + if done, ok := postRunStatusOnce(ctx, log, endpoint, encoded, body.Status, i, attempts); done { + return ok } } + return false } -// postRunStatusOnce performs a single HTTP attempt. Returns true on a 2xx -// or 4xx (terminal — retrying a bad request will not help). Returns false -// on transport errors or 5xx so the caller can retry. +// postRunStatusOnce performs a single HTTP attempt. done is true on a 2xx +// or 4xx (terminal — retrying a bad request will not help) and false on +// transport errors or 5xx so the caller can retry; accepted is true only +// on a 2xx. // // Treats parent-ctx cancellation as terminal-and-quiet: the only time we // observe ctx.Err() != nil here is shutdown (cancelRun fired). Logging a @@ -198,13 +202,13 @@ func postRunStatusWithRetry(ctx context.Context, log *progress.Logger, // scan — because the final phase-boundary post raced the deferred // cancelRun — would mislead operators into thinking the run failed. func postRunStatusOnce(ctx context.Context, log *progress.Logger, - endpoint string, body []byte, status string, attempt, maxAttempts int) bool { + endpoint string, body []byte, status string, attempt, maxAttempts int) (done, accepted bool) { // Short-circuit when the scan ctx is already done — no point opening a // connection just to have it cancelled mid-handshake. if ctx.Err() != nil { log.Debug("run-status[%s %d/%d]: skipped (parent context done)", status, attempt, maxAttempts) - return true + return true, false } cctx, cancel := context.WithTimeout(ctx, runStatusHTTPTimeout) @@ -213,7 +217,7 @@ func postRunStatusOnce(ctx context.Context, log *progress.Logger, req, err := http.NewRequestWithContext(cctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { log.Progress("run-status[%s %d/%d]: request error: %v", status, attempt, maxAttempts, err) - return false + return false, false } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+config.APIKey) @@ -228,20 +232,20 @@ func postRunStatusOnce(ctx context.Context, log *progress.Logger, // on an already-cancelled context. if ctx.Err() != nil { log.Debug("run-status[%s %d/%d]: aborted at shutdown: %v", status, attempt, maxAttempts, err) - return true + return true, false } log.Progress("run-status[%s %d/%d]: POST error: %v", status, attempt, maxAttempts, err) - return false + return false, false } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 300 { - return true + return true, true } if resp.StatusCode >= 400 && resp.StatusCode < 500 { log.Progress("run-status[%s]: HTTP %d (terminal, no retry)", status, resp.StatusCode) - return true + return true, false } log.Progress("run-status[%s %d/%d]: HTTP %d from backend", status, attempt, maxAttempts, resp.StatusCode) - return false + return false, false } diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 32d130b2..bae41918 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -395,7 +395,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err close(sigHandlerDone) if r := recover(); r != nil { - err = fmt.Errorf("panic in telemetry.Run: %v", r) + err = fmt.Errorf("%w: %v", ErrPanic, r) reportFailedOnce(err.Error()) return } @@ -569,8 +569,15 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err log.Debug("scan-state: using legacy full-snapshot uploads") } - // Report "started" now that we have a device_id. Fire-and-forget. - reportRunStatus(ctx, log, executionID, deviceID, runStatusStarted, "", invocationMethod) + // Report "started" now that we have a device_id. Once the backend has + // it, mark the bootstrap's run record (self-update health, spec §4). + scanCheckpoint(log, "scan.before-started") + if reportRunStatus(ctx, log, executionID, deviceID, runStatusStarted, "", invocationMethod) { + if err := markRunStarted(); err != nil { + log.Debug("run record: %v", err) + } + } + scanCheckpoint(log, "scan.after-started") // First progress upsert: surfaces device_info completion immediately // without waiting for the 5-minute heartbeat, and FORCE-attaches the log diff --git a/internal/telemetry/update_event.go b/internal/telemetry/update_event.go new file mode 100644 index 00000000..4c11576b --- /dev/null +++ b/internal/telemetry/update_event.go @@ -0,0 +1,158 @@ +package telemetry + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/buildinfo" + "github.com/step-security/dev-machine-guard/internal/config" + "github.com/step-security/dev-machine-guard/internal/updatestate" +) + +const ( + runStatusUpdate = "update" + invocationMethodUpdate = "update" + updateEventAttempts = 3 + updateEventHTTPTimeout = 10 * time.Second + updateEventRetryBackoff = time.Second + updateEventMaxError = 2048 + runLockWait = 5 * time.Second +) + +// UpdateEvent is one self-update event (spec Contracts: Update event), +// posted to run-status with status "update". +type UpdateEvent struct { + ExecutionID string + DeviceID string + InstallChannel string // "msi" | "loader" + Event string + From, To string + Error string +} + +type updateEventBody struct { + ExecutionID string `json:"execution_id"` + DeviceID string `json:"device_id"` + Status string `json:"status"` + AgentVersion string `json:"agent_version"` + Platform string `json:"platform"` + InvocationMethod string `json:"invocation_method"` + InstallChannel string `json:"install_channel"` + UpdateEvent string `json:"update_event"` + UpdateFrom string `json:"update_from,omitempty"` + UpdateTo string `json:"update_to,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} + +// ErrUpdateEventRejected means the backend answered 400/422: retrying the +// same event cannot help. +var ErrUpdateEventRejected = errors.New("run-status rejected the update event") + +// PostUpdateEvent posts ev with a small retry budget. agent_version is +// always this process's version. It returns nil only on a 2xx, and +// ErrUpdateEventRejected only for 400/422 (or an event that can't be valid). +func PostUpdateEvent(ctx context.Context, client *http.Client, ev UpdateEvent) error { + if !config.IsEnterpriseMode() { + return errors.New("not in enterprise mode") + } + if ev.DeviceID == "" { + return errors.New("no device id") + } + // agent-api requires install_channel and, for a rollback, update_from; + // such an event can never be accepted, so it is reported as rejected. + if ev.InstallChannel != "msi" && ev.InstallChannel != "loader" { + return fmt.Errorf("%w: install_channel %q", ErrUpdateEventRejected, ev.InstallChannel) + } + if ev.Event == "update_rolled_back" && ev.From == "" { + return fmt.Errorf("%w: update_rolled_back without update_from", ErrUpdateEventRejected) + } + if len(ev.Error) > updateEventMaxError { + ev.Error = ev.Error[:updateEventMaxError] + } + body, err := json.Marshal(updateEventBody{ + ExecutionID: ev.ExecutionID, DeviceID: ev.DeviceID, Status: runStatusUpdate, + AgentVersion: buildinfo.Version, Platform: runtime.GOOS, InvocationMethod: invocationMethodUpdate, + InstallChannel: ev.InstallChannel, UpdateEvent: ev.Event, + UpdateFrom: ev.From, UpdateTo: ev.To, ErrorMessage: ev.Error, + }) + if err != nil { + return err + } + endpoint := fmt.Sprintf("%s/v1/%s/developer-mdm-agent/telemetry/run-status", config.APIEndpoint, config.CustomerID) + if client == nil { + client = http.DefaultClient + } + var last error + for i := 0; i < updateEventAttempts; i++ { + if i > 0 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(updateEventRetryBackoff): + } + } + last = postUpdateEventOnce(ctx, client, endpoint, body) + if last == nil || errors.Is(last, ErrUpdateEventRejected) { + return last + } + } + return last +} + +func postUpdateEventOnce(ctx context.Context, client *http.Client, endpoint string, body []byte) error { + ctx, cancel := context.WithTimeout(ctx, updateEventHTTPTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+config.APIKey) + req.Header.Set("X-Agent-Version", buildinfo.Version) + resp, err := client.Do(req) + if err != nil { + return err + } + _ = resp.Body.Close() + switch { + case resp.StatusCode < 300: + return nil + case resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusUnprocessableEntity: + // A permanent validation reject: the same body can never succeed. + return fmt.Errorf("%w: HTTP %d", ErrUpdateEventRejected, resp.StatusCode) + } + // 401/403 (key rotation, WAF), 408, 429 and 5xx are transient. + return fmt.Errorf("run-status: HTTP %d", resp.StatusCode) +} + +// Env set by the bootstrap for scan runs (spec Contracts: Run record). +const ( + EnvRunRecord = "STEPSEC_RUN_RECORD" + EnvRunID = "STEPSEC_RUN_ID" + EnvBootstrapVersion = "STEPSEC_BOOTSTRAP_VERSION" + EnvBootstrapFallback = "STEPSEC_BOOTSTRAP_FALLBACK" +) + +// markRunStarted sets the started marker on this launch's record +// (STEPSEC_RUN_ID) in the bootstrap's run file right after run-status +// "started" was accepted. Best-effort. +func markRunStarted() error { + path, runID := os.Getenv(EnvRunRecord), os.Getenv(EnvRunID) + if path == "" || runID == "" { + return nil + } + if !filepath.IsAbs(path) || !strings.EqualFold(filepath.Ext(path), ".json") { + return fmt.Errorf("%s=%q is not an absolute .json path", EnvRunRecord, path) + } + // Same lock and bounded wait as the bootstrap's run-record writes. + return updatestate.MarkRunStartedLocked(path, runID, runLockWait) +} diff --git a/internal/telemetry/update_event_test.go b/internal/telemetry/update_event_test.go new file mode 100644 index 00000000..9e60b73d --- /dev/null +++ b/internal/telemetry/update_event_test.go @@ -0,0 +1,187 @@ +package telemetry + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "runtime" + "sync/atomic" + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/buildinfo" + "github.com/step-security/dev-machine-guard/internal/progress" + "github.com/step-security/dev-machine-guard/internal/updatestate" +) + +func TestPostUpdateEventPayload(t *testing.T) { + var got map[string]any + var path, auth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path, auth = r.URL.Path, r.Header.Get("Authorization") + b, _ := io.ReadAll(r.Body) + got = nil + _ = json.Unmarshal(b, &got) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + defer withEnterpriseConfig(t, srv.URL)() + + err := PostUpdateEvent(context.Background(), srv.Client(), UpdateEvent{ + ExecutionID: "e-1", DeviceID: "dev-1", InstallChannel: "msi", + Event: "update_rolled_back", From: "1.20.0", To: "1.19.0", Error: "bad_runs", + }) + if err != nil { + t.Fatal(err) + } + if path != "/v1/test-customer/developer-mdm-agent/telemetry/run-status" || auth != "Bearer sk-test-123" { + t.Fatalf("path=%s auth=%s", path, auth) + } + want := map[string]any{ + "execution_id": "e-1", "device_id": "dev-1", "status": "update", "agent_version": buildinfo.Version, + "platform": runtime.GOOS, "invocation_method": "update", "install_channel": "msi", + "update_event": "update_rolled_back", "update_from": "1.20.0", "update_to": "1.19.0", "error_message": "bad_runs", + } + if len(got) != len(want) { + t.Fatalf("payload keys = %v", got) + } + for k, v := range want { + if got[k] != v { + t.Errorf("%s = %v, want %v", k, got[k], v) + } + } + + // Optional fields are omitted when empty; error_message is capped. + long := make([]byte, 5000) + for i := range long { + long[i] = 'x' + } + if err := PostUpdateEvent(context.Background(), srv.Client(), UpdateEvent{ + ExecutionID: "e-2", DeviceID: "dev-1", InstallChannel: "msi", Event: "update_check_failed", Error: string(long), + }); err != nil { + t.Fatal(err) + } + if _, ok := got["update_from"]; ok { + t.Fatal("empty update_from sent") + } + if n := len(got["error_message"].(string)); n != 2048 { + t.Fatalf("error_message len = %d", n) + } +} + +func TestPostUpdateEventRetriesAndRejects(t *testing.T) { + var calls atomic.Int32 + status := atomic.Int32{} + status.Store(503) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.WriteHeader(int(status.Load())) + })) + defer srv.Close() + defer withEnterpriseConfig(t, srv.URL)() + ev := UpdateEvent{ExecutionID: "e", DeviceID: "d", InstallChannel: "msi", Event: "update_ok"} + + if err := PostUpdateEvent(context.Background(), srv.Client(), ev); err == nil || errors.Is(err, ErrUpdateEventRejected) { + t.Fatalf("5xx: %v", err) + } + if calls.Load() != updateEventAttempts { + t.Fatalf("attempts = %d", calls.Load()) + } + calls.Store(0) + status.Store(400) + if err := PostUpdateEvent(context.Background(), srv.Client(), ev); !errors.Is(err, ErrUpdateEventRejected) { + t.Fatalf("4xx: %v", err) + } + if calls.Load() != 1 { + t.Fatalf("4xx retried: %d", calls.Load()) + } + calls.Store(0) + for _, bad := range []UpdateEvent{ + {ExecutionID: "e", DeviceID: "d", Event: "update_ok"}, + {ExecutionID: "e", DeviceID: "d", InstallChannel: "msi", Event: "update_rolled_back", To: "1.19.0"}, + } { + if err := PostUpdateEvent(context.Background(), srv.Client(), bad); !errors.Is(err, ErrUpdateEventRejected) { + t.Fatalf("%+v: %v", bad, err) + } + } + if calls.Load() != 0 { + t.Fatal("invalid event reached the backend") + } + ev.DeviceID = "" + if err := PostUpdateEvent(context.Background(), srv.Client(), ev); err == nil { + t.Fatal("event without device id posted") + } +} + +func TestStartedMarkerOnlyAfterAcceptedStarted(t *testing.T) { + status := atomic.Int32{} + status.Store(200) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(int(status.Load())) + })) + defer srv.Close() + defer withEnterpriseConfig(t, srv.URL)() + log := progress.NewLogger(progress.LevelError) + + if !reportRunStatus(context.Background(), log, "x", "d", runStatusStarted, "", "install") { + t.Fatal("2xx not reported as accepted") + } + status.Store(400) + if reportRunStatus(context.Background(), log, "x", "d", runStatusStarted, "", "install") { + t.Fatal("4xx reported as accepted") + } + + runs := filepath.Join(t.TempDir(), "runs", "1.20.0.json") + for _, id := range []string{"run-a", "run-b"} { + if err := updatestate.AppendRun(runs, updatestate.RunRecord{RunID: id, Version: "1.20.0", At: time.Now()}); err != nil { + t.Fatal(err) + } + } + t.Setenv(EnvRunRecord, runs) + t.Setenv(EnvRunID, "run-a") + if err := markRunStarted(); err != nil { + t.Fatal(err) + } + recs := updatestate.ReadRuns(runs) + if len(recs) != 2 || !recs[0].Started || recs[1].Started { + t.Fatalf("records = %+v", recs) + } + t.Setenv(EnvRunRecord, "relative.json") + if err := markRunStarted(); err == nil { + t.Fatal("relative run record path accepted") + } + t.Setenv(EnvRunRecord, "") + if err := markRunStarted(); err != nil { + t.Fatalf("unset env: %v", err) + } +} + +// R-C2-3: only a permanent validation reject (400/422) drops an event; +// auth, timeout, rate-limit and server errors are retried. +func TestUpdateEventStatusPolicy(t *testing.T) { + status := atomic.Int32{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(int(status.Load())) + })) + defer srv.Close() + defer withEnterpriseConfig(t, srv.URL)() + ev := UpdateEvent{ExecutionID: "e", DeviceID: "d", InstallChannel: "msi", Event: "update_ok"} + for code, drop := range map[int]bool{400: true, 422: true, 401: false, 403: false, 408: false, 429: false, 500: false, 503: false} { + status.Store(int32(code)) + err := PostUpdateEvent(context.Background(), srv.Client(), ev) + if err == nil || errors.Is(err, ErrUpdateEventRejected) != drop { + t.Errorf("HTTP %d: err=%v, want dropped=%v", code, err, drop) + } + } +} + +func TestExitCodeForRunErrors(t *testing.T) { + if ExitCode(fmt.Errorf("%w: boom", ErrPanic)) != 2 || ExitCode(errors.New("offline")) != 1 { + t.Fatal("ExitCode mapping wrong") + } +} diff --git a/internal/updatestate/acl.go b/internal/updatestate/acl.go new file mode 100644 index 00000000..f12320a0 --- /dev/null +++ b/internal/updatestate/acl.go @@ -0,0 +1,22 @@ +package updatestate + +import ( + "fmt" + "os" +) + +// checkDirNotReparse rejects anything at path that is not a plain directory +// (files, symlinks, junctions), so ACL work never follows a redirect. +func checkDirNotReparse(path string) (exists bool, err error) { + fi, err := os.Lstat(path) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + if !fi.IsDir() || fi.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 { + return true, fmt.Errorf("updatestate: %s is not a plain directory", path) + } + return true, nil +} diff --git a/internal/updatestate/acl_other.go b/internal/updatestate/acl_other.go new file mode 100644 index 00000000..3d525af5 --- /dev/null +++ b/internal/updatestate/acl_other.go @@ -0,0 +1,19 @@ +//go:build !windows + +package updatestate + +import "os" + +// EnsureProtectedDir creates path if missing. Off Windows there is no +// protected-DACL model to enforce, so it never reports drift. +func EnsureProtectedDir(path string) (fixed bool, err error) { + exists, err := checkDirNotReparse(path) + if err != nil || exists { + return false, err + } + // #nosec G301 -- versions\ and state\ are world-readable by design (Users RX). + if err := os.Mkdir(path, 0o755); err != nil && !os.IsExist(err) { + return false, err + } + return false, nil +} diff --git a/internal/updatestate/acl_other_test.go b/internal/updatestate/acl_other_test.go new file mode 100644 index 00000000..57bfb0e7 --- /dev/null +++ b/internal/updatestate/acl_other_test.go @@ -0,0 +1,34 @@ +//go:build !windows + +package updatestate + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEnsureProtectedDirStub(t *testing.T) { + dir := filepath.Join(t.TempDir(), "versions") + for i := 0; i < 2; i++ { + if fixed, err := EnsureProtectedDir(dir); fixed || err != nil { + t.Fatalf("EnsureProtectedDir = %v, %v", fixed, err) + } + } + if fi, err := os.Stat(dir); err != nil || !fi.IsDir() { + t.Fatalf("dir not created: %v", err) + } + file := filepath.Join(t.TempDir(), "f") + if err := os.WriteFile(file, nil, 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(t.TempDir(), "l") + if err := os.Symlink(dir, link); err != nil { + t.Fatal(err) + } + for _, p := range []string{file, link} { + if _, err := EnsureProtectedDir(p); err == nil { + t.Errorf("EnsureProtectedDir(%s) accepted a non-directory", p) + } + } +} diff --git a/internal/updatestate/acl_windows.go b/internal/updatestate/acl_windows.go new file mode 100644 index 00000000..0a296b07 --- /dev/null +++ b/internal/updatestate/acl_windows.go @@ -0,0 +1,175 @@ +//go:build windows + +package updatestate + +import ( + "errors" + "fmt" + "runtime" + "unsafe" + + "golang.org/x/sys/windows" +) + +// protectedDirSDDL: owner SYSTEM; protected DACL (no inheritance from the +// parent) granting SYSTEM and Administrators full control and Users +// read+execute, each inherited by subdirectories and files. +const protectedDirSDDL = "O:SYD:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;0x1200a9;;;BU)" + +const ( + fileAllAccess windows.ACCESS_MASK = 0x001F01FF // FA + fileReadExecute windows.ACCESS_MASK = 0x001200A9 // RX + inheritOICI = windows.OBJECT_INHERIT_ACE | windows.CONTAINER_INHERIT_ACE +) + +// EnsureProtectedDir creates path if missing and makes its owner SYSTEM and +// its DACL protected: SYSTEM F, Administrators F, Users RX, inherited by +// children. It reports whether an existing directory had drifted and was +// fixed; creating the directory is not drift. Re-applying the DACL +// propagates it to children that inherit. Needs SYSTEM or an elevated +// administrator (setting another principal as owner takes +// SeRestorePrivilege, which is enabled for the call). The parent must exist +// and is trusted not to be writable by non-admins. +func EnsureProtectedDir(path string) (fixed bool, err error) { + err = withOwnerPrivileges(func() error { + exists, err := checkDirNotReparse(path) + if err != nil { + return err + } + if !exists { + err = createProtectedDir(path) + if !errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + return err + } + if _, err = checkDirNotReparse(path); err != nil { + return err + } + } + ok, err := hasProtectedACL(path) + if err != nil || ok { + return err + } + fixed = true + return applyProtectedACL(path) + }) + return fixed, err +} + +func createProtectedDir(path string) error { + sd, err := windows.SecurityDescriptorFromString(protectedDirSDDL) + if err != nil { + return err + } + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return err + } + sa := windows.SecurityAttributes{SecurityDescriptor: sd} + sa.Length = uint32(unsafe.Sizeof(sa)) + return windows.CreateDirectory(p, &sa) +} + +func applyProtectedACL(path string) error { + sd, err := windows.SecurityDescriptorFromString(protectedDirSDDL) + if err != nil { + return err + } + owner, _, err := sd.Owner() + if err != nil { + return err + } + dacl, _, err := sd.DACL() + if err != nil { + return err + } + if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + owner, nil, dacl, nil); err != nil { + return fmt.Errorf("updatestate: set ACL on %s: %w", path, err) + } + return nil +} + +// hasProtectedACL reports whether path has exactly the wanted owner and +// protected DACL. +func hasProtectedACL(path string) (bool, error) { + sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION) + if err != nil { + return false, fmt.Errorf("updatestate: read ACL of %s: %w", path, err) + } + owner, _, err := sd.Owner() + if err != nil || owner == nil || !owner.IsWellKnown(windows.WinLocalSystemSid) { + return false, nil + } + control, _, err := sd.Control() + if err != nil || control&windows.SE_DACL_PROTECTED == 0 { + return false, nil + } + acl, _, err := sd.DACL() + if err != nil || acl == nil || acl.AceCount != 3 { + return false, nil + } + want := map[windows.WELL_KNOWN_SID_TYPE]windows.ACCESS_MASK{ + windows.WinLocalSystemSid: fileAllAccess, + windows.WinBuiltinAdministratorsSid: fileAllAccess, + windows.WinBuiltinUsersSid: fileReadExecute, + } + for i := uint32(0); i < uint32(acl.AceCount); i++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(acl, i, &ace); err != nil { + return false, nil + } + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE || ace.Header.AceFlags != inheritOICI { + return false, nil + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + matched := false + for kind, mask := range want { + if sid.IsWellKnown(kind) && ace.Mask == mask { + delete(want, kind) + matched = true + break + } + } + if !matched { + return false, nil + } + } + return len(want) == 0, nil +} + +// withOwnerPrivileges runs fn on a dedicated OS thread impersonating the +// process token with SeRestorePrivilege and SeTakeOwnershipPrivilege enabled +// where held. The thread is never unlocked, so the runtime discards it (and +// any impersonation) when the goroutine exits. +func withOwnerPrivileges(fn func() error) error { + done := make(chan error, 1) + go func() { + runtime.LockOSThread() + if err := windows.ImpersonateSelf(windows.SecurityImpersonation); err == nil { + defer func() { _ = windows.RevertToSelf() }() + enableThreadPrivileges("SeRestorePrivilege", "SeTakeOwnershipPrivilege") + } + done <- fn() + }() + return <-done +} + +func enableThreadPrivileges(names ...string) { + var tok windows.Token + if windows.OpenThreadToken(windows.CurrentThread(), windows.TOKEN_ADJUST_PRIVILEGES|windows.TOKEN_QUERY, false, &tok) != nil { + return + } + defer tok.Close() + for _, name := range names { + var luid windows.LUID + p, err := windows.UTF16PtrFromString(name) + if err != nil || windows.LookupPrivilegeValue(nil, p, &luid) != nil { + continue + } + tp := windows.Tokenprivileges{PrivilegeCount: 1} + tp.Privileges[0] = windows.LUIDAndAttributes{Luid: luid, Attributes: windows.SE_PRIVILEGE_ENABLED} + _ = windows.AdjustTokenPrivileges(tok, false, &tp, 0, nil, nil) + } +} diff --git a/internal/updatestate/acl_windows_test.go b/internal/updatestate/acl_windows_test.go new file mode 100644 index 00000000..452430b6 --- /dev/null +++ b/internal/updatestate/acl_windows_test.go @@ -0,0 +1,131 @@ +//go:build windows + +package updatestate + +import ( + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/windows" +) + +func requireElevated(t *testing.T) { + t.Helper() + if !windows.GetCurrentProcessToken().IsElevated() { + t.Skip("needs an elevated (administrator) process") + } +} + +func TestEnsureProtectedDir(t *testing.T) { + requireElevated(t) + dir := filepath.Join(t.TempDir(), "versions") + + fixed, err := EnsureProtectedDir(dir) + if err != nil || fixed { + t.Fatalf("create: fixed=%v err=%v", fixed, err) + } + if ok, err := hasProtectedACL(dir); !ok || err != nil { + t.Fatalf("new dir lacks the protected ACL: %v", err) + } + if fixed, err = EnsureProtectedDir(dir); err != nil || fixed { + t.Fatalf("no drift: fixed=%v err=%v", fixed, err) + } + + // Children inherit the three ACEs. + child := filepath.Join(dir, "1.19.0") + if err := os.Mkdir(child, 0o755); err != nil { + t.Fatal(err) + } + if n := inheritedAces(t, child); n != 3 { + t.Fatalf("child has %d inherited ACEs, want 3", n) + } + + everyone, _ := windows.CreateWellKnownSid(windows.WinWorldSid) + admins, _ := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + drifts := map[string]func(){ + "unprotected": func() { + setSecurity(t, dir, windows.DACL_SECURITY_INFORMATION|windows.UNPROTECTED_DACL_SECURITY_INFORMATION, nil, + "D:(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;0x1200a9;;;BU)") + }, + "extra ace": func() { + setSecurity(t, dir, windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, + "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;0x1200a9;;;BU)(A;OICI;FA;;;"+everyone.String()+")") + }, + "users modify": func() { + setSecurity(t, dir, windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, + "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;0x1301bf;;;BU)") + }, + "owner": func() { + setSecurity(t, dir, windows.OWNER_SECURITY_INFORMATION, admins, "") + }, + } + for name, drift := range drifts { + t.Run(name, func(t *testing.T) { + drift() + if ok, _ := hasProtectedACL(dir); ok { + t.Fatal("drift not detected") + } + if fixed, err := EnsureProtectedDir(dir); err != nil || !fixed { + t.Fatalf("fix: fixed=%v err=%v", fixed, err) + } + if ok, err := hasProtectedACL(dir); !ok || err != nil { + t.Fatalf("not fixed: %v", err) + } + if fixed, err := EnsureProtectedDir(dir); err != nil || fixed { + t.Fatalf("after fix: fixed=%v err=%v", fixed, err) + } + }) + } + + file := filepath.Join(t.TempDir(), "f") + if err := os.WriteFile(file, nil, 0o600); err != nil { + t.Fatal(err) + } + if _, err := EnsureProtectedDir(file); err == nil { + t.Fatal("accepted a file") + } +} + +func setSecurity(t *testing.T, path string, info windows.SECURITY_INFORMATION, owner *windows.SID, sddl string) { + t.Helper() + var dacl *windows.ACL + if sddl != "" { + sd, err := windows.SecurityDescriptorFromString(sddl) + if err != nil { + t.Fatal(err) + } + if dacl, _, err = sd.DACL(); err != nil { + t.Fatal(err) + } + } + err := withOwnerPrivileges(func() error { + return windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, info, owner, nil, dacl, nil) + }) + if err != nil { + t.Fatalf("set drift: %v", err) + } +} + +func inheritedAces(t *testing.T, path string) int { + t.Helper() + sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatal(err) + } + acl, _, err := sd.DACL() + if err != nil { + t.Fatal(err) + } + n := 0 + for i := uint32(0); i < uint32(acl.AceCount); i++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(acl, i, &ace); err != nil { + t.Fatal(err) + } + if ace.Header.AceFlags&windows.INHERITED_ACE != 0 { + n++ + } + } + return n +} diff --git a/internal/updatestate/adopt_snapshot.go b/internal/updatestate/adopt_snapshot.go new file mode 100644 index 00000000..ab02061c --- /dev/null +++ b/internal/updatestate/adopt_snapshot.go @@ -0,0 +1,85 @@ +package updatestate + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "time" +) + +// AdoptSnapshotMaxAge bounds how long an adopt-seed snapshot is honoured: an +// MSI transaction is far shorter, so an older one belongs to an MSI that +// ended without its commit or rollback action and is swept, never applied. +const AdoptSnapshotMaxAge = time.Hour + +// AdoptSnapshot is what adopt-seed recorded before changing anything, so +// the MSI's rollback action can undo it if the transaction fails. +type AdoptSnapshot struct { + Seed string `json:"seed"` + StatePresent bool `json:"state_present"` + State []byte `json:"state,omitempty"` // state.json bytes before adopt-seed + CreatedDir bool `json:"created_dir"` // adopt-seed created versions\ + // AfterGeneration is the generation adopt-seed wrote; nil when it left + // state.json unchanged. A rollback restores state only while the file + // still carries it (nobody wrote since). + AfterGeneration *uint64 `json:"after_generation,omitempty"` +} + +// AdoptSnapshotFile is state\adopt-snapshot.json. +func (l Layout) AdoptSnapshotFile() string { return filepath.Join(l.StateDir(), "adopt-snapshot.json") } + +// WriteAdoptSnapshot writes s atomically. +func WriteAdoptSnapshot(path string, s *AdoptSnapshot) error { + data, err := json.Marshal(s) + if err != nil { + return err + } + return writeAtomic(path, data, nil, nil) +} + +// ReadAdoptSnapshot returns the snapshot and its age by file time; a +// missing file is (nil, 0, nil). +func ReadAdoptSnapshot(path string) (*AdoptSnapshot, time.Duration, error) { + fi, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil, 0, nil + } + if err != nil { + return nil, 0, err + } + data, err := readBounded(path, 2*maxStateSize) + if err != nil { + return nil, 0, err + } + var s AdoptSnapshot + if err := json.Unmarshal(data, &s); err != nil { + return nil, 0, err + } + return &s, time.Since(fi.ModTime()), nil +} + +// SweepStaleAdoptSnapshot removes a snapshot older than AdoptSnapshotMaxAge +// (or unreadable). Callers hold the update lock. +func SweepStaleAdoptSnapshot(path string) { + s, age, err := ReadAdoptSnapshot(path) + if err != nil || (s != nil && age > AdoptSnapshotMaxAge) { + _ = os.Remove(path) + } +} + +// StateGeneration reads state.json's generation without full validation +// (0 when absent). +func StateGeneration(path string) (uint64, bool) { + data, err := readBounded(path, maxStateSize) + if err != nil { + return 0, false + } + var g struct { + Generation uint64 `json:"generation"` + } + if json.Unmarshal(data, &g) != nil { + return 0, false + } + return g.Generation, true +} diff --git a/internal/updatestate/atomic.go b/internal/updatestate/atomic.go new file mode 100644 index 00000000..de686e43 --- /dev/null +++ b/internal/updatestate/atomic.go @@ -0,0 +1,67 @@ +package updatestate + +import ( + "os" + "path/filepath" + "strings" +) + +// writeAtomic writes data to a temp file beside path, fsyncs it, then +// replaces path (MoveFileEx REPLACE_EXISTING|WRITE_THROUGH on Windows, +// rename + directory fsync elsewhere). afterTemp runs once the temp file is +// durable and afterRename once path holds data; a non-nil error from +// afterTemp abandons the write and leaves path untouched. +func writeAtomic(path string, data []byte, afterTemp, afterRename func() error) error { + dir, base := filepath.Split(path) + f, err := os.CreateTemp(dir, tempPrefix(base)+"*") + if err != nil { + return err + } + tmp := f.Name() + committed := false + defer func() { + if !committed { + _ = os.Remove(tmp) + } + }() + _, err = f.Write(data) + if err == nil { + err = f.Sync() + } + if cerr := f.Close(); err == nil { + err = cerr + } + if err != nil { + return err + } + if afterTemp != nil { + if err := afterTemp(); err != nil { + return err + } + } + if err := replaceFile(tmp, path); err != nil { + return err + } + committed = true + if afterRename != nil { + return afterRename() + } + return nil +} + +func tempPrefix(base string) string { return "." + base + ".tmp-" } + +// SweepTemps removes temp files that a killed writer left beside path. Call +// it only while holding the update lock, so no live writer is affected. +func SweepTemps(path string) { + dir, base := filepath.Split(path) + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + if e.Type().IsRegular() && strings.HasPrefix(e.Name(), tempPrefix(base)) { + _ = os.Remove(filepath.Join(dir, e.Name())) + } + } +} diff --git a/internal/updatestate/atomic_other.go b/internal/updatestate/atomic_other.go new file mode 100644 index 00000000..3857f4bc --- /dev/null +++ b/internal/updatestate/atomic_other.go @@ -0,0 +1,23 @@ +//go:build !windows + +package updatestate + +import ( + "os" + "path/filepath" +) + +func replaceFile(src, dst string) error { + if err := os.Rename(src, dst); err != nil { + return err + } + d, err := os.Open(filepath.Dir(dst)) + if err != nil { + return err + } + err = d.Sync() + if cerr := d.Close(); err == nil { + err = cerr + } + return err +} diff --git a/internal/updatestate/atomic_windows.go b/internal/updatestate/atomic_windows.go new file mode 100644 index 00000000..4a8a0ca8 --- /dev/null +++ b/internal/updatestate/atomic_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package updatestate + +import "golang.org/x/sys/windows" + +func replaceFile(src, dst string) error { + from, err := windows.UTF16PtrFromString(src) + if err != nil { + return err + } + to, err := windows.UTF16PtrFromString(dst) + if err != nil { + return err + } + return windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH) +} diff --git a/internal/updatestate/crash_faults_test.go b/internal/updatestate/crash_faults_test.go new file mode 100644 index 00000000..3509fa2d --- /dev/null +++ b/internal/updatestate/crash_faults_test.go @@ -0,0 +1,38 @@ +//go:build selfupdate_faults + +package updatestate + +import ( + "testing" + + "github.com/step-security/dev-machine-guard/internal/faultinject" +) + +// TestSaveAtomicUnderInjectedFaults drives the real faultinject checkpoints +// through DMG_FAULT for every prefix the spec names. +func TestSaveAtomicUnderInjectedFaults(t *testing.T) { + for _, prefix := range []string{"S6", "rollback", "adopt"} { + for _, tc := range []struct { + action, at string + code int + current string + }{ + {"kill", "state-temp", 137, "1.18.2"}, + {"kill", "state-renamed", 137, "1.19.0"}, + {"err", "state-temp", 91, "1.18.2"}, + {"diskfull", "state-temp", 91, "1.18.2"}, + {"err", "state-renamed", 91, "1.19.0"}, + } { + name := prefix + "." + tc.at + t.Run(name+":"+tc.action, func(t *testing.T) { + if err := faultinject.Validate(name + ":" + tc.action); err != nil { + t.Fatal(err) + } + code, st := runCrash(t, prefix, "DMG_FAULT="+name+":"+tc.action) + if code != tc.code || st.Current != tc.current { + t.Fatalf("exit %d current %s, want %d %s", code, st.Current, tc.code, tc.current) + } + }) + } + } +} diff --git a/internal/updatestate/crash_test.go b/internal/updatestate/crash_test.go new file mode 100644 index 00000000..49e8e0e9 --- /dev/null +++ b/internal/updatestate/crash_test.go @@ -0,0 +1,90 @@ +package updatestate + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/proctest" +) + +// TestCrashHelper is the subprocess body: it saves the "new" state and is +// killed at the requested checkpoint, either by a stubbed checkpoint +// (UPDATESTATE_HOOK=1) or by a real faultinject DMG_FAULT (tagged builds). +func TestCrashHelper(t *testing.T) { + path := os.Getenv("UPDATESTATE_CRASH_PATH") + if path == "" { + t.Skip("helper") + } + proctest.Guard() + if phase := os.Getenv("UPDATESTATE_HOOK_PHASE"); phase != "" { + checkpoint = func(name string) error { + if name == phase { + os.Exit(137) + } + return nil + } + } + if err := SaveCheckpointed(path, newCrashState(), os.Getenv("UPDATESTATE_PREFIX")); err != nil { + os.Exit(91) + } + os.Exit(0) +} + +func oldCrashState() *State { return &State{Current: "1.18.2", Previous: "1.17.0"} } + +func newCrashState() *State { + return &State{ + Current: "1.19.0", + Previous: "1.18.2", + Probation: &Probation{Version: "1.19.0", Since: time.Date(2026, 10, 19, 0, 0, 0, 0, time.UTC)}, + } +} + +// runCrash saves the old state, runs the helper with env, and returns the +// helper's exit code and the state Load then sees. +func runCrash(t *testing.T, prefix string, env ...string) (int, *State) { + t.Helper() + path := filepath.Join(t.TempDir(), "state.json") + if err := Save(path, oldCrashState()); err != nil { + t.Fatal(err) + } + cmd := proctest.Command(t, os.Args[0], "-test.run=^TestCrashHelper$") + cmd.Env = append(cmd.Env, append(env, "UPDATESTATE_CRASH_PATH="+path, "UPDATESTATE_PREFIX="+prefix)...) + _ = cmd.Run() + st, corrupt, err := Load(path) + if err != nil || corrupt || st == nil { + t.Fatalf("Load after crash: %+v corrupt=%v err=%v (torn state)", st, corrupt, err) + } + SweepTemps(path) + if entries, _ := os.ReadDir(filepath.Dir(path)); len(entries) != 1 { + t.Fatalf("SweepTemps left %v", entries) + } + return cmd.ProcessState.ExitCode(), st +} + +func TestSaveAtomicUnderKill(t *testing.T) { + for _, tc := range []struct { + phase string + wantNew bool + }{ + {"S6.state-temp", false}, + {"S6.state-renamed", true}, + } { + t.Run(tc.phase, func(t *testing.T) { + code, st := runCrash(t, "S6", "UPDATESTATE_HOOK_PHASE="+tc.phase) + if code != 137 { + t.Fatalf("helper exit %d, want 137", code) + } + if want := map[bool]string{false: "1.18.2", true: "1.19.0"}[tc.wantNew]; st.Current != want { + t.Fatalf("current = %s, want %s", st.Current, want) + } + }) + } + t.Run("no crash", func(t *testing.T) { + if code, st := runCrash(t, "S6", "UPDATESTATE_HOOK_PHASE=none"); code != 0 || st.Current != "1.19.0" { + t.Fatalf("exit %d current %s", code, st.Current) + } + }) +} diff --git a/internal/updatestate/fuzz_test.go b/internal/updatestate/fuzz_test.go new file mode 100644 index 00000000..d2f96eee --- /dev/null +++ b/internal/updatestate/fuzz_test.go @@ -0,0 +1,76 @@ +package updatestate + +import ( + "encoding/json" + "path/filepath" + "testing" +) + +func FuzzDecodeState(f *testing.F) { + for _, s := range []string{ + `{"schema":1,"current":"1.19.0","previous":"1.18.2","probation":{"version":"1.19.0","since":"2026-10-19T00:00:00Z","ok":0,"bad":1,"smoke_ok":true},"quarantine":{"1.18.9":{"reason":"x","at":"2026-10-19T00:00:00Z"}},"updater":{"version":"1.18.2","consecutive_failures":2},"switch_deferrals":1,"dedupe":{"k":"2026-10-19T00:00:00Z"}}`, + `{"schema":1,"current":"1.2.3"}`, `{"schema":2}`, `null`, ``, `{"schema":1,"current":"01.2.3"}`, + } { + f.Add([]byte(s)) + } + f.Fuzz(func(t *testing.T, data []byte) { + st, err := decodeState(data) + if err != nil { + return + } + // Anything accepted must save and load back to the same state. + path := filepath.Join(t.TempDir(), "state.json") + if err := Save(path, st); err != nil { + t.Fatalf("accepted state does not save: %v", err) + } + got, corrupt, err := Load(path) + if err != nil || corrupt { + t.Fatalf("saved state reloads corrupt=%v err=%v", corrupt, err) + } + a, _ := json.Marshal(got) + if st.Schema < SchemaVersion { + st.Schema = SchemaVersion + } + if st.Quarantine == nil { + st.Quarantine = map[string]Quarantine{} + } + if st.Dedupe == nil { + st.Dedupe = got.Dedupe + } + b, _ := json.Marshal(st) + if string(a) != string(b) { + t.Fatalf("round trip changed state:\n%s\n%s", a, b) + } + }) +} + +func FuzzDecodeRuns(f *testing.F) { + for _, s := range []string{ + `{"records":[{"version":"1.19.0","execution_id":"e","started":true,"exit_code":0,"at":"2026-10-19T00:00:00Z"}]}`, + `{"records":[{"version":"1.19.0","watchdog_killed":true}]}`, `garbage`, `{"records":null}`, + } { + f.Add([]byte(s)) + } + f.Fuzz(func(t *testing.T, data []byte) { + recs := decodeRuns(data) + // The reader never trims (R-F1-10); the 64 KiB read bound caps it. + for _, r := range recs { + if !validVersion(r.Version) { + t.Fatalf("invalid version kept: %q", r.Version) + } + _ = ClassifyRun(r) + } + }) +} + +func FuzzParseVersion(f *testing.F) { + for _, s := range []string{"1.2.3", "0.0.0", "01.2.3", "1.2.3-rc.1", "4294967296.0.0"} { + f.Add(s) + } + f.Fuzz(func(t *testing.T, s string) { + v, err := ParseVersion(s) + if err == nil && v.String() != s { + t.Fatalf("ParseVersion(%q) = %v: not canonical", s, v) + } + }) +} diff --git a/internal/updatestate/layout.go b/internal/updatestate/layout.go new file mode 100644 index 00000000..86313746 --- /dev/null +++ b/internal/updatestate/layout.go @@ -0,0 +1,61 @@ +// Package updatestate owns the on-disk self-update layout: version +// directories, state.json, the updater lock and the bootstrap's run records. +// +// \versions\\ immutable once renamed in +// \versions\.staging--*\ never executed +// \seed\\ MSI-owned fallback payload +// \state\{state.json,update.lock,update.log} +// \runs\.json user-writable, advisory only +package updatestate + +import ( + "os" + "path/filepath" + "strings" +) + +const ( + // ManifestName is the per-version manifest written before the rename in. + ManifestName = "manifest.json" + // StagingPrefix and TrashPrefix name entries of VersionsDir that S1 sweeps. + StagingPrefix = ".staging-" + TrashPrefix = ".trash-" +) + +// Layout resolves every self-update path from an install root (versions, +// seed, state) and a data root (runs). Production code derives it from the +// running binary (ResolveLayout); nothing hard-codes the MSI's folders. +type Layout struct { + InstallRoot string + DataRoot string +} + +func (l Layout) VersionsDir() string { return filepath.Join(l.InstallRoot, "versions") } + +func (l Layout) VersionDir(v Version) string { return filepath.Join(l.VersionsDir(), v.String()) } + +func (l Layout) SeedDir(v Version) string { + return filepath.Join(l.InstallRoot, "seed", v.String()) +} + +func (l Layout) StateDir() string { return filepath.Join(l.InstallRoot, "state") } +func (l Layout) StateFile() string { return filepath.Join(l.StateDir(), "state.json") } +func (l Layout) LockFile() string { return filepath.Join(l.StateDir(), "update.lock") } +func (l Layout) LogFile() string { return filepath.Join(l.StateDir(), "update.log") } + +func (l Layout) RunsDir() string { return filepath.Join(l.DataRoot, "runs") } + +func (l Layout) RunFile(v Version) string { + return filepath.Join(l.RunsDir(), v.String()+".json") +} + +// StagingDir creates a fresh versions\.staging-- directory. It sits +// in VersionsDir (which must exist) so the final rename stays on one volume. +func (l Layout) StagingDir(v Version) (string, error) { + return os.MkdirTemp(l.VersionsDir(), StagingPrefix+v.String()+"-") +} + +// IsSweepable reports whether a VersionsDir entry is staging or trash. +func IsSweepable(name string) bool { + return strings.HasPrefix(name, StagingPrefix) || strings.HasPrefix(name, TrashPrefix) +} diff --git a/internal/updatestate/layout_test.go b/internal/updatestate/layout_test.go new file mode 100644 index 00000000..d751ee16 --- /dev/null +++ b/internal/updatestate/layout_test.go @@ -0,0 +1,56 @@ +package updatestate + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLayoutPaths(t *testing.T) { + l := Layout{InstallRoot: filepath.Join("root", "SS"), DataRoot: filepath.Join("data", "SS")} + v := Version{1, 19, 0} + j := filepath.Join + for got, want := range map[string]string{ + l.VersionsDir(): j("root", "SS", "versions"), + l.VersionDir(v): j("root", "SS", "versions", "1.19.0"), + l.SeedDir(v): j("root", "SS", "seed", "1.19.0"), + l.StateDir(): j("root", "SS", "state"), + l.StateFile(): j("root", "SS", "state", "state.json"), + l.LockFile(): j("root", "SS", "state", "update.lock"), + l.LogFile(): j("root", "SS", "state", "update.log"), + l.RunsDir(): j("data", "SS", "runs"), + l.RunFile(v): j("data", "SS", "runs", "1.19.0.json"), + } { + if got != want { + t.Errorf("got %q, want %q", got, want) + } + } +} + +func TestStagingDir(t *testing.T) { + l := Layout{InstallRoot: t.TempDir()} + v := Version{1, 2, 3} + if _, err := l.StagingDir(v); err == nil { + t.Fatal("StagingDir must fail when VersionsDir is missing") + } + if err := os.MkdirAll(l.VersionsDir(), 0o755); err != nil { + t.Fatal(err) + } + a, err := l.StagingDir(v) + if err != nil { + t.Fatal(err) + } + b, err := l.StagingDir(v) + if err != nil { + t.Fatal(err) + } + if a == b || filepath.Dir(a) != l.VersionsDir() || !strings.HasPrefix(filepath.Base(a), ".staging-1.2.3-") { + t.Fatalf("staging dirs %q, %q", a, b) + } + for name, want := range map[string]bool{filepath.Base(a): true, ".trash-1.2.3-x": true, "1.2.3": false, "staging-1": false} { + if IsSweepable(name) != want { + t.Errorf("IsSweepable(%q) != %v", name, want) + } + } +} diff --git a/internal/updatestate/lock.go b/internal/updatestate/lock.go new file mode 100644 index 00000000..cd4253fa --- /dev/null +++ b/internal/updatestate/lock.go @@ -0,0 +1,40 @@ +package updatestate + +import ( + "errors" + "os" +) + +// ErrLocked means another process holds the update lock. +var ErrLocked = errors.New("updatestate: update lock held by another process") + +// Lock is an OS-owned exclusive lock that the kernel releases when the +// holder dies. The lock file is never removed: every contender must lock the +// same file. +type Lock struct{ f *os.File } + +// AcquireLock takes the lock without blocking; it returns ErrLocked when +// another process holds it. +func AcquireLock(path string) (*Lock, error) { + // #nosec G304 -- fixed lock path inside the protected state directory. + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + if err := lockFile(f); err != nil { + _ = f.Close() + if isLockHeld(err) { + return nil, ErrLocked + } + return nil, err + } + return &Lock{f: f}, nil +} + +// Release drops the lock. It is safe to call more than once. +func (l *Lock) Release() { + if l != nil && l.f != nil { + _ = l.f.Close() + l.f = nil + } +} diff --git a/internal/updatestate/lock_other.go b/internal/updatestate/lock_other.go new file mode 100644 index 00000000..b386fa0f --- /dev/null +++ b/internal/updatestate/lock_other.go @@ -0,0 +1,22 @@ +//go:build !windows + +package updatestate + +import ( + "errors" + "os" + + "golang.org/x/sys/unix" +) + +func lockFile(f *os.File) error { + fd := f.Fd() + // POSIX descriptors fit in a signed 32-bit int; reject anything else + // before converting os.File's platform-neutral uintptr. + if fd > 1<<31-1 { + return os.ErrInvalid + } + return unix.Flock(int(fd), unix.LOCK_EX|unix.LOCK_NB) +} + +func isLockHeld(err error) bool { return errors.Is(err, unix.EWOULDBLOCK) } diff --git a/internal/updatestate/lock_test.go b/internal/updatestate/lock_test.go new file mode 100644 index 00000000..ef390eaf --- /dev/null +++ b/internal/updatestate/lock_test.go @@ -0,0 +1,73 @@ +package updatestate + +import ( + "bufio" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/step-security/dev-machine-guard/internal/proctest" +) + +func TestLockExclusiveNonBlocking(t *testing.T) { + path := filepath.Join(t.TempDir(), "update.lock") + a, err := AcquireLock(path) + if err != nil { + t.Fatal(err) + } + if _, err := AcquireLock(path); !errors.Is(err, ErrLocked) { + t.Fatalf("second AcquireLock = %v, want ErrLocked", err) + } + a.Release() + a.Release() + b, err := AcquireLock(path) + if err != nil { + t.Fatalf("AcquireLock after Release: %v", err) + } + b.Release() + if _, err := os.Stat(path); err != nil { + t.Fatalf("lock file must persist: %v", err) + } + if _, err := AcquireLock(filepath.Join(t.TempDir(), "missing", "update.lock")); err == nil || errors.Is(err, ErrLocked) { + t.Fatalf("AcquireLock in a missing dir = %v", err) + } +} + +func TestLockHelper(t *testing.T) { + path := os.Getenv("UPDATESTATE_LOCK_PATH") + if path == "" { + t.Skip("helper") + } + proctest.Guard() + if _, err := AcquireLock(path); err != nil { + os.Exit(90) + } + os.Stdout.WriteString("locked\n") + _, _ = bufio.NewReader(os.Stdin).ReadString('\n') + os.Exit(137) // die holding the lock, no Release +} + +func TestLockReleasedOnProcessDeath(t *testing.T) { + path := filepath.Join(t.TempDir(), "update.lock") + cmd := proctest.Command(t, os.Args[0], "-test.run=^TestLockHelper$") + cmd.Env = append(cmd.Env, "UPDATESTATE_LOCK_PATH="+path) + in, _ := cmd.StdinPipe() + out, _ := cmd.StdoutPipe() + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + if line, _ := bufio.NewReader(out).ReadString('\n'); line != "locked\n" { + t.Fatalf("helper said %q", line) + } + if _, err := AcquireLock(path); !errors.Is(err, ErrLocked) { + t.Fatalf("lock held by another process: %v, want ErrLocked", err) + } + _ = in.Close() + _ = cmd.Wait() + l, err := AcquireLock(path) + if err != nil { + t.Fatalf("lock not released by process death: %v", err) + } + l.Release() +} diff --git a/internal/updatestate/lock_windows.go b/internal/updatestate/lock_windows.go new file mode 100644 index 00000000..9b0d43b0 --- /dev/null +++ b/internal/updatestate/lock_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package updatestate + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +func lockFile(f *os.File) error { + return windows.LockFileEx(windows.Handle(f.Fd()), + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, &windows.Overlapped{}) +} + +func isLockHeld(err error) bool { return errors.Is(err, windows.ERROR_LOCK_VIOLATION) } diff --git a/internal/updatestate/main_test.go b/internal/updatestate/main_test.go new file mode 100644 index 00000000..748e266f --- /dev/null +++ b/internal/updatestate/main_test.go @@ -0,0 +1,10 @@ +package updatestate + +import ( + "os" + "testing" + + "github.com/step-security/dev-machine-guard/internal/proctest" +) + +func TestMain(m *testing.M) { os.Exit(proctest.Main(m)) } diff --git a/internal/updatestate/manifest.go b/internal/updatestate/manifest.go new file mode 100644 index 00000000..25d2f862 --- /dev/null +++ b/internal/updatestate/manifest.go @@ -0,0 +1,122 @@ +package updatestate + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" +) + +// Files of a version directory. They keep their release names so the +// launcher finds the agent beside itself (internal/launcher.AgentBinary) and +// the WSL scan finds dmg-linux beside the agent, unchanged. +const ( + AgentFile = "stepsecurity-dev-machine-guard.exe" + LauncherFile = "stepsecurity-dev-machine-guard-task.exe" + LinuxFile = "dmg-linux" +) + +// VersionFiles lists every file a complete version directory holds besides +// its manifest. +var VersionFiles = []string{AgentFile, LauncherFile, LinuxFile} + +// maxManifestSize bounds what ReadManifest reads. +const maxManifestSize = 64 << 10 + +// Manifest is versions\\manifest.json: the sha256 of every file, written +// (and fsynced) before the directory is renamed into place. +type Manifest struct { + Schema int `json:"schema"` + Version string `json:"version"` + Files map[string]string `json:"files"` // file name -> lowercase hex sha256 +} + +// WriteManifest atomically writes dir\manifest.json. +func WriteManifest(dir string, m Manifest) error { + if !validVersion(m.Version) { + return fmt.Errorf("updatestate: manifest version %q is not x.y.z", m.Version) + } + m.Schema = SchemaVersion + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + return writeAtomic(filepath.Join(dir, ManifestName), append(data, '\n'), nil, nil) +} + +// ReadManifest reads dir\manifest.json. +func ReadManifest(dir string) (*Manifest, error) { + data, err := readBounded(filepath.Join(dir, ManifestName), maxManifestSize) + if err != nil { + return nil, err + } + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("updatestate: manifest: %w", err) + } + return &m, nil +} + +// ErrIncomplete means a version directory lacks a file or its manifest. +var ErrIncomplete = errors.New("updatestate: version directory incomplete") + +// VersionDirComplete is the updater's cheap integrity check: every +// VersionFiles entry and the manifest exist as regular files. It does not +// hash. (VersionDirUsable is the bootstrap's narrower launchability check: +// agent and launcher only, no manifest.) +func VersionDirComplete(dir string) error { + for _, name := range append([]string{ManifestName}, VersionFiles...) { + fi, err := os.Lstat(filepath.Join(dir, name)) + if err != nil || !fi.Mode().IsRegular() { + return fmt.Errorf("%w: %s", ErrIncomplete, name) + } + } + return nil +} + +// VerifyVersionDir checks that dir is a complete version v whose files match +// the manifest hashes. +func VerifyVersionDir(dir string, v Version) error { + if err := VersionDirComplete(dir); err != nil { + return err + } + m, err := ReadManifest(dir) + if err != nil { + return err + } + if m.Version != v.String() { + return fmt.Errorf("updatestate: manifest version %q in %s, want %s", m.Version, dir, v) + } + for _, name := range VersionFiles { + want := m.Files[name] + if want == "" { + return fmt.Errorf("%w: manifest lacks %s", ErrIncomplete, name) + } + got, err := FileSHA256(filepath.Join(dir, name)) + if err != nil { + return err + } + if got != want { + return fmt.Errorf("updatestate: %s in %s does not match its manifest hash", name, dir) + } + } + return nil +} + +// FileSHA256 returns the lowercase hex sha256 of a file. +func FileSHA256(path string) (string, error) { + f, err := openShared(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/updatestate/manifest_test.go b/internal/updatestate/manifest_test.go new file mode 100644 index 00000000..75ce1073 --- /dev/null +++ b/internal/updatestate/manifest_test.go @@ -0,0 +1,86 @@ +package updatestate + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func writeVersionDir(t *testing.T, dir, v string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + m := Manifest{Version: v, Files: map[string]string{}} + for _, name := range VersionFiles { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(name+v), 0o755); err != nil { + t.Fatal(err) + } + h, err := FileSHA256(p) + if err != nil { + t.Fatal(err) + } + m.Files[name] = h + } + if err := WriteManifest(dir, m); err != nil { + t.Fatal(err) + } +} + +func TestVerifyVersionDir(t *testing.T) { + v, _ := ParseVersion("1.20.0") + dir := filepath.Join(t.TempDir(), "1.20.0") + writeVersionDir(t, dir, "1.20.0") + if err := VerifyVersionDir(dir, v); err != nil { + t.Fatalf("good dir: %v", err) + } + other, _ := ParseVersion("1.21.0") + if err := VerifyVersionDir(dir, other); err == nil { + t.Fatal("version mismatch accepted") + } + if err := os.WriteFile(filepath.Join(dir, LinuxFile), []byte("tampered"), 0o755); err != nil { + t.Fatal(err) + } + if err := VerifyVersionDir(dir, v); err == nil { + t.Fatal("tampered file accepted") + } + if err := os.Remove(filepath.Join(dir, AgentFile)); err != nil { + t.Fatal(err) + } + if err := VersionDirComplete(dir); !errors.Is(err, ErrIncomplete) { + t.Fatalf("missing agent: %v", err) + } + if err := WriteManifest(dir, Manifest{Version: "v1"}); err == nil { + t.Fatal("non-strict manifest version written") + } +} + +// D29: the files the MSI owns (replaced or removed by Windows Installer): +// the bootstrap and every file under seed\. +func TestMSIOwnedFiles(t *testing.T) { + l := Layout{InstallRoot: t.TempDir(), DataRoot: t.TempDir()} + for _, p := range []string{ + filepath.Join(l.InstallRoot, BootstrapFile), + filepath.Join(l.InstallRoot, "seed", "1.19.0", AgentFile), + filepath.Join(l.InstallRoot, "seed", "1.19.0", LinuxFile), + filepath.Join(l.InstallRoot, "versions", "1.20.0", AgentFile), + } { + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("x"), 0o755); err != nil { + t.Fatal(err) + } + } + got := l.MSIOwnedFiles() + if len(got) != 3 { + t.Fatalf("MSIOwnedFiles = %v", got) + } + for _, p := range got { + if filepath.Base(filepath.Dir(p)) == "1.20.0" { + t.Fatalf("versions\\ is updater-owned: %v", got) + } + } +} diff --git a/internal/updatestate/msiowned.go b/internal/updatestate/msiowned.go new file mode 100644 index 00000000..13c1b039 --- /dev/null +++ b/internal/updatestate/msiowned.go @@ -0,0 +1,31 @@ +package updatestate + +import ( + "os" + "path/filepath" +) + +// BootstrapFile is the MSI-owned stable entry point in the install root. +const BootstrapFile = "stepsecurity-dev-machine-guard-bootstrap.exe" + +// MSIOwnedFiles lists the files Windows Installer replaces or removes: the +// bootstrap and every file under seed\. Nothing may hold them open when +// the MSI's file actions run (D29), or the change needs a reboot. +func (l Layout) MSIOwnedFiles() []string { + var out []string + if fi, err := os.Lstat(filepath.Join(l.InstallRoot, BootstrapFile)); err == nil && fi.Mode().IsRegular() { + out = append(out, filepath.Join(l.InstallRoot, BootstrapFile)) + } + _ = filepath.WalkDir(filepath.Join(l.InstallRoot, "seed"), func(p string, d os.DirEntry, err error) error { + if err == nil && d.Type().IsRegular() { + out = append(out, p) + } + return nil + }) + return out +} + +// MSIHelperFile is the copy of the bootstrap the MSI's stop-for-msi action +// runs (state\msi\…): not an MSI file, so the action's own image never +// holds a file the MSI is about to replace. +func (l Layout) MSIHelperFile() string { return filepath.Join(l.StateDir(), "msi", BootstrapFile) } diff --git a/internal/updatestate/open_other.go b/internal/updatestate/open_other.go new file mode 100644 index 00000000..92ac79d9 --- /dev/null +++ b/internal/updatestate/open_other.go @@ -0,0 +1,21 @@ +//go:build !windows + +package updatestate + +import ( + "errors" + "fmt" + "os" + "syscall" +) + +// openShared opens path read-only without following a final symlink and +// without blocking on a FIFO. +func openShared(path string) (*os.File, error) { + // #nosec G304 -- callers pass layout-derived paths. + f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if errors.Is(err, syscall.ELOOP) { + return nil, fmt.Errorf("%w: %w", errUnreadable, err) + } + return f, err +} diff --git a/internal/updatestate/open_windows.go b/internal/updatestate/open_windows.go new file mode 100644 index 00000000..67dd99b5 --- /dev/null +++ b/internal/updatestate/open_windows.go @@ -0,0 +1,28 @@ +//go:build windows + +package updatestate + +import ( + "os" + + "golang.org/x/sys/windows" +) + +// openShared opens path for reading with FILE_SHARE_DELETE, so a reader +// never makes a concurrent MoveFileEx replace fail. Reparse points are +// opened as themselves, and directories open too (FILE_FLAG_BACKUP_SEMANTICS), +// so callers reject both as non-regular (corrupt) rather than seeing an +// access-denied I/O error. +func openShared(path string) (*os.File, error) { + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + h, err := windows.CreateFile(p, windows.GENERIC_READ, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_BACKUP_SEMANTICS, 0) + if err != nil { + return nil, &os.PathError{Op: "open", Path: path, Err: err} + } + return os.NewFile(uintptr(h), path), nil +} diff --git a/internal/updatestate/owner.go b/internal/updatestate/owner.go new file mode 100644 index 00000000..8d393096 --- /dev/null +++ b/internal/updatestate/owner.go @@ -0,0 +1,38 @@ +package updatestate + +import ( + "os" + "sync" + "time" +) + +// liveness of a run record's owning bootstrap process. +type liveness int + +const ( + ownerUnknown liveness = iota // no owner recorded, or it can't be queried + ownerAlive + ownerDead // gone, or the PID now belongs to another process +) + +var ( + selfStartOnce sync.Once + selfStart time.Time +) + +// stampOwner records this process as the owner of a new record. +func stampOwner(r *RunRecord) { + if r.OwnerPID != 0 { + return + } + r.OwnerPID = os.Getpid() + selfStartOnce.Do(func() { selfStart, _ = processStartTime(r.OwnerPID) }) + r.OwnerStart = selfStart +} + +func ownerOf(r RunRecord) liveness { + if r.OwnerPID <= 0 { + return ownerUnknown + } + return processLiveness(r.OwnerPID, r.OwnerStart) +} diff --git a/internal/updatestate/owner_other.go b/internal/updatestate/owner_other.go new file mode 100644 index 00000000..5a088cdf --- /dev/null +++ b/internal/updatestate/owner_other.go @@ -0,0 +1,24 @@ +//go:build !windows + +package updatestate + +import ( + "errors" + "time" + + "golang.org/x/sys/unix" +) + +// processStartTime is not available portably off Windows; liveness then +// rests on the PID alone. +func processStartTime(int) (time.Time, bool) { return time.Time{}, false } + +func processLiveness(pid int, _ time.Time) liveness { + switch err := unix.Kill(pid, 0); { + case err == nil, errors.Is(err, unix.EPERM): + return ownerAlive + case errors.Is(err, unix.ESRCH): + return ownerDead + } + return ownerUnknown +} diff --git a/internal/updatestate/owner_windows.go b/internal/updatestate/owner_windows.go new file mode 100644 index 00000000..025d4a41 --- /dev/null +++ b/internal/updatestate/owner_windows.go @@ -0,0 +1,50 @@ +//go:build windows + +package updatestate + +import ( + "errors" + "time" + + "golang.org/x/sys/windows" +) + +const stillActive = 259 // STILL_ACTIVE + +// processStartTime is the creation time of pid. +func processStartTime(pid int) (time.Time, bool) { + h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) // #nosec G115 -- a pid + if err != nil { + return time.Time{}, false + } + defer func() { _ = windows.CloseHandle(h) }() + var created, exited, kernel, user windows.Filetime + if windows.GetProcessTimes(h, &created, &exited, &kernel, &user) != nil { + return time.Time{}, false + } + return time.Unix(0, created.Nanoseconds()).UTC(), true +} + +// processLiveness: gone (or exited, or the PID reused by a process with +// another creation time) is dead; a process we may not query is unknown. +func processLiveness(pid int, created time.Time) liveness { + h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) // #nosec G115 -- a pid + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + return ownerDead + } + if err != nil { + return ownerUnknown + } + defer func() { _ = windows.CloseHandle(h) }() + var code uint32 + if windows.GetExitCodeProcess(h, &code) == nil && code != stillActive { + return ownerDead + } + if !created.IsZero() { + var c, e, k, u windows.Filetime + if windows.GetProcessTimes(h, &c, &e, &k, &u) == nil && !time.Unix(0, c.Nanoseconds()).Equal(created) { + return ownerDead // PID reused + } + } + return ownerAlive +} diff --git a/internal/updatestate/pointers.go b/internal/updatestate/pointers.go new file mode 100644 index 00000000..c0ee0dd4 --- /dev/null +++ b/internal/updatestate/pointers.go @@ -0,0 +1,284 @@ +package updatestate + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +// Pointers is the frozen bootstrap read contract of state.json: the fields +// every bootstrap, including the first one ever shipped, must keep reading. +// Probation and Updater are only filled for schema 1; a higher schema is read +// as current/previous only and never as corrupt. +type Pointers struct { + Schema int + Current string + Previous string + Probation string // probation version, "" when none (schema 1 only) + // Updater bookkeeping (schema 1 only). + UpdaterVersion string + UpdaterFailures int +} + +// LoadPointers reads state.json for the bootstrap through the frozen read +// contract (R-B-1): only schema (an integer >= 1), current (strict x.y.z) and +// previous (strict x.y.z, else dropped) decide whether the state is usable; +// no other field is validated, for any schema, so a future writer can change +// everything else without stranding first-generation bootstraps on the seed. +// Probation and updater are read best-effort, for any schema, for the update +// verb's selection: anything malformed there reads as absent, never corrupt. +// The (p, corrupt, err) contract matches Load. +func LoadPointers(path string) (p *Pointers, corrupt bool, err error) { + data, err := readBounded(path, maxStateSize) + switch { + case errors.Is(err, os.ErrNotExist): + return nil, false, nil + case errors.Is(err, errUnreadable): + return nil, true, nil + case err != nil: + return nil, false, err + } + p, derr := decodePointers(data) + if derr != nil { + return nil, true, nil + } + return p, false, nil +} + +func decodePointers(data []byte) (*Pointers, error) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + var p Pointers + if err := json.Unmarshal(raw["schema"], &p.Schema); err != nil || p.Schema < SchemaVersion { + return nil, fmt.Errorf("unsupported schema %s", raw["schema"]) + } + if err := json.Unmarshal(raw["current"], &p.Current); err != nil || !validVersion(p.Current) { + return nil, fmt.Errorf("invalid current %s", raw["current"]) + } + if json.Unmarshal(raw["previous"], &p.Previous) != nil || !validVersion(p.Previous) { + p.Previous = "" + } + // Existing fields keep their meaning in every schema (additive-only + // contract), so probation/updater are read for any schema, best-effort. + var prob struct { + Version string `json:"version"` + } + if json.Unmarshal(raw["probation"], &prob) == nil { + p.Probation = prob.Version + } + var upd struct { + Version string `json:"version"` + ConsecutiveFailures int `json:"consecutive_failures"` + } + if json.Unmarshal(raw["updater"], &upd) == nil { + p.UpdaterVersion, p.UpdaterFailures = upd.Version, upd.ConsecutiveFailures + } + return &p, nil +} + +// Fallback reasons for the bootstrap's EnvBootstrapFallback. Reasons about a +// named version carry it after a colon (current_missing:1.20.0) so the agent +// can report update_pointer_invalid with update_from = that version. +const ( + FallbackStateMissing = "state_missing" + FallbackStateCorrupt = "state_corrupt" + FallbackStateUnreadable = "state_unreadable" + FallbackCurrentMissing = "current_missing" + FallbackCurrentIncomplete = "current_incomplete" + FallbackStartFailed = "start_failed" + FallbackUpdaterFailed = "updater_failed" + FallbackNoUpdater = "no_confirmed_updater" + FallbackNothingRunnable = "no_runnable_version" +) + +// VersionDirUsable reports whether dir holds LauncherFile and AgentFile as +// non-empty regular files: what the bootstrap needs to launch a version. +// It is file presence only; integrity is the updater's manifest check +// (VerifyVersionDir), not the bootstrap's. +func VersionDirUsable(dir string) bool { + for _, name := range []string{LauncherFile, AgentFile} { + fi, err := os.Lstat(filepath.Join(dir, name)) + if err != nil || !fi.Mode().IsRegular() || fi.Size() == 0 { + return false + } + } + return true +} + +// FindSeed returns the newest usable seed\\ version. +func (l Layout) FindSeed() (Version, bool) { + entries, err := os.ReadDir(filepath.Join(l.InstallRoot, "seed")) + if err != nil { + return Version{}, false + } + var best Version + found := false + for _, e := range entries { + if !e.IsDir() { + continue + } + v, err := ParseVersion(e.Name()) + if err != nil || !VersionDirUsable(l.SeedDir(v)) { + continue + } + if !found || Compare(v, best) > 0 { + best, found = v, true + } + } + return best, found +} + +// RunChoice is one launchable directory in the bootstrap's run order. +type RunChoice struct { + Version Version + Dir string + Reason string // why this is not the state's current; "" when it is +} + +// LoadReason maps a LoadPointers/Load result to a fallback reason, "" when +// a state was read. +func LoadReason(found bool, corrupt bool, err error) string { + switch { + case err != nil: + return FallbackStateUnreadable + case corrupt: + return FallbackStateCorrupt + case !found: + return FallbackStateMissing + } + return "" +} + +// CheckVersionDir validates versions\\ and returns its version, or the +// fallback reason (current_missing / current_incomplete) it is unusable. +func (l Layout) CheckVersionDir(s string) (Version, string) { + v, err := ParseVersion(s) + if err != nil { + return Version{}, FallbackCurrentMissing + ":" + s + } + dir := l.VersionDir(v) + if _, err := os.Lstat(dir); err != nil { + return Version{}, FallbackCurrentMissing + ":" + s + } + if !VersionDirUsable(dir) { + return Version{}, FallbackCurrentIncomplete + ":" + s + } + return v, "" +} + +// ResolveRunChain orders current → previous → seed, skipping unusable +// directories. p is the LoadPointers result (nil when missing or corrupt); +// stateReason is LoadReason of that load. Every choice after the state's +// current carries the reason current was not used. +func ResolveRunChain(l Layout, p *Pointers, stateReason string) []RunChoice { + var out []RunChoice + seen := map[Version]bool{} + reason := stateReason + if p != nil { + if v, why := l.CheckVersionDir(p.Current); why == "" { + out = append(out, RunChoice{Version: v, Dir: l.VersionDir(v)}) + seen[v] = true + } else { + reason = why + } + if p.Previous != "" && p.Previous != p.Current { + if v, why := l.CheckVersionDir(p.Previous); why == "" && !seen[v] { + out = append(out, RunChoice{Version: v, Dir: l.VersionDir(v), Reason: reason}) + seen[v] = true + } + } + } + if v, ok := l.FindSeed(); ok { + out = append(out, RunChoice{Version: v, Dir: l.SeedDir(v), Reason: reason}) + } + return out +} + +// ResolveRunVersion is the version a scan launched now would run for st +// (nil = missing or corrupt state), with the fallback reason ("" when it is +// st.Current). With nothing runnable it returns the zero Version and +// FallbackNothingRunnable. +func ResolveRunVersion(l Layout, st *State) (Version, string) { + var p *Pointers + if st != nil { + p = &Pointers{Schema: st.Schema, Current: st.Current, Previous: st.Previous} + } + chain := ResolveRunChain(l, p, LoadReason(st != nil, false, nil)) + if len(chain) == 0 { + return Version{}, FallbackNothingRunnable + } + return chain[0].Version, chain[0].Reason +} + +// RunLockFile is the lock beside runs\.json that every read-modify-write +// of that file takes (R-B-2): the bootstrap's AppendRun/FinalizeRun and the +// agent's MarkRunStarted, so overlapping scan tasks never lose a record. +func (l Layout) RunLockFile(v Version) string { return RunLockPath(l.RunFile(v)) } + +// ErrNotRecordable means SaveUpdaterRecord found no state it may patch: the +// file is missing or its pointers (schema/current/previous) are unreadable. +var ErrNotRecordable = errors.New("updatestate: state not recordable by this build") + +// SaveUpdaterRecord rewrites only state.updater.{version,consecutive_failures} +// via fn, through the same tolerant read as LoadPointers (D18): every other +// byte of the file, including values this build's Load would reject and +// unknown keys inside updater, is kept. Written atomically with checkpoints +// .state-temp / .state-renamed. Call it with the update lock +// held. +func SaveUpdaterRecord(path, prefix string, fn func(version string, failures int) (string, int)) error { + data, err := readBounded(path, maxStateSize) + if err != nil { + if errors.Is(err, os.ErrNotExist) || errors.Is(err, errUnreadable) { + return ErrNotRecordable + } + return err + } + // Any schema >= 1: updater.version/consecutive_failures keep their meaning + // in every schema, and the rest of the file (schema included) is kept. + p, err := decodePointers(data) + if err != nil { + return ErrNotRecordable + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return ErrNotRecordable + } + upd := map[string]json.RawMessage{} + if json.Unmarshal(raw["updater"], &upd) != nil || upd == nil { + upd = map[string]json.RawMessage{} + } + version, failures := fn(p.UpdaterVersion, p.UpdaterFailures) + if failures < 0 { + failures = 0 + } + if upd["version"], err = json.Marshal(version); err != nil { + return err + } + if upd["consecutive_failures"], err = json.Marshal(failures); err != nil { + return err + } + if raw["updater"], err = json.Marshal(upd); err != nil { + return err + } + // Bump generation like every state writer (the updater decides whether + // its own write landed by generation). + var gen uint64 + _ = json.Unmarshal(raw["generation"], &gen) + if raw["generation"], err = json.Marshal(gen + 1); err != nil { + return err + } + out, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return err + } + var afterTemp, afterRename func() error + if prefix != "" { + afterTemp = func() error { return checkpoint(prefix + ".state-temp") } + afterRename = func() error { return checkpoint(prefix + ".state-renamed") } + } + return writeAtomic(path, append(out, '\n'), afterTemp, afterRename) +} diff --git a/internal/updatestate/pointers_test.go b/internal/updatestate/pointers_test.go new file mode 100644 index 00000000..412f065c --- /dev/null +++ b/internal/updatestate/pointers_test.go @@ -0,0 +1,155 @@ +package updatestate + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeStateFile(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "state.json") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func TestLoadPointers(t *testing.T) { + cases := []struct { + name string + body string + wantCorr bool + want *Pointers + }{ + {"schema1", `{"schema":1,"current":"1.2.3","previous":"1.2.2","probation":{"version":"1.2.3"},"updater":{"version":"1.2.2","consecutive_failures":3}}`, + false, &Pointers{Schema: 1, Current: "1.2.3", Previous: "1.2.2", Probation: "1.2.3", UpdaterVersion: "1.2.2", UpdaterFailures: 3}}, + {"schema1 extra fields", `{"schema":1,"current":"1.2.3","future":{"x":1}}`, false, &Pointers{Schema: 1, Current: "1.2.3"}}, + {"higher schema reads pointers only", `{"schema":7,"current":"2.0.0","previous":"bogus","probation":"???","updater":5}`, + false, &Pointers{Schema: 7, Current: "2.0.0"}}, + {"higher schema bad current", `{"schema":2,"current":"..\\x"}`, true, nil}, + {"schema0", `{"schema":0,"current":"1.2.3"}`, true, nil}, + {"traversal", `{"schema":1,"current":"..\\x"}`, true, nil}, + {"garbage", `not json`, true, nil}, + {"empty", ``, true, nil}, + {"schema1 invalid counter still resolves (R-B-1)", `{"schema":1,"current":"1.2.3","switch_deferrals":-1}`, false, &Pointers{Schema: 1, Current: "1.2.3"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p, corrupt, err := LoadPointers(writeStateFile(t, tc.body)) + if err != nil { + t.Fatalf("err = %v", err) + } + if corrupt != tc.wantCorr { + t.Fatalf("corrupt = %v, want %v", corrupt, tc.wantCorr) + } + if tc.want == nil { + if p != nil { + t.Fatalf("p = %+v, want nil", p) + } + return + } + if *p != *tc.want { + t.Fatalf("p = %+v, want %+v", *p, *tc.want) + } + }) + } +} + +func TestLoadPointers_MissingAndHuge(t *testing.T) { + p, corrupt, err := LoadPointers(filepath.Join(t.TempDir(), "nope.json")) + if p != nil || corrupt || err != nil { + t.Fatalf("missing: %v %v %v", p, corrupt, err) + } + huge := `{"schema":1,"current":"1.2.3","pad":"` + strings.Repeat("a", maxStateSize) + `"}` + p, corrupt, err = LoadPointers(writeStateFile(t, huge)) + if p != nil || !corrupt || err != nil { + t.Fatalf("huge: %v %v %v", p, corrupt, err) + } +} + +func TestResolveRunVersion(t *testing.T) { + l := Layout{InstallRoot: t.TempDir(), DataRoot: t.TempDir()} + mk := func(dir string, files ...string) { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for _, f := range files { + if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o755); err != nil { + t.Fatal(err) + } + } + } + if v, why := ResolveRunVersion(l, nil); why != FallbackNothingRunnable || v != (Version{}) { + t.Fatalf("empty: %v %q", v, why) + } + mk(filepath.Join(l.InstallRoot, "seed", "1.0.0"), AgentFile, LauncherFile) + mk(filepath.Join(l.InstallRoot, "seed", "0.9.0"), AgentFile, LauncherFile) + mk(filepath.Join(l.InstallRoot, "seed", "2.0.0"), AgentFile) // incomplete: ignored + if v, why := ResolveRunVersion(l, nil); v.String() != "1.0.0" || why != FallbackStateMissing { + t.Fatalf("missing state: %v %q", v, why) + } + st := &State{Schema: 1, Current: "1.2.0", Previous: "1.1.0"} + mk(l.VersionDir(Version{1, 1, 0}), AgentFile, LauncherFile) + if v, why := ResolveRunVersion(l, st); v.String() != "1.1.0" || why != FallbackCurrentMissing+":1.2.0" { + t.Fatalf("previous: %v %q", v, why) + } + mk(l.VersionDir(Version{1, 2, 0}), LauncherFile) + if _, why := ResolveRunVersion(l, st); why != FallbackCurrentIncomplete+":1.2.0" { + t.Fatalf("incomplete: %q", why) + } + mk(l.VersionDir(Version{1, 2, 0}), AgentFile) + if v, why := ResolveRunVersion(l, st); v.String() != "1.2.0" || why != "" { + t.Fatalf("current: %v %q", v, why) + } +} + +// The frozen bootstrap read contract: a schema-2 file with fields this build +// doesn't know still yields current/previous, unlike Load. +func TestLoadPointers_Schema2ExtraFields(t *testing.T) { + path := writeStateFile(t, `{"schema":2,"current":"1.4.0","previous":"1.3.0","probation":{"version":"1.4.0","new":1},"quarantine":"changed-type","brand_new":{"x":[1,2]}}`) + if _, corrupt, _ := Load(path); !corrupt { + t.Fatal("Load should treat schema 2 as corrupt (updater rule)") + } + p, corrupt, err := LoadPointers(path) + if err != nil || corrupt || p == nil || p.Current != "1.4.0" || p.Previous != "1.3.0" || p.Schema != 2 { + t.Fatalf("p=%+v corrupt=%v err=%v", p, corrupt, err) + } + // Existing fields keep their meaning in a newer schema (additive-only). + if p.Probation != "1.4.0" { + t.Fatalf("schema-2 probation not read: %+v", p) + } +} + +// R-B-1: the frozen read contract is schema/current/previous only. Fields a +// future writer may legally change (RC versions in quarantine, updater or +// probation; odd counters; changed types) never make the state unreadable. +func TestLoadPointers_FrozenContractIgnoresOtherFields(t *testing.T) { + for name, body := range map[string]string{ + "rc quarantine key": `{"schema":1,"current":"1.21.0","previous":"1.20.0","quarantine":{"1.22.0-rc.1":{"reason":"x"}}}`, + "rc updater": `{"schema":1,"current":"1.21.0","previous":"1.20.0","updater":{"version":"1.22.0-rc.1","consecutive_failures":1}}`, + "rc probation": `{"schema":1,"current":"1.21.0","previous":"1.20.0","probation":{"version":"1.22.0-rc.1"}}`, + "negative counter": `{"schema":1,"current":"1.21.0","previous":"1.20.0","switch_deferrals":-1}`, + "changed types": `{"schema":1,"current":"1.21.0","previous":"1.20.0","probation":"x","updater":7,"dedupe":[1]}`, + "newer schema": `{"schema":3,"current":"1.21.0","previous":"1.20.0","probation":"x"}`, + } { + t.Run(name, func(t *testing.T) { + p, corrupt, err := LoadPointers(writeStateFile(t, body)) + if err != nil || corrupt || p == nil || p.Current != "1.21.0" || p.Previous != "1.20.0" { + t.Fatalf("p=%+v corrupt=%v err=%v", p, corrupt, err) + } + }) + } + // An invalid or non-string previous is dropped, never corrupt. + p, corrupt, _ := LoadPointers(writeStateFile(t, `{"schema":1,"current":"1.21.0","previous":5}`)) + if corrupt || p == nil || p.Previous != "" { + t.Fatalf("non-string previous: p=%+v corrupt=%v", p, corrupt) + } + // Only current (strict) and schema (>= 1) can make it unreadable. + for _, body := range []string{`{"schema":1,"current":"1.21.0-rc.1"}`, `{"schema":"1","current":"1.21.0"}`, `{"schema":0,"current":"1.21.0"}`, `{"schema":1,"current":7}`} { + if _, corrupt, _ := LoadPointers(writeStateFile(t, body)); !corrupt { + t.Errorf("%s: want corrupt", body) + } + } +} diff --git a/internal/updatestate/root.go b/internal/updatestate/root.go new file mode 100644 index 00000000..aaf3f798 --- /dev/null +++ b/internal/updatestate/root.go @@ -0,0 +1,64 @@ +package updatestate + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// ErrUnsafeRoot means a binary's install root can't be trusted as an MSI +// install root: no bootstrap in it, or users can write it. +var ErrUnsafeRoot = errors.New("untrusted install root") + +// checkRoot is checkRootProtected; tests replace it. +var checkRoot = checkRootProtected + +// BootstrapRoot returns the install root of the MSI bootstrap install that +// the binary at exePath belongs to: its own directory (the bootstrap, or a +// legacy top-level agent) or, for a binary under seed\\ or +// versions\\, two levels up. ok is false when no bootstrap sits in that +// root. This is the one rule for finding the root; exists reports whether +// a file exists. +func BootstrapRoot(exePath string, exists func(string) bool) (string, bool) { + dir := filepath.Dir(exePath) + candidates := []string{dir} + parent := filepath.Dir(dir) + if b := strings.ToLower(filepath.Base(parent)); b == "seed" || b == "versions" { + candidates = append(candidates, filepath.Dir(parent)) + } + for _, root := range candidates { + if exists(filepath.Join(root, BootstrapFile)) { + return root, true + } + } + return "", false +} + +// ResolveInstallRoot returns the install root of the binary at exePath +// (BootstrapRoot) after checking users can't write it. A SYSTEM process +// must never trust a root it can't vouch for, so there is no fallback. +func ResolveInstallRoot(exePath string) (string, error) { + root, ok := BootstrapRoot(exePath, func(p string) bool { + fi, err := os.Lstat(p) + return err == nil && fi.Mode().IsRegular() + }) + if !ok { + return "", fmt.Errorf("%w: no %s for %s", ErrUnsafeRoot, BootstrapFile, exePath) + } + if err := checkRoot(root); err != nil { + return "", fmt.Errorf("%w: %s: %v", ErrUnsafeRoot, root, err) + } + return root, nil +} + +// ResolveLayout is the MSI layout of the binary at exePath: the install +// root from ResolveInstallRoot and the data root from DataRoot. +func ResolveLayout(exePath string) (Layout, error) { + root, err := ResolveInstallRoot(exePath) + if err != nil { + return Layout{}, err + } + return Layout{InstallRoot: root, DataRoot: DataRoot()}, nil +} diff --git a/internal/updatestate/root_other.go b/internal/updatestate/root_other.go new file mode 100644 index 00000000..995751e8 --- /dev/null +++ b/internal/updatestate/root_other.go @@ -0,0 +1,34 @@ +//go:build !windows + +package updatestate + +import ( + "fmt" + "os" + "path/filepath" + "syscall" +) + +// DataRoot is the MSI data root; there is none off Windows. +func DataRoot() string { return "" } + +// checkRootProtected requires root and every ancestor to be owned by root +// (uid 0) and not group- or world-writable. +func checkRootProtected(root string) error { + for p := filepath.Clean(root); ; p = filepath.Dir(p) { + fi, err := os.Lstat(p) + if err != nil { + return err + } + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok || st.Uid != 0 { + return fmt.Errorf("%s is not owned by root", p) + } + if fi.Mode().Perm()&0o022 != 0 && fi.Mode()&os.ModeSticky == 0 { + return fmt.Errorf("%s is writable by non-root users", p) + } + if filepath.Dir(p) == p { + return nil + } + } +} diff --git a/internal/updatestate/root_test.go b/internal/updatestate/root_test.go new file mode 100644 index 00000000..779ad08b --- /dev/null +++ b/internal/updatestate/root_test.go @@ -0,0 +1,74 @@ +package updatestate + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func touch(t *testing.T, p string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, nil, 0o644); err != nil { + t.Fatal(err) + } +} + +func trustRoots(t *testing.T) { + t.Helper() + checkRoot = func(string) error { return nil } + t.Cleanup(func() { checkRoot = checkRootProtected }) +} + +// Copilot 4356483380: the updater derives its install root from its own +// path (any MSI INSTALLFOLDER), by the same rule as the bootstrap. +func TestResolveInstallRoot_CustomRoot(t *testing.T) { + trustRoots(t) + root := filepath.Join(t.TempDir(), "D", "Apps", "StepSecurity") + touch(t, filepath.Join(root, BootstrapFile)) + v := Version{1, 20, 0} + for _, exe := range []string{ + filepath.Join(root, "versions", "1.20.0", AgentFile), + filepath.Join(root, "seed", "1.20.0", AgentFile), + filepath.Join(root, BootstrapFile), + } { + got, err := ResolveInstallRoot(exe) + if err != nil || got != root { + t.Fatalf("ResolveInstallRoot(%s) = %q, %v; want %q", exe, got, err, root) + } + } + l, err := ResolveLayout(filepath.Join(root, "versions", "1.20.0", AgentFile)) + if err != nil { + t.Fatal(err) + } + for _, p := range []string{l.StateFile(), l.LockFile(), l.LogFile(), l.VersionDir(v), l.SeedDir(v), filepath.Join(l.StateDir(), "device-id")} { + if rel, err := filepath.Rel(root, p); err != nil || rel == "" || rel[0] == '.' { + t.Errorf("%s is not under the custom root %s", p, root) + } + } +} + +func TestResolveInstallRoot_NoBootstrapRefused(t *testing.T) { + trustRoots(t) + root := filepath.Join(t.TempDir(), "StepSecurity") + exe := filepath.Join(root, "versions", "1.20.0", AgentFile) + touch(t, exe) + if _, err := ResolveInstallRoot(exe); !errors.Is(err, ErrUnsafeRoot) { + t.Fatalf("no bootstrap: err = %v, want ErrUnsafeRoot", err) + } +} + +// A root the current (unprivileged) user owns is user-writable: refused. +func TestResolveInstallRoot_UserWritableRootRefused(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("running privileged: a temp root isn't user-writable for this check") + } + root := filepath.Join(t.TempDir(), "StepSecurity") + touch(t, filepath.Join(root, BootstrapFile)) + if _, err := ResolveInstallRoot(filepath.Join(root, "versions", "1.20.0", AgentFile)); !errors.Is(err, ErrUnsafeRoot) { + t.Fatalf("user-writable root: err = %v, want ErrUnsafeRoot", err) + } +} diff --git a/internal/updatestate/root_windows.go b/internal/updatestate/root_windows.go new file mode 100644 index 00000000..5e86f977 --- /dev/null +++ b/internal/updatestate/root_windows.go @@ -0,0 +1,95 @@ +//go:build windows + +package updatestate + +import ( + "fmt" + "os" + "path/filepath" + "unsafe" + + "golang.org/x/sys/windows" +) + +// DataRoot is \StepSecurity, from the ProgramData known folder. +func DataRoot() string { + pd, err := windows.KnownFolderPath(windows.FOLDERID_ProgramData, 0) + if err != nil || pd == "" { + pd = os.Getenv("ProgramData") // the known folder can't fail on a working system + } + return filepath.Join(pd, "StepSecurity") +} + +// trustedInstallerSID is NT SERVICE\TrustedInstaller, owner of +// C:\Program Files and the system drive's root. +const trustedInstallerSID = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464" + +const ( + aceInheritOnly = 0x08 // INHERIT_ONLY_ACE: doesn't apply to the object itself + aceAllowed = 0 // ACCESS_ALLOWED_ACE_TYPE + aceDenied = 1 // ACCESS_DENIED_ACE_TYPE + + // rootWriteMask: rights that let a holder change the root's contents + // or its ACL. ancestorWriteMask: rights on an ancestor that let a + // holder replace or re-ACL what's beneath it (adding new entries, + // FILE_ADD_SUBDIRECTORY on a drive root, is harmless). + rootWriteMask = 0x2 | 0x4 | 0x40 | 0x10000 | 0x40000 | 0x80000 | 0x10000000 | 0x40000000 + ancestorWriteMask = 0x40 | 0x10000 | 0x40000 | 0x80000 | 0x10000000 +) + +func trustedSID(s *windows.SID) bool { + return s != nil && (s.IsWellKnown(windows.WinLocalSystemSid) || + s.IsWellKnown(windows.WinBuiltinAdministratorsSid) || s.String() == trustedInstallerSID) +} + +// checkRootProtected requires root, and every ancestor, to be owned by +// SYSTEM, Administrators or TrustedInstaller, and grants no one else write +// access to root, or rights over an ancestor that would let them swap root. +func checkRootProtected(root string) error { + mask := uint32(rootWriteMask) + for p := filepath.Clean(root); ; p = filepath.Dir(p) { + if err := checkDirACL(p, mask); err != nil { + return err + } + if filepath.Dir(p) == p { + return nil + } + mask = ancestorWriteMask + } +} + +func checkDirACL(path string, mask uint32) error { + sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION) + if err != nil { + return fmt.Errorf("read ACL of %s: %w", path, err) + } + owner, _, err := sd.Owner() + if err != nil || !trustedSID(owner) { + return fmt.Errorf("%s is owned by %v, not SYSTEM/Administrators/TrustedInstaller", path, owner) + } + acl, _, err := sd.DACL() + if err != nil { + return fmt.Errorf("read DACL of %s: %w", path, err) + } + if acl == nil { + return fmt.Errorf("%s has a NULL DACL", path) + } + for i := uint32(0); i < uint32(acl.AceCount); i++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(acl, i, &ace); err != nil { + return fmt.Errorf("read ACE %d of %s: %w", i, path, err) + } + if ace.Header.AceFlags&aceInheritOnly != 0 || ace.Header.AceType == aceDenied || uint32(ace.Mask)&mask == 0 { + continue + } + if ace.Header.AceType != aceAllowed { + return fmt.Errorf("%s has an ACE of type %d granting %#x", path, ace.Header.AceType, ace.Mask) + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + if !trustedSID(sid) { + return fmt.Errorf("%s grants %s write access (%#x)", path, sid, ace.Mask) + } + } + return nil +} diff --git a/internal/updatestate/root_windows_test.go b/internal/updatestate/root_windows_test.go new file mode 100644 index 00000000..9c828a02 --- /dev/null +++ b/internal/updatestate/root_windows_test.go @@ -0,0 +1,54 @@ +//go:build windows + +package updatestate + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +// A custom root outside any user profile, owned by SYSTEM with a +// protected DACL (as the MSI leaves INSTALLFOLDER), is accepted. +func TestResolveInstallRoot_ProtectedCustomRoot(t *testing.T) { + top := filepath.Join(os.Getenv("SystemDrive")+`\`, fmt.Sprintf("dmg-root-test-%d", time.Now().UnixNano())) + root := filepath.Join(top, "StepSecurity") + t.Cleanup(func() { _ = os.RemoveAll(top) }) + for _, d := range []string{top, root} { + if _, err := EnsureProtectedDir(d); err != nil { + t.Skipf("needs an elevated runner: %v", err) + } + } + touch(t, filepath.Join(root, BootstrapFile)) + got, err := ResolveInstallRoot(filepath.Join(root, "versions", "1.20.0", AgentFile)) + if err != nil || got != root { + t.Fatalf("ResolveInstallRoot = %q, %v; want %q", got, err, root) + } +} + +func TestDataRootIsProgramDataKnownFolder(t *testing.T) { + want := filepath.Join(os.Getenv("ProgramData"), "StepSecurity") + if got := DataRoot(); !strings.EqualFold(got, want) { + t.Fatalf("DataRoot = %q, want %q", got, want) + } +} + +// The real Program Files (owned by TrustedInstaller) passes, as an MSI +// INSTALLFOLDER under it must. +func TestCheckRootProtected_ProgramFiles(t *testing.T) { + sid, _, _, err := windows.LookupSID("", `NT SERVICE\TrustedInstaller`) + if err != nil { + t.Fatal(err) + } + if sid.String() != trustedInstallerSID { + t.Fatalf("TrustedInstaller SID = %s, constant %s", sid, trustedInstallerSID) + } + if err := checkRootProtected(os.Getenv("ProgramFiles")); err != nil { + t.Fatalf("Program Files refused: %v", err) + } +} diff --git a/internal/updatestate/runlock_test.go b/internal/updatestate/runlock_test.go new file mode 100644 index 00000000..88342ce5 --- /dev/null +++ b/internal/updatestate/runlock_test.go @@ -0,0 +1,164 @@ +package updatestate + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/proctest" +) + +const raceFiles = 40 + +// TestRunRaceHelper is one side of TestAgentMarkerVsBootstrapFinalize: +// "agent" marks record a in every file, "bootstrap" finalizes record b +// under the run-record lock (as dmg-boot's bootstrap does, 544d1ee). +func TestRunRaceHelper(t *testing.T) { + dir, role := os.Getenv("RUNRACE_DIR"), os.Getenv("RUNRACE_ROLE") + if dir == "" { + t.Skip("helper") + } + proctest.Guard() + for time.Now().Before(mustUnix(os.Getenv("RUNRACE_START"))) { + time.Sleep(time.Millisecond) + } + for i := 0; i < raceFiles; i++ { + path := filepath.Join(dir, fmt.Sprintf("%d.json", i)) + var err error + switch role { + case "agent": + err = MarkRunStartedLocked(path, "a", 5*time.Second) + case "agent-unlocked": + err = MarkRunStarted(path, "a") + case "bootstrap": + err = withTestRunLock(path+".lock", func() error { + return FinalizeRun(path, "b", 0, false, time.Now()) + }) + } + if err != nil { + fmt.Fprintln(os.Stderr, role, i, err) + } + } + os.Exit(0) +} + +func mustUnix(s string) time.Time { + n, _ := strconv.ParseInt(s, 10, 64) + return time.Unix(0, n) +} + +// withTestRunLock mirrors the bootstrap's withRunLock. +func withTestRunLock(lockPath string, fn func() error) error { + deadline := time.Now().Add(5 * time.Second) + for { + l, err := AcquireLock(lockPath) + if err == nil { + defer l.Release() + return fn() + } + if time.Now().After(deadline) { + return err + } + time.Sleep(time.Millisecond) + } +} + +// race runs the agent role against a locked bootstrap finalizer and +// returns how many files lost the marker or the finalization. +func race(t *testing.T, agentRole string) int { + t.Helper() + dir := t.TempDir() + for i := 0; i < raceFiles; i++ { + path := filepath.Join(dir, fmt.Sprintf("%d.json", i)) + for _, id := range []string{"a", "b"} { + if err := AppendRun(path, RunRecord{RunID: id, Version: "1.19.0"}); err != nil { + t.Fatal(err) + } + } + } + start := strconv.FormatInt(time.Now().Add(300*time.Millisecond).UnixNano(), 10) + var cmds []*exec.Cmd + for _, role := range []string{agentRole, "bootstrap"} { + cmd := proctest.Command(t, os.Args[0], "-test.run=^TestRunRaceHelper$") + cmd.Env = append(cmd.Env, "RUNRACE_DIR="+dir, "RUNRACE_ROLE="+role, "RUNRACE_START="+start) + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + cmds = append(cmds, cmd) + } + for _, c := range cmds { + _ = c.Wait() + } + lost := 0 + for i := 0; i < raceFiles; i++ { + got := map[string]RunRecord{} + for _, r := range ReadRuns(filepath.Join(dir, fmt.Sprintf("%d.json", i))) { + got[r.RunID] = r + } + if !got["a"].Started || got["b"].ExitCode == nil { + lost++ + } + } + return lost +} + +// D17: the agent's started marker and the bootstrap's finalize of another +// record in the same file, from two processes, must both survive. +func TestAgentMarkerVsBootstrapFinalize(t *testing.T) { + if lost := race(t, "agent"); lost != 0 { + t.Fatalf("%d of %d files lost an update with the agent under the run lock", lost, raceFiles) + } + // Evidence for the bug the lock fixes (not asserted: it is a race). + t.Logf("without the agent taking the lock: %d of %d files lost an update", race(t, "agent-unlocked"), raceFiles) +} + +func TestMarkRunStartedLockedTimesOut(t *testing.T) { + path := filepath.Join(t.TempDir(), "1.19.0.json") + if err := AppendRun(path, RunRecord{RunID: "a", Version: "1.19.0"}); err != nil { + t.Fatal(err) + } + held, err := AcquireLock(path + ".lock") + if err != nil { + t.Fatal(err) + } + defer held.Release() + start := time.Now() + if err := MarkRunStartedLocked(path, "a", 200*time.Millisecond); err == nil { + t.Fatal("marked while the bootstrap held the run lock") + } + if time.Since(start) > 5*time.Second { + t.Fatal("wait not bounded") + } + if ReadRuns(path)[0].Started { + t.Fatal("marker written without the lock") + } +} + +// B4(a): the agent's marker and the bootstrap use one lock path helper: a +// holder of Layout.RunLockFile blocks MarkRunStartedLocked on RunFile. +func TestMarkerAndBootstrapShareTheRunLock(t *testing.T) { + l := Layout{InstallRoot: t.TempDir(), DataRoot: t.TempDir()} + v, _ := ParseVersion("1.20.0") + if err := AppendRun(l.RunFile(v), RunRecord{RunID: "a", Version: "1.20.0"}); err != nil { + t.Fatal(err) + } + if l.RunFile(v)+".lock" != l.RunLockFile(v) { + t.Fatalf("lock paths differ") + } + held, err := AcquireLock(l.RunLockFile(v)) + if err != nil { + t.Fatal(err) + } + if err := MarkRunStartedLocked(l.RunFile(v), "a", 100*time.Millisecond); err == nil { + t.Fatal("marker written while the bootstrap held the run lock") + } + held.Release() + if err := MarkRunStartedLocked(l.RunFile(v), "a", time.Second); err != nil { + t.Fatal(err) + } +} diff --git a/internal/updatestate/runs.go b/internal/updatestate/runs.go new file mode 100644 index 00000000..293a4c7e --- /dev/null +++ b/internal/updatestate/runs.go @@ -0,0 +1,326 @@ +package updatestate + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +// A launch can't outlive the agent's execution deadline (4 h default), plus +// the scan task's ExecutionTimeLimit margin over it (30 min, which covers +// the bootstrap's own watchdog slack), plus abandonSlack. An open record +// older than that belongs to a bootstrap that died (logoff, shutdown, the +// task time limit) and will never be finalized. +const ( + runAgentDeadline = 4 * time.Hour + runTaskMargin = 30 * time.Minute + abandonSlack = 2 * time.Hour + abandonAfter = runAgentDeadline + runTaskMargin + abandonSlack +) + +// MaxRunRecords is how many run records a runs\.json keeps. +const MaxRunRecords = 10 + +// maxRunsSize bounds what ReadRuns reads from the user-writable file. +const maxRunsSize = 64 << 10 + +// RunRecord is one bootstrap launch of a version. The bootstrap generates +// RunID, appends the record and passes STEPSEC_RUN_ID to the agent; the +// agent sets Started right after a successful run-status "started", and the +// bootstrap finalizes ExitCode, WatchdogKilled and At. Both updates match by +// RunID, so overlapping launches never touch each other's record. A nil +// ExitCode means not finalized yet. +type RunRecord struct { + RunID string `json:"run_id,omitempty"` + // Seq is the per-file launch sequence number (max+1 at append). + Seq int `json:"seq,omitempty"` + Version string `json:"version"` + // StartedAt is when the record was appended (the launch began). + StartedAt time.Time `json:"start,omitzero"` + // Abandoned marks an open record whose launch outlived any possible + // scan (its bootstrap died); it is Neutral and evicted first. + Abandoned bool `json:"abandoned,omitempty"` + // OwnerPID/OwnerStart identify the bootstrap process that owns an open + // record (its creation time guards against PID reuse); FirstSeen is + // when a writer first saw a record whose start time it couldn't trust. + OwnerPID int `json:"owner_pid,omitempty"` + OwnerStart time.Time `json:"owner_start,omitzero"` + FirstSeen time.Time `json:"first_seen,omitzero"` + ExecutionID string `json:"execution_id,omitempty"` + Started bool `json:"started"` + ExitCode *int `json:"exit_code,omitempty"` + WatchdogKilled bool `json:"watchdog_killed,omitempty"` + At time.Time `json:"at,omitzero"` +} + +type runFile struct { + Records []RunRecord `json:"records"` + // MaxSeq is the highest Seq ever assigned in this file, kept across + // trimming so sequence numbers never repeat. + MaxSeq int `json:"max_seq,omitempty"` +} + +// ErrNoOpenRun means the run file has no unfinalized record with that run_id. +var ErrNoOpenRun = errors.New("updatestate: no open run record") + +// ReadRuns returns every record in the file, oldest first; trimming +// happens only when writing. The file +// is user-writable, so anything unreadable or malformed reads as no records +// and records with an invalid version are dropped; it never fails. +func ReadRuns(path string) []RunRecord { + recs, _ := readRunFile(path) + return recs +} + +// MaxRunSeq is the highest sequence number assigned in a run file (0 when +// none). The updater records it at a switch as the probation's start. +func MaxRunSeq(path string) int { + _, maxSeq := readRunFile(path) + return maxSeq +} + +func readRunFile(path string) ([]RunRecord, int) { + data, err := readBounded(path, maxRunsSize) + if err != nil { + return nil, 0 + } + return decodeRunFile(data) +} + +func decodeRuns(data []byte) []RunRecord { + recs, _ := decodeRunFile(data) + return recs +} + +// decodeRunFile returns the valid records and the file's highest seq. +func decodeRunFile(data []byte) ([]RunRecord, int) { + var f runFile + if json.Unmarshal(data, &f) != nil { + return nil, 0 + } + maxSeq := max(f.MaxSeq, 0) + for _, r := range f.Records { + maxSeq = max(maxSeq, r.Seq) + } + out := f.Records[:0] + for _, r := range f.Records { + if validVersion(r.Version) { + out = append(out, r) + } + } + if len(out) == 0 { + return nil, maxSeq + } + return out, maxSeq +} + +// trimRuns keeps at most MaxRunRecords plus any fresh open records, +// evicting the oldest closed or abandoned records first. A fresh open +// record (its launch still running) and the record named protect (the one +// this write finalizes or marks) are never evicted. Order is kept. +func trimRuns(recs []RunRecord, protect string) []RunRecord { + excess := len(recs) - MaxRunRecords + if excess <= 0 { + return recs + } + out := make([]RunRecord, 0, len(recs)) + for _, r := range recs { + if excess > 0 && ClassifyRun(r) != RunOpen && (protect == "" || r.RunID != protect) { + excess-- + continue + } + out = append(out, r) + } + return out +} + +// abandonStale marks open records whose bootstrap is gone as abandoned: +// they will never be finalized. Liveness comes from the owning process +// (PID + creation time). Only when it can't be determined does wall-clock +// age decide, from the start time, or from FirstSeen when the start time +// is missing or in the future (clock moved). +func abandonStale(recs []RunRecord, now time.Time, protect string) { + for i := range recs { + r := &recs[i] + if r.Abandoned || ClassifyRun(*r) != RunOpen || (protect != "" && r.RunID == protect) { + continue // the record this write touches is its own live evidence + } + switch ownerOf(*r) { + case ownerDead: + r.Abandoned = true + case ownerAlive: + default: + switch { + case !r.StartedAt.IsZero() && !r.StartedAt.After(now): + r.Abandoned = now.Sub(r.StartedAt) > abandonAfter + case r.FirstSeen.IsZero(): + r.FirstSeen = now.UTC() + default: + r.Abandoned = now.Sub(r.FirstSeen) > abandonAfter + } + } + } +} + +// writeRuns abandons stale open records, trims (never evicting protect) +// and writes. Callers hold the run-record lock. +func writeRuns(path string, recs []RunRecord, protect string, maxSeq int) error { + abandonStale(recs, time.Now(), protect) + recs = trimRuns(recs, protect) + data, err := json.Marshal(runFile{Records: recs, MaxSeq: maxSeq}) + if err != nil { + return err + } + // #nosec G301 -- runs\ is shared by the user scan and the SYSTEM updater. + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return writeAtomic(path, data, nil, nil) +} + +// ErrRunID means AppendRun got an empty run_id or one already in the file; +// such a record could never be finalized by its own launch. +var ErrRunID = errors.New("updatestate: run record needs a unique run_id") + +// AppendRun appends r, keeping the newest MaxRunRecords. +func AppendRun(path string, r RunRecord) error { + if !validVersion(r.Version) { + return errors.New("updatestate: run record needs a strict x.y.z version") + } + if r.RunID == "" { + return ErrRunID + } + recs, maxSeq := readRunFile(path) + for _, existing := range recs { + if existing.RunID == r.RunID { + return ErrRunID + } + } + if r.StartedAt.IsZero() { + r.StartedAt = time.Now().UTC() + } + stampOwner(&r) + maxSeq++ + r.Seq = maxSeq + return writeRuns(path, append(recs, r), r.RunID, maxSeq) +} + +// MarkRunStarted sets the started marker on the open record runID. +func MarkRunStarted(path, runID string) error { + return updateOpenRun(path, runID, func(r *RunRecord) { r.Started = true }) +} + +// RunLockPath is the lock beside a run file (runs\.json.lock) that every +// read-modify-write of it takes: the bootstrap's AppendRun/FinalizeRun (via +// Layout.RunLockFile) and the agent's MarkRunStartedLocked. +func RunLockPath(runFile string) string { return runFile + ".lock" } + +// MarkRunStartedLocked is the agent's MarkRunStarted under the run-record +// lock the bootstrap takes for AppendRun/FinalizeRun (RunLockPath), waiting +// at most wait. On timeout it +// returns an error without writing; callers skip the marker and never fail +// the scan. +func MarkRunStartedLocked(path, runID string, wait time.Duration) error { + lockPath := RunLockPath(path) + deadline := time.Now().Add(wait) + for { + l, err := AcquireLock(lockPath) + if err == nil { + defer l.Release() + return MarkRunStarted(path, runID) + } + if !errors.Is(err, ErrLocked) || !time.Now().Before(deadline) { + return fmt.Errorf("run-record lock %s: %w", lockPath, err) + } + time.Sleep(50 * time.Millisecond) + } +} + +// FinalizeRun closes the open record runID. +func FinalizeRun(path, runID string, exitCode int, watchdogKilled bool, at time.Time) error { + return updateOpenRun(path, runID, func(r *RunRecord) { + r.ExitCode = &exitCode + r.WatchdogKilled = watchdogKilled + r.At = at.UTC() + }) +} + +func updateOpenRun(path, runID string, fn func(*RunRecord)) error { + if runID == "" { + return ErrNoOpenRun + } + recs, maxSeq := readRunFile(path) + for i := range recs { + // An abandoned record is revivable by its own run: its late + // finalize or marker records the real outcome. + if r := recs[i]; r.RunID == runID && r.ExitCode == nil && !r.WatchdogKilled { + recs[i].Abandoned = false + fn(&recs[i]) + return writeRuns(path, recs, runID, maxSeq) + } + } + return ErrNoOpenRun +} + +// RunVerdict is the health reading of one run record (spec §4). +type RunVerdict int + +const ( + // RunOpen: not finalized (still running, or the bootstrap died). + RunOpen RunVerdict = iota + // RunHealthy: the started marker is set, no panic exit, no watchdog kill. + RunHealthy + // RunBad: a watchdog kill, exit code 2 (Go panic), or an exit before the + // started marker with a crash-like code (anything but 0 or 1). + RunBad + // RunNeutral: exited 0 or 1 without the started marker. That is how the + // agent ends when the backend is unreachable or another scan holds the + // instance lock; spec §4 says environmental failures are not bad runs. + RunNeutral +) + +// cannotStartExitCode is the launcher's "couldn't start the agent" (D5). +const cannotStartExitCode = 70 + +// sessionEnd reports exits caused by the user's session ending, not by the +// version: 130 (the agent's SIGTERM path, which Go maps CTRL_CLOSE / +// CTRL_LOGOFF / CTRL_SHUTDOWN events to), STATUS_CONTROL_C_EXIT 0xC000013A +// and DBG_TERMINATE_PROCESS 0x40010004 (a process terminated at logoff or +// shutdown). Compared as uint32, so the int32 and uint32 forms both match. +func sessionEnd(code int) bool { + switch uint32(code) { // #nosec G115 -- Windows exit codes are 32-bit + case 130, 0xC000013A, 0x40010004: + return true + } + return false +} + +// panicExitCode is the exit status of an unrecovered Go panic (and of the +// agent's watchdog hard-out). +const panicExitCode = 2 + +// ClassifyRun applies the bad-run rule to one record. +func ClassifyRun(r RunRecord) RunVerdict { + switch { + case r.Abandoned: + // Its launch died unrecorded: neither a crash nor a scan. + return RunNeutral + case r.WatchdogKilled: + return RunBad + case r.ExitCode == nil: + return RunOpen + case *r.ExitCode == panicExitCode || *r.ExitCode == cannotStartExitCode: + // Kills and crashes are judged before the started marker: a version + // that crashes or hangs after posting started is still bad. + return RunBad + case r.Started: + return RunHealthy + case *r.ExitCode == 0 || *r.ExitCode == 1 || sessionEnd(*r.ExitCode): + return RunNeutral + default: + return RunBad + } +} diff --git a/internal/updatestate/runs_test.go b/internal/updatestate/runs_test.go new file mode 100644 index 00000000..d000af18 --- /dev/null +++ b/internal/updatestate/runs_test.go @@ -0,0 +1,460 @@ +package updatestate + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/proctest" +) + +func TestRunRecordsLifecycle(t *testing.T) { + path := filepath.Join(t.TempDir(), "runs", "1.19.0.json") + if recs := ReadRuns(path); recs != nil { + t.Fatalf("missing file = %v", recs) + } + if err := MarkRunStarted(path, "x"); err != ErrNoOpenRun { + t.Fatalf("MarkRunStarted on empty = %v", err) + } + if err := AppendRun(path, RunRecord{Version: "bad"}); err == nil { + t.Fatal("AppendRun accepted a bad version") + } + at := time.Date(2026, 10, 19, 1, 2, 3, 0, time.UTC) + for i := 0; i < 13; i++ { + id := fmt.Sprintf("run-%d", i) + if err := AppendRun(path, RunRecord{RunID: id, Version: "1.19.0"}); err != nil { + t.Fatal(err) + } + if i%2 == 0 { + if err := MarkRunStarted(path, id); err != nil { + t.Fatal(err) + } + } + if err := FinalizeRun(path, id, i%3, false, at.Add(time.Duration(i)*time.Minute)); err != nil { + t.Fatal(err) + } + } + if err := FinalizeRun(path, "run-12", 0, false, at); err != ErrNoOpenRun { + t.Fatalf("FinalizeRun with no open record = %v", err) + } + recs := ReadRuns(path) + if len(recs) != MaxRunRecords { + t.Fatalf("kept %d records, want %d", len(recs), MaxRunRecords) + } + first, last := recs[0], recs[len(recs)-1] + if !first.At.Equal(at.Add(3*time.Minute)) || !last.At.Equal(at.Add(12*time.Minute)) { + t.Fatalf("kept the wrong window: %v .. %v", first.At, last.At) + } + if !last.Started || last.RunID != "run-12" || *last.ExitCode != 0 { + t.Fatalf("last record %+v", last) + } + if err := AppendRun(path, RunRecord{RunID: "run-13", Version: "1.19.0"}); err != nil { + t.Fatal(err) + } + if recs := ReadRuns(path); ClassifyRun(recs[len(recs)-1]) != RunOpen { + t.Fatal("new record is not open") + } +} + +// R-F1-1: overlapping launches (hourly + logon on wake) each keep their own +// record, whatever order they start and finish in. +func TestOverlappingRunsMatchByRunID(t *testing.T) { + at := time.Date(2026, 10, 19, 1, 0, 0, 0, time.UTC) + for _, aFirst := range []bool{true, false} { + path := filepath.Join(t.TempDir(), "1.19.0.json") + for _, id := range []string{"A", "B"} { + if err := AppendRun(path, RunRecord{RunID: id, Version: "1.19.0"}); err != nil { + t.Fatal(err) + } + } + finalizeA := func() { + if err := MarkRunStarted(path, "A"); err != nil { + t.Fatalf("mark A: %v", err) + } + if err := FinalizeRun(path, "A", 0, false, at); err != nil { + t.Fatalf("finalize A: %v", err) + } + } + finalizeB := func() { + if err := FinalizeRun(path, "B", 1, false, at); err != nil { + t.Fatalf("finalize B: %v", err) + } + } + if aFirst { + finalizeA() + finalizeB() + } else { + finalizeB() + finalizeA() + } + got := map[string]RunVerdict{} + for _, r := range ReadRuns(path) { + got[r.RunID] = ClassifyRun(r) + } + if got["A"] != RunHealthy || got["B"] != RunNeutral { + t.Fatalf("aFirst=%v verdicts = %v, want A healthy, B neutral", aFirst, got) + } + if err := MarkRunStarted(path, "A"); err != ErrNoOpenRun { + t.Fatalf("closed record reopened: %v", err) + } + if err := MarkRunStarted(path, ""); err != ErrNoOpenRun { + t.Fatalf("empty run id matched: %v", err) + } + } +} + +// D13: a record without a run_id, or with one already in the file, could +// never be matched (or would be matched twice), so AppendRun refuses it. +func TestAppendRunRequiresUniqueRunID(t *testing.T) { + path := filepath.Join(t.TempDir(), "1.19.0.json") + if err := AppendRun(path, RunRecord{Version: "1.19.0"}); err == nil { + t.Fatal("empty run_id accepted") + } + if err := AppendRun(path, RunRecord{RunID: "A", Version: "1.19.0"}); err != nil { + t.Fatal(err) + } + if err := AppendRun(path, RunRecord{RunID: "A", Version: "1.19.0"}); err == nil { + t.Fatal("duplicate run_id accepted") + } + if recs := ReadRuns(path); len(recs) != 1 { + t.Fatalf("records = %+v", recs) + } +} + +func TestReadRunsToleratesGarbage(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "1.19.0.json") + for _, body := range []string{"", "garbage", "[]", `{"records":"x"}`, `{"records":[{"version":1}]}`, + `{"records":[{"version":"1.19.0","exit_code":"x"}]}`, `{"records":[{"version":"../x"}]}`} { + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + if recs := ReadRuns(path); recs != nil { + t.Errorf("ReadRuns(%q) = %+v, want none", body, recs) + } + } + // Garbage is replaced, not appended to. + if err := AppendRun(path, RunRecord{RunID: "r1", Version: "1.19.0"}); err != nil { + t.Fatal(err) + } + if recs := ReadRuns(path); len(recs) != 1 { + t.Fatalf("after append over garbage: %+v", recs) + } + // Records with bad versions are dropped individually. + body := `{"records":[{"version":"1.19.0","started":true,"exit_code":0},{"version":"x"}]}` + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + if recs := ReadRuns(path); len(recs) != 1 || !recs[0].Started { + t.Fatalf("mixed file: %+v", recs) + } + big := make([]byte, maxRunsSize+1) + if err := os.WriteFile(path, big, 0o600); err != nil { + t.Fatal(err) + } + if recs := ReadRuns(path); recs != nil { + t.Fatalf("oversize file read as %d records", len(recs)) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + if recs := ReadRuns(path); recs != nil { + t.Fatal("directory read as records") + } +} + +func TestClassifyRun(t *testing.T) { + code := func(c int) *int { return &c } + for _, tc := range []struct { + name string + r RunRecord + want RunVerdict + }{ + {"open", RunRecord{}, RunOpen}, + {"open started", RunRecord{Started: true}, RunOpen}, + {"watchdog while open", RunRecord{WatchdogKilled: true}, RunBad}, + {"healthy", RunRecord{Started: true, ExitCode: code(0)}, RunHealthy}, + {"healthy error exit", RunRecord{Started: true, ExitCode: code(1)}, RunHealthy}, + {"panic after start", RunRecord{Started: true, ExitCode: code(2)}, RunBad}, + {"watchdog after start", RunRecord{Started: true, ExitCode: code(0), WatchdogKilled: true}, RunBad}, + {"panic before start", RunRecord{ExitCode: code(2)}, RunBad}, + {"crash before start", RunRecord{ExitCode: code(-1073741819)}, RunBad}, + {"crash code before start", RunRecord{ExitCode: code(3221225477)}, RunBad}, + {"offline or lock held", RunRecord{ExitCode: code(1)}, RunNeutral}, + {"clean exit without marker", RunRecord{ExitCode: code(0)}, RunNeutral}, + } { + if got := ClassifyRun(tc.r); got != tc.want { + t.Errorf("%s: %v, want %v", tc.name, got, tc.want) + } + } +} + +// D17: trimming to the last MaxRunRecords evicts the oldest CLOSED records +// first; a record still open (its launch is running) is never evicted, so +// its own finalize/marker still finds it. +func TestTrimNeverEvictsOpenRecords(t *testing.T) { + path := filepath.Join(t.TempDir(), "1.19.0.json") + at := time.Date(2026, 10, 19, 1, 0, 0, 0, time.UTC) + if err := AppendRun(path, RunRecord{RunID: "long-running", Version: "1.19.0"}); err != nil { + t.Fatal(err) + } + for i := 0; i < MaxRunRecords+3; i++ { + id := fmt.Sprintf("r%d", i) + if err := AppendRun(path, RunRecord{RunID: id, Version: "1.19.0"}); err != nil { + t.Fatal(err) + } + if err := FinalizeRun(path, id, 0, false, at); err != nil { + t.Fatal(err) + } + } + recs := ReadRuns(path) + if len(recs) != MaxRunRecords || recs[0].RunID != "long-running" || recs[len(recs)-1].RunID != fmt.Sprintf("r%d", MaxRunRecords+2) { + t.Fatalf("kept %d records: first %q last %q", len(recs), recs[0].RunID, recs[len(recs)-1].RunID) + } + if err := MarkRunStarted(path, "long-running"); err != nil { + t.Fatalf("open record evicted: %v", err) + } + if err := FinalizeRun(path, "long-running", 0, false, at); err != nil { + t.Fatalf("open record evicted: %v", err) + } +} + +// R-F1-8: open records whose bootstrap died (logoff, shutdown, the task +// time limit) are abandoned once older than a scan's maximum life: they +// become Neutral, are evicted first, and the record finalized in the same +// write is never evicted, so a crash still reads as Bad. +func TestStaleOpenRecordsDoNotEvictTheFinalizedOne(t *testing.T) { + path := filepath.Join(t.TempDir(), "1.20.0.json") + stale := time.Now().Add(-(abandonAfter + time.Hour)) + dead := deadPID(t) + for i := 0; i < 12; i++ { + if err := AppendRun(path, RunRecord{RunID: fmt.Sprintf("dead-%d", i), Version: "1.20.0", StartedAt: stale, OwnerPID: dead}); err != nil { + t.Fatal(err) + } + } + if err := AppendRun(path, RunRecord{RunID: "crash", Version: "1.20.0"}); err != nil { + t.Fatal(err) + } + if err := FinalizeRun(path, "crash", 2, false, time.Now()); err != nil { + t.Fatalf("finalize: %v", err) + } + recs := ReadRuns(path) + var crash *RunRecord + abandoned := 0 + for i := range recs { + switch { + case recs[i].RunID == "crash": + crash = &recs[i] + case recs[i].Abandoned: + abandoned++ + if v := ClassifyRun(recs[i]); v != RunNeutral { + t.Fatalf("abandoned record classified %v", v) + } + } + } + if crash == nil || ClassifyRun(*crash) != RunBad { + t.Fatalf("the finalized crash record was lost or not Bad: %+v", recs) + } + if len(recs) > MaxRunRecords { + t.Fatalf("%d records kept, want ≤ %d with no fresh open ones", len(recs), MaxRunRecords) + } + // Fresh open records are never evicted and don't count against the cap. + for i := 0; i < 3; i++ { + if err := AppendRun(path, RunRecord{RunID: fmt.Sprintf("live-%d", i), Version: "1.20.0"}); err != nil { + t.Fatal(err) + } + } + recs = ReadRuns(path) + live := 0 + for _, r := range recs { + if strings.HasPrefix(r.RunID, "live-") { + live++ + } + } + if live != 3 || len(recs) > MaxRunRecords+3 { + t.Fatalf("live=%d len=%d", live, len(recs)) + } +} + +// deadPID is the pid of a process that has already exited. +func deadPID(t *testing.T) int { + t.Helper() + cmd := proctest.Command(t, os.Args[0], "-test.run=^$") + if err := cmd.Run(); err != nil { + t.Fatal(err) + } + return cmd.Process.Pid +} + +func recordOf(t *testing.T, path, id string) RunRecord { + t.Helper() + for _, r := range ReadRuns(path) { + if r.RunID == id { + return r + } + } + t.Fatalf("record %s missing", id) + return RunRecord{} +} + +// R-F1-9: abandonment follows the owning bootstrap's liveness, not the +// wall clock: a laptop asleep for two days doesn't abandon a live run. +func TestAbandonFollowsOwnerLiveness(t *testing.T) { + path := filepath.Join(t.TempDir(), "1.20.0.json") + twoDaysAgo := time.Now().Add(-48 * time.Hour) + future := time.Now().Add(48 * time.Hour) + dead := deadPID(t) + for _, r := range []RunRecord{ + {RunID: "asleep-alive", StartedAt: twoDaysAgo}, // owner = this process (alive) + {RunID: "fresh-dead", OwnerPID: dead}, + {RunID: "future-dead", StartedAt: future, OwnerPID: dead}, + } { + r.Version = "1.20.0" + if err := AppendRun(path, r); err != nil { + t.Fatal(err) + } + } + if err := AppendRun(path, RunRecord{RunID: "trigger", Version: "1.20.0"}); err != nil { + t.Fatal(err) + } + if r := recordOf(t, path, "asleep-alive"); r.Abandoned { + t.Fatal("a live owner's old run was abandoned") + } + for _, id := range []string{"fresh-dead", "future-dead"} { + if r := recordOf(t, path, id); !r.Abandoned { + t.Fatalf("%s: dead owner's run not abandoned", id) + } + } + // A late finalize by the run itself revives it with the real outcome. + if err := FinalizeRun(path, "fresh-dead", 2, false, time.Now()); err != nil { + t.Fatalf("late finalize: %v", err) + } + if r := recordOf(t, path, "fresh-dead"); r.Abandoned || ClassifyRun(r) != RunBad { + t.Fatalf("late finalize lost: %+v", r) + } + if err := MarkRunStarted(path, "future-dead"); err != nil { + t.Fatalf("late marker: %v", err) + } + if r := recordOf(t, path, "future-dead"); r.Abandoned || !r.Started { + t.Fatalf("late marker lost: %+v", r) + } +} + +// Without an owner (older records), a future start time counts from when a +// writer first saw it. +func TestAbandonUnknownOwnerFutureStart(t *testing.T) { + path := filepath.Join(t.TempDir(), "1.20.0.json") + future := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339Nano) + body := `{"records":[{"run_id":"old","version":"1.20.0","start":"` + future + `"}]}` + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + if err := AppendRun(path, RunRecord{RunID: "t1", Version: "1.20.0"}); err != nil { + t.Fatal(err) + } + r := recordOf(t, path, "old") + if r.Abandoned || r.FirstSeen.IsZero() { + t.Fatalf("first sighting: %+v", r) + } + // Seen long ago: past the bound now. + data, _ := os.ReadFile(path) + stale := time.Now().Add(-(abandonAfter + time.Hour)).UTC().Format(time.RFC3339Nano) + data = []byte(strings.Replace(string(data), r.FirstSeen.Format(time.RFC3339Nano), stale, 1)) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + if err := AppendRun(path, RunRecord{RunID: "t2", Version: "1.20.0"}); err != nil { + t.Fatal(err) + } + if r := recordOf(t, path, "old"); !r.Abandoned { + t.Fatalf("unknown owner, seen long ago: not abandoned: %+v", r) + } +} + +// R-F1-10: the reader never trims: 10 fresh open runs plus a just-finalized +// crash, and the crash is visible. +func TestReadRunsNeverTrims(t *testing.T) { + path := filepath.Join(t.TempDir(), "1.20.0.json") + for i := 0; i < 10; i++ { + if err := AppendRun(path, RunRecord{RunID: fmt.Sprintf("live-%d", i), Version: "1.20.0"}); err != nil { + t.Fatal(err) + } + } + if err := AppendRun(path, RunRecord{RunID: "crash", Version: "1.20.0"}); err != nil { + t.Fatal(err) + } + if err := FinalizeRun(path, "crash", 2, false, time.Now()); err != nil { + t.Fatal(err) + } + if r := recordOf(t, path, "crash"); ClassifyRun(r) != RunBad { + t.Fatalf("crash = %+v", r) + } + if n := len(ReadRuns(path)); n != 11 { + t.Fatalf("reader returned %d records, want 11", n) + } +} + +// FR1-7: the watchdogs count awake time, so any watchdog kill is a real +// hang and Bad, before or after the started marker; kill/crash codes are +// judged before the marker. +func TestKillsAndCrashesAreBadEvenAfterStarted(t *testing.T) { + two, seventy, seventyFive := 2, 70, 75 + for _, r := range []RunRecord{ + {Started: true, WatchdogKilled: true, ExitCode: &seventyFive}, + {Started: true, ExitCode: &two}, + {Started: true, ExitCode: &seventy}, + {Started: false, ExitCode: &seventyFive}, + } { + r.Version, r.RunID = "1.20.0", "x" + if v := ClassifyRun(r); v != RunBad { + t.Errorf("%+v: verdict %v, want Bad", r, v) + } + } +} + +// FR1-5: exits caused by the session ending (logoff, shutdown, console +// close) are Neutral; a real crash code is still Bad. +func TestSessionEndExitsAreNeutral(t *testing.T) { + codes := []int{-1073741510} // 0xC000013A as int32 + for _, u := range []uint32{130, 0xC000013A, 0x40010004} { + codes = append(codes, int(u)) // uint32 form (wraps on 32-bit hosts) + } + for _, code := range codes { + c := code + if v := ClassifyRun(RunRecord{Version: "1.20.0", RunID: "x", ExitCode: &c}); v != RunNeutral { + t.Errorf("exit %#x: verdict %v, want Neutral", code, v) + } + } + crash := int(uint32(0xC0000005)) + if ClassifyRun(RunRecord{Version: "1.20.0", RunID: "x", ExitCode: &crash}) != RunBad { + t.Fatal("access violation not Bad") + } +} + +// FR1-6: every appended record gets the next per-file sequence number, and +// it keeps counting past trimmed records. +func TestRunSeqIsMonotonic(t *testing.T) { + path := filepath.Join(t.TempDir(), "1.20.0.json") + at := time.Now() + for i := 1; i <= MaxRunRecords+5; i++ { + id := fmt.Sprintf("r%d", i) + if err := AppendRun(path, RunRecord{RunID: id, Version: "1.20.0"}); err != nil { + t.Fatal(err) + } + if err := FinalizeRun(path, id, 0, false, at); err != nil { + t.Fatal(err) + } + if got := recordOf(t, path, id).Seq; got != i { + t.Fatalf("record %d has seq %d", i, got) + } + } + if MaxRunSeq(path) != MaxRunRecords+5 { + t.Fatalf("MaxRunSeq = %d", MaxRunSeq(path)) + } +} diff --git a/internal/updatestate/seed.go b/internal/updatestate/seed.go new file mode 100644 index 00000000..4c436388 --- /dev/null +++ b/internal/updatestate/seed.go @@ -0,0 +1,153 @@ +package updatestate + +import ( + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/step-security/dev-machine-guard/internal/faultinject" +) + +// CopySeedVersion copies seed\ into versions\ through a staging dir +// after the seed's agent and launcher pass verifyPublisher, checks every +// copied file against the source's sha256 and size taken before the copy, +// writes its manifest (so the updater treats it as a verified version) and +// renames it into place. A dir that already verifies is kept (immutable); +// anything else at that path is moved to trash first. Used by the MSI's +// adopt-seed and by the updater; the caller holds the update lock. It +// returns a note for the log ("" when nothing was copied). +func (l Layout) CopySeedVersion(v Version, now time.Time, verifyPublisher func(path string) error) (string, error) { + // The seed's executables must come from our publisher (nil: skip). + if verifyPublisher != nil { + for _, name := range []string{AgentFile, LauncherFile} { + if err := verifyPublisher(filepath.Join(l.SeedDir(v), name)); err != nil { + return "", fmt.Errorf("seed %s %s: %w", v, name, err) + } + } + } + // Hash the source first: the copy must match it, not just itself. + type fileSum struct { + sum string + size int64 + } + src := map[string]fileSum{} + for _, name := range VersionFiles { + p := filepath.Join(l.SeedDir(v), name) + fi, err := os.Lstat(p) + if err != nil || !fi.Mode().IsRegular() { + return "", fmt.Errorf("seed %s: %s missing or not a file", v, name) + } + sum, err := FileSHA256(p) + if err != nil { + return "", err + } + src[name] = fileSum{sum, fi.Size()} + } + afterSeedHashed() + dst := l.VersionDir(v) + note := "" + if _, err := os.Lstat(dst); err == nil { + if VerifyVersionDir(dst, v) == nil { + return "", nil + } + trash := filepath.Join(l.VersionsDir(), fmt.Sprintf("%s%s-%x", TrashPrefix, v, now.UnixNano())) + if err := os.Rename(dst, trash); err != nil { + return "", fmt.Errorf("moving unverifiable %s aside: %w", dst, err) + } + _ = os.RemoveAll(trash) + note = fmt.Sprintf("moved unverifiable %s aside; ", dst) + } + staging, err := l.StagingDir(v) + if err != nil { + return "", err + } + done := false + defer func() { + if !done { + _ = os.RemoveAll(staging) + } + }() + m := Manifest{Version: v.String(), Files: map[string]string{}} + for _, name := range VersionFiles { + dstFile := filepath.Join(staging, name) + if err := copySeedFile(filepath.Join(l.SeedDir(v), name), dstFile); err != nil { + return "", err + } + sum, err := FileSHA256(dstFile) + if err != nil { + return "", err + } + fi, err := os.Stat(dstFile) + if err != nil { + return "", err + } + if want := src[name]; sum != want.sum || fi.Size() != want.size { + return "", fmt.Errorf("seed %s: copy of %s doesn't match the source hashed before copying", v, name) + } + m.Files[name] = sum + } + if err := WriteManifest(staging, m); err != nil { + return "", err + } + if err := faultinject.Point("adopt.copied"); err != nil { + return "", err + } + if err := os.Rename(staging, dst); err != nil { + return "", err + } + done = true + return note + fmt.Sprintf("copied seed %s into %s", v, dst), nil +} + +// afterSeedHashed runs between hashing the seed and copying it (tests). +var afterSeedHashed = func() {} + +func copySeedFile(src, dst string) error { + in, err := os.Open(src) // #nosec G304 -- MSI-owned seed payload + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o755) // #nosec G304 G302 -- fresh staging dir + if err != nil { + return err + } + _, err = io.Copy(out, in) + if err == nil { + err = out.Sync() + } + if cerr := out.Close(); err == nil { + err = cerr + } + return err +} + +// AdoptSeedRule applies seed adoption to an existing state in memory: +// current = seed when current's dir is unusable, or when the seed is newer +// than current and not under an unexpired quarantine (previous = the old +// current, and the old current's probation ends). An MSI never downgrades a +// self-updated device. It reports whether st changed and why. +func (l Layout) AdoptSeedRule(st *State, seed Version, now time.Time) (changed bool, why string) { + s, old := seed.String(), st.Current + cur, _ := ParseVersion(old) + switch { + case !VersionDirUsable(l.VersionDir(cur)): + st.Current = s + if st.Probation != nil && st.Probation.Version == old { + st.Probation = nil + } + if st.Previous == s { + st.Previous = "" + } + return true, fmt.Sprintf("current %s unusable; current = seed %s", old, s) + case Compare(seed, cur) <= 0: + return false, "" + } + if q, ok := st.Quarantine[s]; ok && (q.Until.IsZero() || now.Before(q.Until)) { + return false, fmt.Sprintf("seed %s is quarantined; current %s kept", s, old) + } + st.Previous, st.Current, st.Probation = old, s, nil + return true, fmt.Sprintf("seed %s newer than current %s; current = seed", s, old) +} diff --git a/internal/updatestate/seed_test.go b/internal/updatestate/seed_test.go new file mode 100644 index 00000000..2886757b --- /dev/null +++ b/internal/updatestate/seed_test.go @@ -0,0 +1,70 @@ +package updatestate + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func writeSeedFiles(t *testing.T, l Layout, v Version) { + t.Helper() + dir := l.SeedDir(v) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for _, f := range VersionFiles { + if err := os.WriteFile(filepath.Join(dir, f), []byte("seed:"+f), 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.MkdirAll(l.VersionsDir(), 0o755); err != nil { + t.Fatal(err) + } +} + +// FR1-8: the seed's executables must pass the publisher check before +// anything is copied. +func TestCopySeedVersionChecksPublisherFirst(t *testing.T) { + l := Layout{InstallRoot: t.TempDir(), DataRoot: t.TempDir()} + v, _ := ParseVersion("1.21.0") + writeSeedFiles(t, l, v) + var checked []string + bad := func(p string) error { + checked = append(checked, filepath.Base(p)) + return errors.New("publisher differs") + } + if _, err := l.CopySeedVersion(v, time.Now(), bad); err == nil { + t.Fatal("seed with a wrong publisher copied") + } + if _, err := os.Stat(l.VersionDir(v)); !os.IsNotExist(err) { + t.Fatal("versions dir created despite the publisher failure") + } + if len(checked) == 0 || checked[0] != AgentFile { + t.Fatalf("checked %v", checked) + } + ok := func(string) error { return nil } + if _, err := l.CopySeedVersion(v, time.Now(), ok); err != nil { + t.Fatal(err) + } +} + +// FR1-8: the copy must match hashes of the SOURCE taken before copying, so +// a seed changed mid-copy is not adopted. +func TestCopySeedVersionComparesSourceHashes(t *testing.T) { + l := Layout{InstallRoot: t.TempDir(), DataRoot: t.TempDir()} + v, _ := ParseVersion("1.21.0") + writeSeedFiles(t, l, v) + old := afterSeedHashed + afterSeedHashed = func() { + _ = os.WriteFile(filepath.Join(l.SeedDir(v), LinuxFile), []byte("swapped after hashing"), 0o755) + } + defer func() { afterSeedHashed = old }() + if _, err := l.CopySeedVersion(v, time.Now(), nil); err == nil { + t.Fatal("a seed changed during the copy was adopted") + } + if _, err := os.Stat(l.VersionDir(v)); !os.IsNotExist(err) { + t.Fatal("versions dir created from a changed seed") + } +} diff --git a/internal/updatestate/state.go b/internal/updatestate/state.go new file mode 100644 index 00000000..cc3013dd --- /dev/null +++ b/internal/updatestate/state.go @@ -0,0 +1,263 @@ +package updatestate + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "time" + + "github.com/step-security/dev-machine-guard/internal/faultinject" +) + +// SchemaVersion is the state.json schema this package writes. A file with a +// higher schema is read for its known fields and written back with its own +// schema number and unknown keys intact. +const SchemaVersion = 1 + +// maxStateSize bounds what Load reads; anything larger is corrupt. +const maxStateSize = 1 << 20 + +// State is state.json (schema 1, spec "Contracts"). Version fields hold +// strict x.y.z strings; Load guarantees they parse. +type State struct { + Schema int `json:"schema"` + Current string `json:"current"` + Previous string `json:"previous"` + Probation *Probation `json:"probation"` + Quarantine map[string]Quarantine `json:"quarantine"` + Updater Updater `json:"updater"` + SwitchDeferrals int `json:"switch_deferrals"` + Dedupe map[string]time.Time `json:"dedupe"` + + // Additive fields (schema 1 readers ignore them). + + // Generation is incremented by every Save. After a write error, a + // re-read showing the expected generation proves the write landed. + Generation uint64 `json:"generation,omitempty"` + // LastCheck is when the updater last asked latest-binary for a target. + LastCheck time.Time `json:"last_check,omitzero"` + // Outbox holds update events not yet accepted by the backend. Events + // are added in the same atomic write as the state change they report. + Outbox []OutboxEvent `json:"outbox,omitempty"` + // SmokeTimeouts counts consecutive smoke-test timeouts per version; the + // third quarantines it (a timeout alone is retried). + SmokeTimeouts map[string]int `json:"smoke_timeouts,omitempty"` + // Restages counts re-stagings of a version whose dir was damaged (an AV + // deleting a file); RestageBackoff pauses them (version -> until). + Restages map[string]Restage `json:"restages,omitempty"` + RestageBackoff map[string]time.Time `json:"restage_backoff,omitempty"` + // PendingSwitch is a staged target whose switch was deferred; its + // versions dir is kept and SwitchDeferrals counts for it alone. + PendingSwitch string `json:"pending_switch,omitempty"` + // OutboxDropped counts events dropped because the outbox was full. + OutboxDropped uint64 `json:"outbox_dropped,omitempty"` + + // Extra holds keys this version doesn't know; Save writes them back. + Extra map[string]json.RawMessage `json:"-"` +} + +// Restage counts re-stagings of one version since First (Last: the latest). +type Restage struct { + Count int `json:"count"` + First time.Time `json:"first"` + Last time.Time `json:"last"` +} + +// OutboxEvent is a pending update event (spec Contracts: Update event). +type OutboxEvent struct { + ExecutionID string `json:"execution_id"` + Event string `json:"update_event"` + From string `json:"update_from,omitempty"` + To string `json:"update_to,omitempty"` + Error string `json:"error_message,omitempty"` + At time.Time `json:"at"` + // Attempts and NextAttempt implement the retry backoff. + Attempts int `json:"attempts,omitempty"` + NextAttempt time.Time `json:"next_attempt,omitzero"` + + Extra map[string]json.RawMessage `json:"-"` // unknown keys, kept on Save +} + +// Probation tracks a newly switched-to version until it is confirmed or +// rolled back. OK and Bad count runs (Bad counts consecutive bad runs). +type Probation struct { + Version string `json:"version"` + Since time.Time `json:"since"` + OK int `json:"ok"` + Bad int `json:"bad"` + SmokeOK bool `json:"smoke_ok"` + // SinceSeq is the run file's highest sequence number at the switch; + // runs with a higher seq belong to the probation (additive). + SinceSeq *int `json:"since_seq,omitempty"` // nil: an older probation (time rule) + // Ticks counts update ticks that evaluated this probation, so it closes + // even if the clock jumps (additive). + Ticks int `json:"ticks,omitempty"` + + Extra map[string]json.RawMessage `json:"-"` // unknown keys, kept on Save +} + +type Quarantine struct { + Reason string `json:"reason"` + At time.Time `json:"at"` + // Until, when set, ends the quarantine (D5: inconclusive probation). + // Zero means permanent. + Until time.Time `json:"until,omitzero"` + + Extra map[string]json.RawMessage `json:"-"` // unknown keys, kept on Save +} + +// Updater records which version last ran the updater and how often in a +// row it failed, for the bootstrap's updater selection. +type Updater struct { + Version string `json:"version"` + ConsecutiveFailures int `json:"consecutive_failures"` + + Extra map[string]json.RawMessage `json:"-"` // unknown keys, kept on Save +} + +// Validate reports why s is not a well-formed schema-1 state. Current is +// required; the other version fields are optional but strict when set. +func (s *State) Validate() error { + switch { + case s.Schema < SchemaVersion: + // A higher schema is read for the fields this version knows; the + // rest is preserved (frozen read contract, D3/D15). + return fmt.Errorf("unsupported schema %d", s.Schema) + case !validVersion(s.Current): + return fmt.Errorf("invalid current %q", s.Current) + case s.Previous != "" && !validVersion(s.Previous): + return fmt.Errorf("invalid previous %q", s.Previous) + case s.Updater.Version != "" && !validVersion(s.Updater.Version): + return fmt.Errorf("invalid updater version %q", s.Updater.Version) + case s.SwitchDeferrals < 0 || s.Updater.ConsecutiveFailures < 0: + return errors.New("negative counter") + } + if p := s.Probation; p != nil { + if !validVersion(p.Version) { + return fmt.Errorf("invalid probation version %q", p.Version) + } + if p.OK < 0 || p.Bad < 0 { + return errors.New("negative probation counter") + } + } + for v := range s.Quarantine { + if !validVersion(v) { + return fmt.Errorf("invalid quarantined version %q", v) + } + } + return nil +} + +// decodeState parses and validates state.json. Unknown fields are ignored +// so an older agent can still read a newer schema-1 file after a rollback. +func decodeState(data []byte) (*State, error) { + var s State + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + if err := s.Validate(); err != nil { + return nil, err + } + if s.Quarantine == nil { + s.Quarantine = map[string]Quarantine{} + } + if s.Dedupe == nil { + s.Dedupe = map[string]time.Time{} + } + return &s, nil +} + +// Load reads state.json. A missing file returns (nil, false, nil). An +// unreadable-as-state file (bad JSON, unknown schema, invalid fields, +// oversize) returns (nil, true, nil): callers treat it as missing and report +// update_state_corrupt. err is set only for I/O failures other than absence. +func Load(path string) (st *State, corrupt bool, err error) { + data, err := readBounded(path, maxStateSize) + switch { + case errors.Is(err, os.ErrNotExist): + return nil, false, nil + case errors.Is(err, errUnreadable): + return nil, true, nil + case err != nil: + return nil, false, err + } + s, derr := decodeState(data) + if derr != nil { + return nil, true, nil + } + return s, false, nil +} + +// errUnreadable marks content that can never be valid: oversize or not a +// regular file. +var errUnreadable = errors.New("updatestate: not a bounded regular file") + +func readBounded(path string, limit int64) ([]byte, error) { + f, err := openShared(path) + if err != nil { + return nil, err + } + defer f.Close() + fi, err := f.Stat() + if err != nil { + return nil, err + } + if !fi.Mode().IsRegular() { + return nil, errUnreadable + } + data, err := io.ReadAll(io.LimitReader(f, limit+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, errUnreadable + } + return data, nil +} + +// checkpoint is swapped by tests that kill a subprocess mid-Save. +var checkpoint = faultinject.Point + +// Save atomically replaces state.json with st (schema set to 1). It refuses +// a state that Load would reject. After a crash at any point, Load returns +// either the old or the new state. +func Save(path string, st *State) error { return SaveCheckpointed(path, st, "") } + +// SaveCheckpointed writes st with Generation+1 (and sets st.Generation on +// success). It is Save with fault checkpoints .state-temp (temp +// file durable, target untouched) and .state-renamed (target +// replaced), e.g. prefix "S6", "rollback" or "adopt". An empty prefix +// disables them. An injected error at state-renamed is returned although the +// new state is already in place, as a late write error would be. +func SaveCheckpointed(path string, st *State, prefix string) error { + out := *st + if out.Schema < SchemaVersion { + out.Schema = SchemaVersion + } + out.Generation = st.Generation + 1 + if out.Quarantine == nil { + out.Quarantine = map[string]Quarantine{} + } + if out.Dedupe == nil { + out.Dedupe = map[string]time.Time{} + } + if err := out.Validate(); err != nil { + return fmt.Errorf("updatestate: refusing to save invalid state: %w", err) + } + data, err := json.MarshalIndent(&out, "", " ") + if err != nil { + return err + } + var afterTemp, afterRename func() error + if prefix != "" { + afterTemp = func() error { return checkpoint(prefix + ".state-temp") } + afterRename = func() error { return checkpoint(prefix + ".state-renamed") } + } + if err := writeAtomic(path, append(data, '\n'), afterTemp, afterRename); err != nil { + return err + } + st.Generation = out.Generation + return nil +} diff --git a/internal/updatestate/state_test.go b/internal/updatestate/state_test.go new file mode 100644 index 00000000..0a6eda95 --- /dev/null +++ b/internal/updatestate/state_test.go @@ -0,0 +1,359 @@ +package updatestate + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + "time" +) + +func sampleState() *State { + at := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC) + return &State{ + Current: "1.19.0", + Previous: "1.18.2", + Probation: &Probation{Version: "1.19.0", Since: at, OK: 1, SmokeOK: true}, + Quarantine: map[string]Quarantine{ + "1.18.9": {Reason: "health_failed", At: at}, + }, + Updater: Updater{Version: "1.18.2", ConsecutiveFailures: 1}, + SwitchDeferrals: 2, + Dedupe: map[string]time.Time{"update_check_failed:1.19.1": at}, + } +} + +func TestSaveLoadRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + want := sampleState() + if err := Save(path, want); err != nil { + t.Fatal(err) + } + got, corrupt, err := Load(path) + if err != nil || corrupt { + t.Fatalf("Load: corrupt=%v err=%v", corrupt, err) + } + want.Schema = SchemaVersion + if !reflect.DeepEqual(got, want) { + t.Fatalf("round trip:\n got %+v\nwant %+v", got, want) + } + // Minimal state: optional fields empty, maps normalized to {}. + if err := Save(path, &State{Current: "1.0.0"}); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(path) + for _, frag := range []string{`"schema": 1`, `"probation": null`, `"quarantine": {}`, `"dedupe": {}`} { + if !strings.Contains(string(raw), frag) { + t.Errorf("saved state lacks %s:\n%s", frag, raw) + } + } + SweepTemps(path) + entries, _ := os.ReadDir(filepath.Dir(path)) + if len(entries) != 1 { + t.Fatalf("leftover files after Save: %v", entries) + } +} + +func TestLoadMissing(t *testing.T) { + st, corrupt, err := Load(filepath.Join(t.TempDir(), "state.json")) + if st != nil || corrupt || err != nil { + t.Fatalf("Load(missing) = %v, %v, %v", st, corrupt, err) + } +} + +func TestLoadCorrupt(t *testing.T) { + cases := map[string]string{ + "empty": ``, + "garbage": "\x00\xffnot json", + "truncated": `{"schema":1,"current":"1.2.3"`, + "array": `[]`, + "null": `null`, + "schema 0": `{"current":"1.2.3"}`, + "schema 2 bad current": `{"schema":2,"current":"x"}`, + "no current": `{"schema":1}`, + "bad current": `{"schema":1,"current":"..\\..\\evil"}`, + "prerelease": `{"schema":1,"current":"1.2.3-rc.1"}`, + "bad previous": `{"schema":1,"current":"1.2.3","previous":"x"}`, + "bad probation": `{"schema":1,"current":"1.2.3","probation":{"version":""}}`, + "neg probation": `{"schema":1,"current":"1.2.3","probation":{"version":"1.2.3","bad":-1}}`, + "bad quarantine": `{"schema":1,"current":"1.2.3","quarantine":{"../x":{}}}`, + "bad updater": `{"schema":1,"current":"1.2.3","updater":{"version":"v1"}}`, + "neg deferrals": `{"schema":1,"current":"1.2.3","switch_deferrals":-1}`, + "bad time": `{"schema":1,"current":"1.2.3","dedupe":{"k":"yesterday"}}`, + "wrong type": `{"schema":"1","current":"1.2.3"}`, + "trailing garbage": `{"schema":1,"current":"1.2.3"} x`, + "oversize": `{"schema":1,"current":"1.2.3","pad":"` + strings.Repeat("a", maxStateSize) + `"}`, + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + st, corrupt, err := Load(path) + if st != nil || !corrupt || err != nil { + t.Fatalf("Load = %+v, corrupt=%v, err=%v; want corrupt", st, corrupt, err) + } + }) + } + t.Run("directory", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + if st, corrupt, err := Load(path); st != nil || (!corrupt && err == nil) { + t.Fatalf("Load(dir) = %v, %v, %v", st, corrupt, err) + } + }) + if runtime.GOOS != "windows" { + t.Run("symlink", func(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "real.json") + if err := Save(target, sampleState()); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "state.json") + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + if st, corrupt, err := Load(path); st != nil || !corrupt || err != nil { + t.Fatalf("Load(symlink) = %v, %v, %v", st, corrupt, err) + } + }) + } +} + +func TestLoadIgnoresUnknownFields(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + body := `{"schema":1,"current":"1.2.3","future_field":{"x":1},"probation":null}` + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + st, corrupt, err := Load(path) + if err != nil || corrupt || st.Current != "1.2.3" { + t.Fatalf("Load = %+v, %v, %v", st, corrupt, err) + } +} + +func TestSaveRefusesInvalidState(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + for _, st := range []*State{{}, {Current: "1.2"}, {Current: "1.2.3", Previous: "x"}} { + if err := Save(path, st); err == nil { + t.Errorf("Save(%+v) succeeded", st) + } + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatal("invalid Save wrote a file") + } +} + +func stubCheckpoint(t *testing.T, fn func(string) error) { + t.Helper() + old := checkpoint + checkpoint = fn + t.Cleanup(func() { checkpoint = old }) +} + +func TestSaveCheckpointErrors(t *testing.T) { + injected := errors.New("injected") + var seen []string + stubCheckpoint(t, func(name string) error { + seen = append(seen, name) + if name == os.Getenv("TEST_FAIL_AT") { + return injected + } + return nil + }) + path := filepath.Join(t.TempDir(), "state.json") + old := &State{Current: "1.18.2"} + if err := Save(path, old); err != nil { + t.Fatal(err) + } + if len(seen) != 0 { + t.Fatalf("Save without prefix hit checkpoints %v", seen) + } + newer := &State{Current: "1.19.0", Previous: "1.18.2"} + + t.Setenv("TEST_FAIL_AT", "S6.state-temp") + if err := SaveCheckpointed(path, newer, "S6"); !errors.Is(err, injected) { + t.Fatalf("err at state-temp = %v", err) + } + if st, _, _ := Load(path); st.Current != "1.18.2" { + t.Fatalf("state-temp failure changed state to %q", st.Current) + } + if entries, _ := os.ReadDir(filepath.Dir(path)); len(entries) != 1 { + t.Fatalf("temp file leaked: %v", entries) + } + + t.Setenv("TEST_FAIL_AT", "rollback.state-renamed") + if err := SaveCheckpointed(path, newer, "rollback"); !errors.Is(err, injected) { + t.Fatalf("err at state-renamed = %v", err) + } + if st, _, _ := Load(path); st.Current != "1.19.0" { + t.Fatalf("state-renamed: state is %q, want new", st.Current) + } + want := []string{"S6.state-temp", "rollback.state-temp", "rollback.state-renamed"} + if !reflect.DeepEqual(seen, want) { + t.Fatalf("checkpoints %v, want %v", seen, want) + } +} + +func TestSweepTemps(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + for _, n := range []string{".state.json.tmp-123", ".state.json.tmp-abc", "state.json", "other.tmp-1", ".runs.json.tmp-1"} { + if err := os.WriteFile(filepath.Join(dir, n), nil, 0o600); err != nil { + t.Fatal(err) + } + } + SweepTemps(path) + entries, _ := os.ReadDir(dir) + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + if want := []string{".runs.json.tmp-1", "other.tmp-1", "state.json"}; !reflect.DeepEqual(names, want) { + t.Fatalf("after sweep %v, want %v", names, want) + } +} + +// R-F1-2: a newer schema-1 writer's extra keys (top level and nested) +// survive Load -> mutate -> Save by this version. +func TestUnknownFieldsSurviveSave(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + in := `{"schema":1,"current":"1.20.0","previous":"1.19.0","future_top":{"a":[1,2]}, + "probation":{"version":"1.20.0","since":"2026-10-19T00:00:00Z","ok":0,"bad":1,"smoke_ok":true,"future_p":"x"}, + "quarantine":{"1.18.0":{"reason":"inconclusive","at":"2026-10-19T00:00:00Z","until":"2026-10-26T00:00:00Z","future_q":7}}, + "updater":{"version":"1.19.0","consecutive_failures":0,"future_u":true}, + "outbox":[{"execution_id":"e1","update_event":"update_ok","at":"2026-10-19T00:00:00Z","future_o":{"k":1}}], + "switch_deferrals":0,"dedupe":{}}` + if err := os.WriteFile(path, []byte(in), 0o600); err != nil { + t.Fatal(err) + } + st, corrupt, err := Load(path) + if err != nil || corrupt { + t.Fatalf("load: %v corrupt=%v", err, corrupt) + } + st.SwitchDeferrals = 2 + st.Probation.Bad = 2 + if err := Save(path, st); err != nil { + t.Fatal(err) + } + var out map[string]any + data, _ := os.ReadFile(path) + if err := json.Unmarshal(data, &out); err != nil { + t.Fatal(err) + } + if fmt.Sprint(out["future_top"]) != "map[a:[1 2]]" || out["switch_deferrals"] != float64(2) { + t.Fatalf("top level: %s", data) + } + p := out["probation"].(map[string]any) + q := out["quarantine"].(map[string]any)["1.18.0"].(map[string]any) + u := out["updater"].(map[string]any) + o := out["outbox"].([]any)[0].(map[string]any) + if fmt.Sprint(o["future_o"]) != "map[k:1]" || o["execution_id"] != "e1" { + t.Fatalf("outbox entry: %s", data) + } + if p["future_p"] != "x" || p["bad"] != float64(2) || q["future_q"] != float64(7) || q["until"] != "2026-10-26T00:00:00Z" || u["future_u"] != true { + t.Fatalf("nested: %s", data) + } + again, _, _ := Load(path) + if again.Quarantine["1.18.0"].Until.IsZero() { + t.Fatal("until lost on reload") + } +} + +// R-F1-3: a valid state that omits or nulls the maps loads with empty, +// writable maps. +func TestLoadNeverReturnsNilMaps(t *testing.T) { + for _, body := range []string{ + `{"schema":1,"current":"1.2.3"}`, + `{"schema":1,"current":"1.2.3","quarantine":null,"dedupe":null}`, + } { + path := filepath.Join(t.TempDir(), "state.json") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + st, corrupt, err := Load(path) + if err != nil || corrupt || st == nil { + t.Fatalf("%s: %v corrupt=%v", body, err, corrupt) + } + if st.Quarantine == nil || st.Dedupe == nil { + t.Fatalf("%s: nil maps", body) + } + st.Quarantine["1.2.4"] = Quarantine{Reason: "x"} + st.Dedupe["k"] = time.Now() + } +} + +// R-F1-5: a directory at the state.json path is corrupt, not an I/O +// error, on every OS (Windows CreateFile refused directories before). +func TestDirectoryAtStatePathIsCorrupt(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + if err := os.Mkdir(path, 0o755); err != nil { + t.Fatal(err) + } + st, corrupt, err := Load(path) + if err != nil || !corrupt || st != nil { + t.Fatalf("Load(dir) = %v, corrupt=%v, err=%v; want corrupt", st, corrupt, err) + } + if recs := ReadRuns(path); recs != nil { + t.Fatalf("ReadRuns(dir) = %v", recs) + } +} + +// D15: a newer schema is read for its known fields and written back with +// its schema number and everything else intact, never treated as corrupt. +func TestHigherSchemaIsReadAndPreserved(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + in := `{"schema":2,"current":"1.20.0","previous":"1.19.0","new_v2":{"x":1}, + "probation":{"version":"1.20.0","since":"2026-10-19T00:00:00Z","ok":0,"bad":0,"smoke_ok":true,"v2_p":true}}` + if err := os.WriteFile(path, []byte(in), 0o600); err != nil { + t.Fatal(err) + } + st, corrupt, err := Load(path) + if err != nil || corrupt || st == nil || st.Current != "1.20.0" || st.Schema != 2 { + t.Fatalf("Load = %+v corrupt=%v err=%v", st, corrupt, err) + } + st.Probation.Bad = 1 + if err := Save(path, st); err != nil { + t.Fatal(err) + } + var out map[string]any + data, _ := os.ReadFile(path) + _ = json.Unmarshal(data, &out) + if out["schema"] != float64(2) || fmt.Sprint(out["new_v2"]) != "map[x:1]" || out["probation"].(map[string]any)["v2_p"] != true { + t.Fatalf("schema-2 state not preserved: %s", data) + } +} + +// B4(b): the bootstrap's updater-record write bumps generation like every +// other writer, so the updater's "did my write land" check stays sound. +func TestSaveUpdaterRecordBumpsGeneration(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + st := &State{Schema: 1, Current: "1.20.0"} + if err := Save(path, st); err != nil { + t.Fatal(err) + } + before := st.Generation + if err := SaveUpdaterRecord(path, "", func(string, int) (string, int) { return "1.20.0", 1 }); err != nil { + t.Fatal(err) + } + got, _, err := Load(path) + if err != nil || got.Generation != before+1 || got.Updater.ConsecutiveFailures != 1 { + t.Fatalf("after record: gen %d (want %d), %+v, %v", got.Generation, before+1, got.Updater, err) + } + // The updater's next save goes on from there. + if err := Save(path, got); err != nil { + t.Fatal(err) + } + again, _, _ := Load(path) + if again.Generation != before+2 || again.Updater.ConsecutiveFailures != 1 { + t.Fatalf("updater after record: %+v", again) + } +} diff --git a/internal/updatestate/unknownfields.go b/internal/updatestate/unknownfields.go new file mode 100644 index 00000000..22a8ab8f --- /dev/null +++ b/internal/updatestate/unknownfields.go @@ -0,0 +1,124 @@ +package updatestate + +import ( + "encoding/json" + "reflect" + "strings" +) + +// State objects keep JSON keys they don't know in Extra and write them back +// verbatim, so an older updater (the one that runs during every probation) +// never erases fields a newer one added (R-F1-2, frozen read contract). + +func (s *State) UnmarshalJSON(b []byte) error { + type plain State + return decodeKeepingUnknown(b, (*plain)(s), &s.Extra) +} + +func (s State) MarshalJSON() ([]byte, error) { + type plain State + return encodeKeepingUnknown(plain(s), s.Extra) +} + +func (p *Probation) UnmarshalJSON(b []byte) error { + type plain Probation + return decodeKeepingUnknown(b, (*plain)(p), &p.Extra) +} + +func (p Probation) MarshalJSON() ([]byte, error) { + type plain Probation + return encodeKeepingUnknown(plain(p), p.Extra) +} + +func (q *Quarantine) UnmarshalJSON(b []byte) error { + type plain Quarantine + return decodeKeepingUnknown(b, (*plain)(q), &q.Extra) +} + +func (q Quarantine) MarshalJSON() ([]byte, error) { + type plain Quarantine + return encodeKeepingUnknown(plain(q), q.Extra) +} + +func (u *Updater) UnmarshalJSON(b []byte) error { + type plain Updater + return decodeKeepingUnknown(b, (*plain)(u), &u.Extra) +} + +func (u Updater) MarshalJSON() ([]byte, error) { + type plain Updater + return encodeKeepingUnknown(plain(u), u.Extra) +} + +func (o *OutboxEvent) UnmarshalJSON(b []byte) error { + type plain OutboxEvent + return decodeKeepingUnknown(b, (*plain)(o), &o.Extra) +} + +func (o OutboxEvent) MarshalJSON() ([]byte, error) { + type plain OutboxEvent + return encodeKeepingUnknown(plain(o), o.Extra) +} + +// decodeKeepingUnknown decodes b into dst (a struct pointer) and stores the +// keys no field claims in *extra. Matching is case-insensitive, as in +// encoding/json. +func decodeKeepingUnknown(b []byte, dst any, extra *map[string]json.RawMessage) error { + if err := json.Unmarshal(b, dst); err != nil { + return err + } + *extra = nil + var raw map[string]json.RawMessage + if json.Unmarshal(b, &raw) != nil || raw == nil { + return nil + } + known := jsonFieldNames(reflect.TypeOf(dst).Elem()) + for k := range raw { + for _, f := range known { + if strings.EqualFold(k, f) { + delete(raw, k) + break + } + } + } + if len(raw) > 0 { + *extra = raw + } + return nil +} + +// encodeKeepingUnknown marshals v and adds the extra keys it doesn't emit. +func encodeKeepingUnknown(v any, extra map[string]json.RawMessage) ([]byte, error) { + b, err := json.Marshal(v) + if err != nil || len(extra) == 0 { + return b, err + } + var m map[string]json.RawMessage + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + for k, raw := range extra { + if _, ok := m[k]; !ok { + m[k] = raw + } + } + return json.Marshal(m) +} + +// jsonFieldNames lists the JSON keys of a struct type's fields. +func jsonFieldNames(t reflect.Type) []string { + var out []string + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + tag := f.Tag.Get("json") + if tag == "-" || !f.IsExported() { + continue + } + name, _, _ := strings.Cut(tag, ",") + if name == "" { + name = f.Name + } + out = append(out, name) + } + return out +} diff --git a/internal/updatestate/version.go b/internal/updatestate/version.go new file mode 100644 index 00000000..54224b80 --- /dev/null +++ b/internal/updatestate/version.go @@ -0,0 +1,52 @@ +package updatestate + +import ( + "fmt" + "strconv" + "strings" +) + +// Version is a strict x.y.z release version. Prereleases and build metadata +// are refused (spec §6). +type Version struct{ Major, Minor, Patch uint32 } + +// ParseVersion accepts only x.y.z with decimal components, no sign, no "v" +// prefix and no leading zeros. +func ParseVersion(s string) (Version, error) { + parts := strings.Split(s, ".") + if len(parts) != 3 { + return Version{}, fmt.Errorf("version %q: want x.y.z", s) + } + var n [3]uint32 + for i, p := range parts { + if p == "" || len(p) > 10 || (len(p) > 1 && p[0] == '0') || strings.TrimLeft(p, "0123456789") != "" { + return Version{}, fmt.Errorf("version %q: invalid component %q", s, p) + } + u, err := strconv.ParseUint(p, 10, 32) + if err != nil { + return Version{}, fmt.Errorf("version %q: %w", s, err) + } + n[i] = uint32(u) + } + return Version{n[0], n[1], n[2]}, nil +} + +func (v Version) String() string { return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) } + +// Compare returns -1, 0 or +1 as a is older than, equal to or newer than b. +func Compare(a, b Version) int { + for _, d := range [3][2]uint32{{a.Major, b.Major}, {a.Minor, b.Minor}, {a.Patch, b.Patch}} { + if d[0] != d[1] { + if d[0] < d[1] { + return -1 + } + return 1 + } + } + return 0 +} + +func validVersion(s string) bool { + _, err := ParseVersion(s) + return err == nil +} diff --git a/internal/updatestate/version_test.go b/internal/updatestate/version_test.go new file mode 100644 index 00000000..9d3e804f --- /dev/null +++ b/internal/updatestate/version_test.go @@ -0,0 +1,45 @@ +package updatestate + +import "testing" + +func TestParseVersion(t *testing.T) { + good := map[string]Version{ + "0.0.0": {}, + "1.19.0": {1, 19, 0}, + "10.200.3000": {10, 200, 3000}, + "4294967295.0.1": {4294967295, 0, 1}, + "1.0.4294967295": {1, 0, 4294967295}, + } + for s, want := range good { + got, err := ParseVersion(s) + if err != nil || got != want || got.String() != s { + t.Errorf("ParseVersion(%q) = %v, %v", s, got, err) + } + } + for _, s := range []string{"", "1", "1.2", "1.2.3.4", "v1.2.3", "1.2.3-rc.1", "1.2.3+b", "01.2.3", + "1.02.3", "1.2.03", "-1.2.3", "+1.2.3", "1..3", "1.2.", " 1.2.3", "1.2.3 ", "1.2.x", + "4294967296.0.0", "99999999999.0.0", "1.2.3\n", "1.2.3", "1/2.3"} { + if v, err := ParseVersion(s); err == nil { + t.Errorf("ParseVersion(%q) = %v, want error", s, v) + } + } +} + +func TestCompare(t *testing.T) { + order := []string{"0.0.0", "0.0.1", "0.1.0", "0.10.0", "1.0.0", "1.9.9", "1.10.0", "1.18.2", "1.19.0", "2.0.0"} + for i, a := range order { + for j, b := range order { + va, _ := ParseVersion(a) + vb, _ := ParseVersion(b) + want := 0 + if i < j { + want = -1 + } else if i > j { + want = 1 + } + if got := Compare(va, vb); got != want { + t.Errorf("Compare(%s, %s) = %d, want %d", a, b, got, want) + } + } + } +} diff --git a/internal/winproc/job_windows.go b/internal/winproc/job_windows.go new file mode 100644 index 00000000..a2a24fbe --- /dev/null +++ b/internal/winproc/job_windows.go @@ -0,0 +1,43 @@ +//go:build windows + +package winproc + +import ( + "fmt" + "os/exec" + "syscall" + + "golang.org/x/sys/windows" +) + +var procNtResumeProcess = windows.NewLazySystemDLL("ntdll.dll").NewProc("NtResumeProcess") + +// StartInJob starts cmd suspended, assigns it to job, then resumes it, so +// nothing the child spawns can escape the job. If the child can't be put in +// the job it is terminated and reaped and an error returned: no child ever +// runs uncontained. +func StartInJob(cmd *exec.Cmd, job windows.Handle) error { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.CreationFlags |= windows.CREATE_SUSPENDED + if err := cmd.Start(); err != nil { + return err + } + h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE|windows.PROCESS_SUSPEND_RESUME, false, uint32(cmd.Process.Pid)) // #nosec G115 -- a live pid + if err == nil { + err = windows.AssignProcessToJobObject(job, h) + if err == nil { + if status, _, _ := procNtResumeProcess.Call(uintptr(h)); status != 0 { + err = fmt.Errorf("resume: NTSTATUS 0x%x", status) + } + } + _ = windows.CloseHandle(h) + } + if err != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + return fmt.Errorf("job setup: %w", err) + } + return nil +} diff --git a/internal/winproc/main_test.go b/internal/winproc/main_test.go new file mode 100644 index 00000000..950a9a38 --- /dev/null +++ b/internal/winproc/main_test.go @@ -0,0 +1,10 @@ +package winproc + +import ( + "os" + "testing" + + "github.com/step-security/dev-machine-guard/internal/proctest" +) + +func TestMain(m *testing.M) { os.Exit(proctest.Main(m)) } diff --git a/internal/winproc/release.go b/internal/winproc/release.go new file mode 100644 index 00000000..807d39db --- /dev/null +++ b/internal/winproc/release.go @@ -0,0 +1,24 @@ +package winproc + +import "time" + +// WaitReleased polls until no process holds any of paths (a file that can +// be opened for exclusive write access, or is missing, is released) or +// timeout passes. It returns the paths still held. A process that has +// exited can keep its image file held while the kernel tears it down, so +// "the process exited" alone is not enough before replacing its files. +func WaitReleased(paths []string, timeout, poll time.Duration) []string { + deadline := time.Now().Add(timeout) + for { + var held []string + for _, p := range paths { + if fileHeld(p) { + held = append(held, p) + } + } + if len(held) == 0 || !time.Now().Before(deadline) { + return held + } + time.Sleep(poll) + } +} diff --git a/internal/winproc/release_other.go b/internal/winproc/release_other.go new file mode 100644 index 00000000..ec36e395 --- /dev/null +++ b/internal/winproc/release_other.go @@ -0,0 +1,8 @@ +//go:build !windows + +package winproc + +// StopUnder is Windows-only. +func StopUnder(string) ([]string, error) { return nil, nil } + +func fileHeld(string) bool { return false } diff --git a/internal/winproc/release_windows.go b/internal/winproc/release_windows.go new file mode 100644 index 00000000..72acfb7b --- /dev/null +++ b/internal/winproc/release_windows.go @@ -0,0 +1,85 @@ +//go:build windows + +package winproc + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +// fileHeld reports whether something holds path: it can't be opened for +// exclusive write access (a running or still-mapped image, or an open +// handle). A missing file is not held. +func fileHeld(path string) bool { + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return false + } + h, err := windows.CreateFile(p, windows.GENERIC_READ|windows.GENERIC_WRITE, 0, nil, + windows.OPEN_EXISTING, windows.FILE_FLAG_OPEN_REPARSE_POINT, 0) + if err != nil { + return !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) + } + _ = windows.CloseHandle(h) + return false +} + +// StopUnder terminates every process (other than this one) whose image +// lives under root and waits up to 5 s for each to exit. It returns the +// images it stopped. Callers then WaitReleased the files they need. +func StopUnder(root string) ([]string, error) { + prefix := strings.ToLower(filepath.Clean(root)) + `\` + snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return nil, fmt.Errorf("process snapshot: %w", err) + } + defer func() { _ = windows.CloseHandle(snap) }() + self := uint32(os.Getpid()) // #nosec G115 -- a pid fits in uint32 + var pe windows.ProcessEntry32 + pe.Size = uint32(unsafe.Sizeof(pe)) + var stopped []string + var errs []error + for err = windows.Process32First(snap, &pe); err == nil; err = windows.Process32Next(snap, &pe) { + if pe.ProcessID == self || pe.ProcessID == 0 { + continue + } + h, oerr := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_TERMINATE|windows.SYNCHRONIZE, false, pe.ProcessID) + if oerr != nil { + continue + } + buf := make([]uint16, windows.MAX_LONG_PATH) + n := uint32(len(buf)) + if qerr := windows.QueryFullProcessImageName(h, 0, &buf[0], &n); qerr == nil { + img := windows.UTF16ToString(buf[:n]) + if strings.HasPrefix(strings.ToLower(img), prefix) { + if serr := StopProcess(h); serr != nil { + errs = append(errs, fmt.Errorf("terminating %s (pid %d): %w", img, pe.ProcessID, serr)) + } else { + stopped = append(stopped, img) + } + } + } + _ = windows.CloseHandle(h) + } + return stopped, errors.Join(errs...) +} + +// StopProcess terminates the process and waits up to 5 s for it to exit. +// A process already exiting (e.g. killed by its kill-on-close job) rejects +// TerminateProcess with ACCESS_DENIED; what counts is that it is gone. +func StopProcess(h windows.Handle) error { + terr := windows.TerminateProcess(h, 1) + if ev, _ := windows.WaitForSingleObject(h, 5000); ev == windows.WAIT_OBJECT_0 { + return nil + } + if terr != nil { + return terr + } + return errors.New("still running 5 s after TerminateProcess") +} diff --git a/internal/winproc/release_windows_test.go b/internal/winproc/release_windows_test.go new file mode 100644 index 00000000..08ae3efa --- /dev/null +++ b/internal/winproc/release_windows_test.go @@ -0,0 +1,76 @@ +//go:build windows + +package winproc + +import ( + "io" + "os" + "path/filepath" + "testing" + "time" + + "golang.org/x/sys/windows" + + "github.com/step-security/dev-machine-guard/internal/proctest" +) + +// D29: a file is "released" only once nothing holds it: an open handle or +// a running image keeps it held, and StopUnder + WaitReleased clears both. +func TestStopUnderThenWaitReleased(t *testing.T) { + root := t.TempDir() + exe := filepath.Join(root, "seed", "1.0.0", "held.exe") + if err := os.MkdirAll(filepath.Dir(exe), 0o755); err != nil { + t.Fatal(err) + } + self, _ := os.Executable() + in, _ := os.Open(self) + out, _ := os.Create(exe) + _, _ = io.Copy(out, in) + _ = in.Close() + _ = out.Close() + + // A running image holds its file. + cmd := proctest.Command(t, exe, "-test.run=^TestHelperSleep$") + cmd.Env = append(cmd.Env, "WINPROC_HELPER_SLEEP=1") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer func() { _ = cmd.Process.Kill() }() + time.Sleep(500 * time.Millisecond) + if held := WaitReleased([]string{exe}, 300*time.Millisecond, 50*time.Millisecond); len(held) != 1 { + t.Fatalf("running image not reported held: %v", held) + } + stopped, err := StopUnder(root) + if err != nil || len(stopped) != 1 { + t.Fatalf("StopUnder = %v, %v", stopped, err) + } + if held := WaitReleased([]string{exe}, 30*time.Second, 10*time.Millisecond); len(held) != 0 { + t.Fatalf("still held after StopUnder: %v", held) + } + + // An open exclusive handle holds it too. + p, _ := windows.UTF16PtrFromString(exe) + h, err := windows.CreateFile(p, windows.GENERIC_READ, 0, nil, windows.OPEN_EXISTING, 0, 0) + if err != nil { + t.Fatal(err) + } + if held := WaitReleased([]string{exe}, 200*time.Millisecond, 50*time.Millisecond); len(held) != 1 { + t.Fatal("open handle not reported held") + } + _ = windows.CloseHandle(h) + if held := WaitReleased([]string{exe}, 5*time.Second, 10*time.Millisecond); len(held) != 0 { + t.Fatal("not released after the handle closed") + } + // A missing file is not held. + if held := WaitReleased([]string{filepath.Join(root, "gone.exe")}, 100*time.Millisecond, 10*time.Millisecond); len(held) != 0 { + t.Fatal("missing file reported held") + } +} + +func TestHelperSleep(t *testing.T) { + if os.Getenv("WINPROC_HELPER_SLEEP") != "1" { + t.Skip("helper") + } + proctest.Guard() + time.Sleep(time.Minute) +} diff --git a/packaging/windows/Product.wxs b/packaging/windows/Product.wxs index a7fb8750..653d9b72 100644 --- a/packaging/windows/Product.wxs +++ b/packaging/windows/Product.wxs @@ -7,9 +7,11 @@ -d Version= e.g. 1.8.2 -d BinaryPath= path to stepsecurity-dev-machine-guard.exe + (installed under seed\\) -d LauncherPath= path to stepsecurity-dev-machine-guard-task.exe - (GUI-subsystem launcher Task Scheduler invokes - so the agent doesn't flash a console window) + (GUI-subsystem launcher; installed twice: as + the bootstrap in the install root and under + seed\\) -d LinuxBinaryPath= path to the LINUX build of the agent, installed as dmg-linux so a Windows host can scan inside its WSL distributions. Must match the MSI's @@ -17,6 +19,21 @@ WSL2 kernel. Windows never executes it. -d Arch= selects InstallScope + UpgradeCode + Self-update layout (see internal/launcher/bootstrap.go): + + C:\Program Files\StepSecurity\ + stepsecurity-dev-machine-guard-bootstrap.exe MSI (stable entrypoint) + seed\\{stepsecurity-dev-machine-guard.exe, + stepsecurity-dev-machine-guard-task.exe, + dmg-linux} MSI (read-only fallback) + versions\, state\ updater-owned, created by + the adopt-seed CA + + The scheduled tasks run the bootstrap, which launches the version that + state.json names. The MSI owns the bootstrap, the seed, AgentVersion and + the three tasks; it never touches versions\ or state\ except on a real + uninstall. + Designed for SCCM/Intune deployment under environments where PowerShell egress is blocked by EDR. Custom actions invoke the .exe directly via msiexec — powershell.exe is never spawned anywhere in install/upgrade/ @@ -68,53 +85,64 @@