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..4886fd70 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) @@ -115,6 +116,23 @@ 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" + "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 +141,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 +157,305 @@ 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" - # 3. ACL hardening on config.json — Users should be Read only, - # Administrators + SYSTEM should be Full Control, inheritance disabled. + # 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" + + # 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 - if ($checks -contains $false) { - Write-Host "::error::One or more install verifications failed" - exit 1 + # 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: 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 + $code = Msi @("/i", "dist\dmg-smoke-next.msi", "/qn") "dist\dmg-upgrade.log" + Check ($code -eq 0) "upgrade msiexec exit 0 ($code)" + 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-uninstall.log if-no-files-found: ignore diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 89016ff3..bea2513e 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/ + - 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,19 @@ 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/ - 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/ + - 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: Authenticode against test-signed binaries (verbose, shows skips) + run: go test -count=1 -v -run 'TestVerify' ./internal/authenticode/ + - name: Test launcher bootstrap mode and self-update tasks + run: | + go test -race -count=1 ./internal/launcher/ + go test -race -count=1 -tags selfupdate_faults ./internal/launcher/ + 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/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..e68fca0f 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, diff --git a/cmd/stepsecurity-dev-machine-guard/selfupdate.go b/cmd/stepsecurity-dev-machine-guard/selfupdate.go new file mode 100644 index 00000000..d4d4dc98 --- /dev/null +++ b/cmd/stepsecurity-dev-machine-guard/selfupdate.go @@ -0,0 +1,223 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "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" +) + +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 returns the configured device id, else the hardware +// identity. Elevated (SYSTEM) runs probe the hardware and rewrite the shared +// device-id file rather than trusting it (M-9). +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())}) +} + +// 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(), + DeviceID: resolveDeviceID(context.Background()), + } + 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 + } + layout := updatestate.MSILayout() + 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 + } + selfExe, err := os.Executable() + if err != nil { + logf("self-update: own path: %v", err) + return selfupdate.ExitFailed + } + httpClient := selfupdate.NewHTTPClient() + deviceID := sync.OnceValue(func() string { return resolveDeviceID(context.Background()) }) + 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) }, + 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() + } +} + +// reportBootstrapFallback sends update_pointer_invalid when the bootstrap +// ran a fallback version (STEPSEC_BOOTSTRAP_FALLBACK=): from = the +// version state.json names, to = this version. Best-effort; the bootstrap +// sets it on every fallback run until the updater repairs the pointer. +func reportBootstrapFallback() { + reason := os.Getenv(telemetry.EnvBootstrapFallback) + if reason == "" || runtime.GOOS != model.PlatformWindows { + return + } + named := "" + if st, _, err := updatestate.Load(updatestate.MSILayout().StateFile()); err == nil && st != nil { + named = st.Current + } + 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: named, To: buildinfo.Version, Error: reason, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "[warn] update_pointer_invalid not sent: %v\n", err) + } +} 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..0beaf6f9 --- /dev/null +++ b/cmd/stepsecurity-dev-machine-guard/selfupdate_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +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) + } +} diff --git a/internal/authenticode/authenticode.go b/internal/authenticode/authenticode.go new file mode 100644 index 00000000..23f4fab6 --- /dev/null +++ b/internal/authenticode/authenticode.go @@ -0,0 +1,86 @@ +// 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) comes from WinVerifyTrust on Windows. The signer +// subject and the timestamp are read from the PE's PKCS#7 blob in Go, which +// is portable and testable everywhere. +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") + // 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 +} + +// Verify checks path with WinVerifyTrust (revocation off) and requires a +// timestamped signature. +func Verify(path string) (*Signature, error) { + if err := winVerifyTrust(path); err != nil { + return nil, err + } + sig, err := Inspect(path) + if err != nil { + return nil, err + } + if !sig.Timestamped { + return nil, fmt.Errorf("%w: %s", ErrNotTimestamped, path) + } + return sig, 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, Inspect, testPinnedSubject()) +} + +func samePublisher(candidate, self string, verify, inspect func(string) (*Signature, error), pin string) error { + got, err := verify(candidate) + if err != nil { + return err + } + want := pin + if want == "" { + // Our own image only supplies the expected subject; it is already + // running, so re-checking its trust adds nothing. + own, err := inspect(self) + switch { + case errors.Is(err, ErrUnsigned): + return ErrNoPublisher + case err != nil: + return fmt.Errorf("authenticode: read own signature: %w", err) + } + want = own.Subject + } + if want == "" || got.Subject != want { + return fmt.Errorf("%w: %q, want %q", ErrPublisherMismatch, got.Subject, want) + } + return nil +} diff --git a/internal/authenticode/pkcs7.go b/internal/authenticode/pkcs7.go new file mode 100644 index 00000000..52d70cc5 --- /dev/null +++ b/internal/authenticode/pkcs7.go @@ -0,0 +1,176 @@ +package authenticode + +import ( + "bytes" + "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 leaf signer subject and whether it carries a timestamp. +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 + } + leaf := findSigner(certs, si.SID) + if leaf == nil { + return nil, fmt.Errorf("%w: signer certificate not embedded", errMalformedSignedData) + } + 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 +} + +func findSigner(certs []*x509.Certificate, sid asn1.RawValue) *x509.Certificate { + if sid.Class == asn1.ClassContextSpecific && sid.Tag == 0 { + for _, c := range certs { + if bytes.Equal(c.SubjectKeyId, sid.Bytes) { + return c + } + } + return nil + } + 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 { + return c + } + } + return nil +} diff --git a/internal/authenticode/pkcs7_test.go b/internal/authenticode/pkcs7_test.go new file mode 100644 index 00000000..75dfcf4c --- /dev/null +++ b/internal/authenticode/pkcs7_test.go @@ -0,0 +1,207 @@ +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 + } + } + a := "CN=StepSecurity Inc" + if err := samePublisher("c", "s", sig(a, nil), sig(a, nil), ""); err != nil { + t.Fatalf("same subject: %v", err) + } + if err := samePublisher("c", "s", sig("CN=Evil", nil), sig(a, nil), ""); !errors.Is(err, ErrPublisherMismatch) { + t.Fatalf("other subject: %v", err) + } + if err := samePublisher("c", "s", sig(a, nil), sig("", ErrUnsigned), ""); !errors.Is(err, ErrNoPublisher) { + t.Fatalf("unsigned self: %v", err) + } + if err := samePublisher("c", "s", sig(a, nil), sig("", ErrUnsigned), a); err != nil { + t.Fatalf("unsigned self with pin: %v", err) + } + if err := samePublisher("c", "s", sig(a, nil), sig(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), sig(a, nil), ""); !errors.Is(err, ErrNotTimestamped) { + t.Fatalf("candidate error not propagated: %v", 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..fa4fa860 --- /dev/null +++ b/internal/authenticode/verify_other.go @@ -0,0 +1,5 @@ +//go:build !windows + +package authenticode + +func winVerifyTrust(string) error { return 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..3a711e73 --- /dev/null +++ b/internal/authenticode/verify_windows.go @@ -0,0 +1,47 @@ +//go:build windows + +package authenticode + +import ( + "errors" + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + trustENoSignature = 0x800B0100 // TRUST_E_NOSIGNATURE + trustESubjectFormUnk = 0x800B0003 // TRUST_E_SUBJECT_FORM_UNKNOWN +) + +// winVerifyTrust runs WINTRUST_ACTION_GENERIC_VERIFY_V2 on path with no UI +// and no revocation checks (offline devices must not fail). Chain building +// may still fetch missing roots through Windows root auto-update. +func winVerifyTrust(path string) error { + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return err + } + file := &windows.WinTrustFileInfo{FilePath: p} + file.Size = uint32(unsafe.Sizeof(*file)) + data := &windows.WinTrustData{ + UIChoice: windows.WTD_UI_NONE, + RevocationChecks: windows.WTD_REVOKE_NONE, + UnionChoice: windows.WTD_CHOICE_FILE, + StateAction: windows.WTD_STATEACTION_VERIFY, + FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(file), + } + data.Size = uint32(unsafe.Sizeof(*data)) + verr := windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, data) + data.StateAction = windows.WTD_STATEACTION_CLOSE + _ = windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, data) + if verr == nil { + return nil + } + var errno windows.Errno + if errors.As(verr, &errno) && (uint32(errno) == trustENoSignature || uint32(errno) == trustESubjectFormUnk) { + return fmt.Errorf("%w: %s", ErrUnsigned, path) + } + return fmt.Errorf("%w: %s: %w", ErrUntrusted, path, verr) +} diff --git a/internal/authenticode/verify_windows_test.go b/internal/authenticode/verify_windows_test.go new file mode 100644 index 00000000..485ce472 --- /dev/null +++ b/internal/authenticode/verify_windows_test.go @@ -0,0 +1,193 @@ +//go:build windows + +package authenticode + +import ( + "errors" + "fmt" + "io" + "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 }, Inspect, ""); !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, Inspect, a.subject); err != nil { + t.Fatalf("pinned subject: %v", err) + } +} 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..7200ee40 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -73,6 +73,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 +106,10 @@ 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"` } // userConfigDir returns ~/.stepsecurity — the per-user config location. @@ -272,6 +288,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 +821,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 +834,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 @@ -847,6 +877,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_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/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..7e2fc971 --- /dev/null +++ b/internal/faultinject/faultinject.go @@ -0,0 +1,18 @@ +// 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), exit (os.Exit(N), e.g. exit2 mimics a Go panic). 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/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..3eb60c82 --- /dev/null +++ b/internal/faultinject/point_on.go @@ -0,0 +1,139 @@ +//go:build selfupdate_faults + +package faultinject + +import ( + "fmt" + "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 +) + +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": + return true + } + n, ok := strings.CutPrefix(a, "exit") + code, err := strconv.Atoi(n) + return ok && err == nil && code >= 0 +} + +// Point acts on the first fault configured for name, if any. A malformed +// DMG_FAULT or Baked spec panics: a typo must never let a fault test pass +// without injecting anything. +func Point(name string) error { + trace(name) + rules, err := parse(Baked + "," + os.Getenv(envFault)) + if err != nil { + panic(fmt.Sprintf("faultinject: invalid %s/Baked fault spec: %v", envFault, err)) + } + 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 "hang": + for { + time.Sleep(time.Hour) + } + default: + 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..47221d6f --- /dev/null +++ b/internal/faultinject/point_on_test.go @@ -0,0 +1,203 @@ +//go:build selfupdate_faults + +package faultinject + +import ( + "bufio" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +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: a malformed spec must fail loudly at first use, never silently +// leave a fault un-fired. +func TestPointPanicsOnBadSpecs(t *testing.T) { + for _, spec := range []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", + } { + t.Run(spec, func(t *testing.T) { + t.Setenv("DMG_FAULT", spec) + defer func() { + r := recover() + if r == nil || !strings.Contains(fmt.Sprint(r), "faultinject") { + t.Fatalf("Point did not panic clearly: %v", r) + } + }() + _ = Point("S0.locked") + }) + } + old := Baked + t.Cleanup(func() { Baked = old }) + Baked = "S0.locked:typo" + t.Setenv("DMG_FAULT", "") + defer func() { + if recover() == nil { + t.Fatal("malformed Baked did not panic") + } + }() + _ = Point("S0.locked") +} + +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") + } + 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() + marker := filepath.Join(t.TempDir(), "marker") + cmd := exec.Command(os.Args[0], "-test.run=^TestHelperProcess$") + cmd.Env = append(os.Environ(), "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 +} diff --git a/internal/launcher/bootstrap.go b/internal/launcher/bootstrap.go new file mode 100644 index 00000000..e4e68fc1 --- /dev/null +++ b/internal/launcher/bootstrap.go @@ -0,0 +1,718 @@ +package launcher + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "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 + + 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 default data root. +func NewBootstrap(exePath string, isSystem func() bool) *Bootstrap { + return &Bootstrap{ + Layout: updatestate.Layout{InstallRoot: filepath.Dir(exePath), DataRoot: updatestate.DefaultDataRoot}, + Spawn: SpawnHidden, + IsSystem: isSystem, + Now: time.Now, + Sleep: time.Sleep, + Getenv: os.Getenv, + Environ: os.Environ, + Out: os.Stdout, + NewRunID: uuid.NewString, + SetRunningVersion: SetRunningVersion, + 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:]) + 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 := res.ExitCode + if res.Killed { + code = ExitKilled + } + b.finalize(runLock, runFile, runID, code, res.Killed) + return code + } + return ExitCannotStart +} + +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] + } + } + 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() + + if err := b.copySeed(v, seedDir); err != nil { + b.logf("adopt-seed: copying seed %s: %v", v, err) + } + 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 + } + b.adoptState(v) + 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 +} + +// copySeed copies the seed into versions\ through a staging dir, 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. +func (b *Bootstrap) copySeed(v updatestate.Version, seedDir string) error { + dst := b.Layout.VersionDir(v) + if _, err := os.Lstat(dst); err == nil { + if updatestate.VerifyVersionDir(dst, v) == nil { + return nil + } + trash := filepath.Join(b.Layout.VersionsDir(), updatestate.TrashPrefix+v.String()+"-"+strconv.FormatInt(b.Now().UnixNano(), 36)) + if err := os.Rename(dst, trash); err != nil { + return fmt.Errorf("moving unverifiable %s aside: %w", dst, err) + } + b.logf("adopt-seed: moved unverifiable %s to %s", dst, trash) + _ = os.RemoveAll(trash) + } + staging, err := b.Layout.StagingDir(v) + if err != nil { + return err + } + done := false + defer func() { + if !done { + _ = os.RemoveAll(staging) + } + }() + m := updatestate.Manifest{Version: v.String(), Files: map[string]string{}} + for _, name := range updatestate.VersionFiles { + dstFile := filepath.Join(staging, name) + if err := copyFile(filepath.Join(seedDir, name), dstFile); err != nil { + return err + } + sum, err := updatestate.FileSHA256(dstFile) + if err != nil { + return err + } + m.Files[name] = sum + } + if err := updatestate.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 + b.logf("adopt-seed: copied seed %s into %s", v, dst) + return nil +} + +func copyFile(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 +} + +// 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) { + 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 + case corrupt: + b.logf("adopt-seed: state pointers unreadable; left unchanged for the updater") + return + 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 + } + b.logf("adopt-seed: no state; current = seed %s", s) + b.recordRunningVersion(s) + return + case p.Schema > updatestate.SchemaVersion: + b.logf("adopt-seed: state schema %d is newer than this bootstrap; left unchanged", p.Schema) + return + } + 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 + } + old := st.Current + cur, _ := updatestate.ParseVersion(old) + switch { + case !updatestate.VersionDirUsable(b.Layout.VersionDir(cur)): + st.Current = s + if st.Probation != nil && st.Probation.Version == old { + st.Probation = nil + } + if st.Previous == s { + st.Previous = "" + } + b.logf("adopt-seed: current %s unusable; current = seed %s", old, s) + case updatestate.Compare(seed, cur) <= 0: + return + default: + // Only an unexpired quarantine of the seed's own version blocks it + // (R-B-6); a D5 "until" in the past has ended. + if q, ok := st.Quarantine[s]; ok && (q.Until.IsZero() || b.Now().Before(q.Until)) { + b.logf("adopt-seed: seed %s is quarantined; current %s kept", s, old) + return + } + st.Previous, st.Current, st.Probation = old, s, nil + b.logf("adopt-seed: seed %s newer than current %s; current = seed", s, old) + } + if err := updatestate.SaveCheckpointed(path, st, "adopt"); err != nil { + b.logf("adopt-seed: writing state: %v", err) + return + } + b.recordRunningVersion(s) +} + +// 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..e1e8827e --- /dev/null +++ b/internal/launcher/bootstrap_test.go @@ -0,0 +1,903 @@ +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) + } + } +} 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/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..eba1c7e9 --- /dev/null +++ b/internal/launcher/runs_concurrency_test.go @@ -0,0 +1,104 @@ +package launcher + +import ( + "bytes" + "fmt" + "math/rand/v2" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/updatestate" +) + +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") + } + 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 := exec.Command(os.Args[0], "-test.run=^TestHelperRunLoop$", "-test.count=1") + cmd.Env = append(os.Environ(), 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..b0e1d35b --- /dev/null +++ b/internal/launcher/spawn.go @@ -0,0 +1,52 @@ +package launcher + +import ( + "errors" + "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 + +// 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() }() + var timeout <-chan time.Time + if deadline > 0 { + t := time.NewTimer(deadline) + defer t.Stop() + timeout = t.C + } + 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..c047a562 --- /dev/null +++ b/internal/launcher/spawn_windows.go @@ -0,0 +1,86 @@ +//go:build windows + +package launcher + +import ( + "os/exec" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +// 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, 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} + if err := cmd.Start(); err != nil { + return SpawnResult{Err: err} + } + job, jerr := NewKillOnCloseJob() + if jerr == nil { + if aerr := AssignToJob(job, cmd.Process.Pid); aerr != nil { + _ = windows.CloseHandle(job) + job = 0 + } + } else { + job = 0 + } + if job != 0 { + defer func() { _ = windows.CloseHandle(job) }() + } + if onStart != nil { + onStart() + } + return waitWithDeadline(cmd, deadline, func() { + if job != 0 { + _ = windows.TerminateJobObject(job, uint32(ExitKilled)) + return + } + _ = cmd.Process.Kill() + }) +} + +// 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..527303a8 --- /dev/null +++ b/internal/launcher/spawn_windows_test.go @@ -0,0 +1,65 @@ +//go:build windows + +package launcher + +import ( + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +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 := 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) + } +} 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..f51b67e5 --- /dev/null +++ b/internal/schtasks/selfupdate.go @@ -0,0 +1,261 @@ +package schtasks + +import ( + "context" + "encoding/xml" + "errors" + "fmt" + "html" + "os" + "path/filepath" + "strings" + "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" + "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) { + dir := filepath.Dir(agentPath) + 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 exec.FileExists(filepath.Join(root, launcher.BootstrapBinary)) { + return root, true + } + } + return "", false +} + +// 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..40695c70 --- /dev/null +++ b/internal/schtasks/selfupdate_windows.go @@ -0,0 +1,127 @@ +//go:build windows + +package schtasks + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "time" + "unsafe" + + "github.com/step-security/dev-machine-guard/internal/progress" + "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 +} + +func terminateUnder(root string, log *progress.Logger) error { + prefix := strings.ToLower(filepath.Clean(root)) + `\` + snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return 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 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 { + log.Progress("Stopped running DMG process %s (pid %d)", img, pe.ProcessID) + } + } + } + _ = windows.CloseHandle(h) + } + return errors.Join(errs...) +} + +// stopProcess terminates the process and waits up to 5 s for it to exit. +// A process that is already exiting (for example killed by its kill-on-close +// job when its parent bootstrap died) rejects TerminateProcess with +// ACCESS_DENIED; what counts is whether it is gone, so that is not an error. +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") +} + +// 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..934e3ae6 --- /dev/null +++ b/internal/schtasks/selfupdate_windows_test.go @@ -0,0 +1,162 @@ +//go:build windows + +package schtasks + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +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") + } + time.Sleep(time.Minute) +} + +func startSleeper(t *testing.T, exe string) *exec.Cmd { + t.Helper() + cmd := exec.Command(exe, "-test.run=^TestHelperSleep$") + cmd.Env = append(os.Environ(), 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/killmatrix_test.go b/internal/selfupdate/killmatrix_test.go new file mode 100644 index 00000000..c69d8448 --- /dev/null +++ b/internal/selfupdate/killmatrix_test.go @@ -0,0 +1,530 @@ +//go:build selfupdate_faults && selfupdate_testkeys + +package selfupdate + +// U0 (harness-design.md §2): 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" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/config" + "github.com/step-security/dev-machine-guard/internal/lock" + "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 ( + 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" { + e.lock = fmt.Sprintf("DMGTestH1-%d-%d", os.Getpid(), time.Now().UnixNano()) + } 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 trace == "" { + trace = filepath.Join(e.t.TempDir(), "trace") + } + cmd := exec.Command(os.Args[0], "-test.run=^$") + cmd.Env = append(os.Environ(), + 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_FAULT="+fault, "DMG_FAULT_TRACE="+trace, + ) + var out strings.Builder + cmd.Stdout, cmd.Stderr = &out, &out + 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\n%s", fault, err, out.String())) + 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) + } + } +} + +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 + } + 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 +} + +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, + }, + { + // 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{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"} + 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 + check := func() *updatestate.State { + st := e.state() + e.assertBootstrapRead() + 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() + } + trace := filepath.Join(t.TempDir(), "fault-trace") + code := e.tick(fault, trace) + 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, ":hang") && code == 0: + t.Fatalf("hang: process was not killed (exit %d)", code) + } + check() + 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) + return + } + } + e.dumpLogs() + t.Fatalf("U2: not converged within %d clean ticks after %s: %+v", cleanTicks, fault, e.state()) +} + +// 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}, + {"unknown schema", func(e *h1Env) { + must(t, os.WriteFile(e.L.StateFile(), []byte(`{"schema":2,"current":"1.19.0"}`), 0o644)) + }, EventStateCorrupt}, + {"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()) + }) + } +} diff --git a/internal/selfupdate/knobs.go b/internal/selfupdate/knobs.go new file mode 100644 index 00000000..870f29c5 --- /dev/null +++ b/internal/selfupdate/knobs.go @@ -0,0 +1,35 @@ +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) +) + +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, +} + +// 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..4da9ccad --- /dev/null +++ b/internal/selfupdate/main_test.go @@ -0,0 +1,75 @@ +package selfupdate + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// 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" { + os.Exit(helperSelfCheck()) + } + if f, ok := helperRoles[os.Getenv(envRole)]; ok { + os.Exit(f()) + } + os.Exit(m.Run()) +} + +func helperSelfCheck() int { + exe, _ := os.Executable() + v := filepath.Base(filepath.Dir(exe)) + 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..f5053597 --- /dev/null +++ b/internal/selfupdate/proxy.go @@ -0,0 +1,125 @@ +package selfupdate + +import ( + "net" + "net/http" + "net/url" + "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(http.ProxyFromEnvironment, loadMachineProxy()) + tr.TLSHandshakeTimeout = 30 * time.Second + tr.ResponseHeaderTimeout = 60 * time.Second + return &http.Client{Transport: tr} +} + +func proxyFunc(env func(*http.Request) (*url.URL, error), mp *machineProxy) func(*http.Request) (*url.URL, error) { + return func(req *http.Request) (*url.URL, error) { + if u, err := env(req); u != nil || err != nil { + return u, err + } + if mp == nil { + return nil, nil + } + return mp.proxyFor(req.URL), nil + } +} + +// 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..c889fbd8 --- /dev/null +++ b/internal/selfupdate/release.go @@ -0,0 +1,329 @@ +// 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.19.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 +} + +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. Missing dmg-linux fields are left empty for +// VerifySignatures to reject (update_verify_failed). +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) + } + if win.Checksum == "" || win.SignedChecksum == "" || win.LauncherChecksum == "" || win.SignedLauncherChecksum == "" { + return nil, fmt.Errorf("%w: response for %s lacks agent or launcher checksums", ErrCheck, v) + } + 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.HTTP.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.HTTP.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..51f991fa --- /dev/null +++ b/internal/selfupdate/release_test.go @@ -0,0 +1,301 @@ +package selfupdate + +import ( + "context" + "errors" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "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) + `"}` }, + "no launcher": func(f *fakeAPI) { f.metaBody = `{"version":"1.20.0","checksum":"a","signed_checksum":"b"}` }, + "missing release": func(f *fakeAPI) { f.target = "1.21.0" }, + } + 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. +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) + } + rel2.Assets[1].Checksum = "nothex" + if err := c.VerifySignatures(rel2); !errors.Is(err, ErrVerify) { + t.Fatalf("non-sha checksum: err = %v", 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", "") + req, _ := http.NewRequest(http.MethodGet, "https://api.example/", nil) + + fromEnv := proxyFunc(func(*http.Request) (*url.URL, error) { return envURL, nil }, mp) + if u, _ := fromEnv(req); u.Host != "env-proxy:1" { + t.Fatalf("env proxy not preferred: %v", u) + } + noEnv := proxyFunc(func(*http.Request) (*url.URL, error) { return nil, nil }, mp) + if u, _ := noEnv(req); u == nil || u.Host != "machine:2" { + t.Fatalf("machine proxy not used: %v", u) + } + none := proxyFunc(func(*http.Request) (*url.URL, error) { return nil, nil }, nil) + if u, _ := none(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) + } +} diff --git a/internal/selfupdate/smoke.go b/internal/selfupdate/smoke.go new file mode 100644 index 00000000..3e88452b --- /dev/null +++ b/internal/selfupdate/smoke.go @@ -0,0 +1,74 @@ +package selfupdate + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/updatestate" +) + +// 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. +type limitedBuffer struct { + bytes.Buffer + max int +} + +func (b *limitedBuffer) Write(p []byte) (int, error) { + if room := b.max - b.Len(); room > 0 { + if len(p) > room { + b.Buffer.Write(p[:room]) + } else { + b.Buffer.Write(p) + } + } + return len(p), nil +} + +// 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 := cmd.Run(); err != nil { + if ctx.Err() != nil { + return fmt.Errorf("self-check timed out after %s", 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..bdf6a57e --- /dev/null +++ b/internal/selfupdate/smoke_test.go @@ -0,0 +1,58 @@ +package selfupdate + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" +) + +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")); err == nil || !strings.Contains(err.Error(), "timed out") { + 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()) + } +} 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..9b4f46b8 --- /dev/null +++ b/internal/selfupdate/system_other.go @@ -0,0 +1,26 @@ +//go:build !windows + +package selfupdate + +import ( + "os/exec" + + "golang.org/x/sys/unix" +) + +func prepareSmoke(*exec.Cmd) {} + +// 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 +} diff --git a/internal/selfupdate/system_windows.go b/internal/selfupdate/system_windows.go new file mode 100644 index 00000000..faee0a37 --- /dev/null +++ b/internal/selfupdate/system_windows.go @@ -0,0 +1,123 @@ +//go:build windows + +package selfupdate + +import ( + "fmt" + "os" + "os/exec" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +func prepareSmoke(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW} +} + +// 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 +} 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..140f088f --- /dev/null +++ b/internal/selfupdate/testkeys_on.go @@ -0,0 +1,49 @@ +//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", +} + +// 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..f05b7f63 --- /dev/null +++ b/internal/selfupdate/updater.go @@ -0,0 +1,778 @@ +package selfupdate + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "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 = 32 // pending events kept + inconclusiveRetry = 7 * 24 * time.Hour + freeSpaceMargin = 50 << 20 + runTimeout = 25 * time.Minute // under the task's 30 min limit +) + +// 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) + // 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 +} + +// 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) + } + + 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) + 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 lists MSI seed versions that hold an agent and launcher, +// oldest first. +func seedVersions(L updatestate.Layout) []updatestate.Version { + entries, err := os.ReadDir(filepath.Join(L.InstallRoot, "seed")) + if err != nil { + return nil + } + var out []updatestate.Version + for _, e := range entries { + v, err := updatestate.ParseVersion(e.Name()) + if err != nil || !e.IsDir() { + continue + } + ok := true + for _, f := range []string{updatestate.AgentFile, updatestate.LauncherFile} { + if fi, err := os.Lstat(filepath.Join(L.SeedDir(v), f)); err != nil || !fi.Mode().IsRegular() { + ok = false + } + } + if ok { + out = append(out, v) + } + } + sort.Slice(out, func(i, j int) bool { return updatestate.Compare(out[i], out[j]) < 0 }) + return out +} + +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 len(st.Outbox) > maxOutbox { + st.Outbox = st.Outbox[len(st.Outbox)-maxOutbox:] + } +} + +// 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()) + 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 !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) + } +} + +// 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 + } + 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() + 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) + } + } +} + +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{}) + } + healthy, neutral, bad := 0, 0, 0 + v, _ := updatestate.ParseVersion(p.Version) + for _, r := range updatestate.ReadRuns(t.L.RunFile(v)) { + if r.At.Before(p.Since) { + 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: + return false, nil + case neutral >= inconclusiveMinRuns: + 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(ctx, filepath.Join(t.L.VersionDir(v), updatestate.AgentFile), v); err != nil { + t.d.Logf("self-update: re-smoke of %s failed: %v", v, err) + return true, t.rollback(ReasonSmokeFailed, time.Time{}) + } + t.confirm("smoke_only") + return true, nil +} + +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)) +} + +// save writes next with the given checkpoint prefix. After any error the +// state on disk may be old or new, so it is re-read; t.st becomes whatever +// is on disk, and an error is returned only if next did not land. +func (t *tick) save(next *updatestate.State, prefix string) error { + err := updatestate.SaveCheckpointed(t.L.StateFile(), next, prefix) + if err == nil { + t.st = next + return nil + } + t.d.Logf("self-update: state write (%s): %v", prefix, err) + disk, _, lerr := updatestate.Load(t.L.StateFile()) + if lerr == nil && disk != nil { + normalize(disk) + t.st = disk + if disk.Current == next.Current && sameProbation(disk.Probation, next.Probation) { + return nil + } + } + return err +} + +func sameProbation(a, b *updatestate.Probation) bool { + if a == nil || b == nil { + return a == b + } + return a.Version == b.Version && a.Since.Equal(b.Since) +} + +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 + 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...) + 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) + 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() + 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) + } + + // S4 Stage. + if event, err := t.stage(ctx, 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) + return t.finish(ctx, ExitOK) + } + + // S5 Smoke. + agent := filepath.Join(t.L.VersionDir(rel.Version), updatestate.AgentFile) + if err := t.d.Smoke(ctx, agent, rel.Version); err != nil { + t.d.Logf("self-update: smoke of %s failed: %v", target, err) + t.st.Quarantine[target] = updatestate.Quarantine{Reason: ReasonSmokeFailed, At: t.now.UTC()} + t.enqueue(EventHealthFailed, st.Current, target, err.Error(), "") + return t.finish(ctx, ExitOK) + } + 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 { + 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 + next.Probation = &updatestate.Probation{Version: target, Since: t.now.UTC(), SmokeOK: true} + next.SwitchDeferrals = 0 + 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().After(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. +func (t *tick) finish(ctx context.Context, code int) int { + if err := t.save(t.st, ""); err != nil { + return ExitFailed + } + if t.flush(ctx) { + if err := t.save(t.st, ""); err != nil { + return ExitFailed + } + } + return code +} + +// flush sends pending events oldest first. Delivered and rejected events +// are removed; anything else stays for the next tick. 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 + } + var keep []updatestate.OutboxEvent + for _, ev := range t.st.Outbox { + err := t.d.Send(ctx, ev) + switch { + case err == nil: + case errors.Is(err, ErrEventRejected): + t.d.Logf("self-update: %s dropped: %v", ev.Event, err) + default: + t.d.Logf("self-update: %s not sent (kept for the next tick): %v", ev.Event, err) + keep = append(keep, ev) + } + } + changed := len(keep) != len(t.st.Outbox) + 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..1cb7b480 --- /dev/null +++ b/internal/selfupdate/updater_test.go @@ -0,0 +1,684 @@ +package selfupdate + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "sync" + "testing" + "time" + + "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.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{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 + } + 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.18.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) + } + 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) + } +} 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 04ea5f51..d5b943bb 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -567,8 +567,13 @@ 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). + if reportRunStatus(ctx, log, executionID, deviceID, runStatusStarted, "", invocationMethod) { + if err := markRunStarted(); err != nil { + log.Debug("run record: %v", err) + } + } // 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..e5f4183b --- /dev/null +++ b/internal/telemetry/update_event.go @@ -0,0 +1,153 @@ +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 +) + +// 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 4xx: 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. +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 < 500: + return fmt.Errorf("%w: HTTP %d", ErrUpdateEventRejected, resp.StatusCode) + } + 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) + } + return updatestate.MarkRunStarted(path, runID) +} diff --git a/internal/telemetry/update_event_test.go b/internal/telemetry/update_event_test.go new file mode 100644 index 00000000..7ac52d2f --- /dev/null +++ b/internal/telemetry/update_event_test.go @@ -0,0 +1,161 @@ +package telemetry + +import ( + "context" + "encoding/json" + "errors" + "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) + } +} 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/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..fd01c330 --- /dev/null +++ b/internal/updatestate/crash_faults_test.go @@ -0,0 +1,31 @@ +//go:build selfupdate_faults + +package updatestate + +import "testing" + +// 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) { + 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..23d2463d --- /dev/null +++ b/internal/updatestate/crash_test.go @@ -0,0 +1,88 @@ +package updatestate + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +// 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") + } + 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 := exec.Command(os.Args[0], "-test.run=^TestCrashHelper$") + cmd.Env = append(os.Environ(), 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..71ddf19f --- /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) + 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) + if len(recs) > MaxRunRecords { + t.Fatalf("%d records", len(recs)) + } + 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..526d09c5 --- /dev/null +++ b/internal/updatestate/layout.go @@ -0,0 +1,72 @@ +// 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" +) + +// Production MSI roots. Everything takes the roots as parameters so tests +// and loader installs can use others. +const ( + DefaultInstallRoot = `C:\Program Files\StepSecurity` + DefaultDataRoot = `C:\ProgramData\StepSecurity` +) + +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). +type Layout struct { + InstallRoot string + DataRoot string +} + +// MSILayout is the production MSI layout. +func MSILayout() Layout { + return Layout{InstallRoot: DefaultInstallRoot, DataRoot: DefaultDataRoot} +} + +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..fceffeab --- /dev/null +++ b/internal/updatestate/layout_test.go @@ -0,0 +1,59 @@ +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) + } + } + if m := MSILayout(); m.InstallRoot != `C:\Program Files\StepSecurity` || m.DataRoot != `C:\ProgramData\StepSecurity` { + t.Errorf("MSILayout = %+v", m) + } +} + +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..de67ffd3 --- /dev/null +++ b/internal/updatestate/lock_test.go @@ -0,0 +1,71 @@ +package updatestate + +import ( + "bufio" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" +) + +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") + } + 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 := exec.Command(os.Args[0], "-test.run=^TestLockHelper$") + cmd.Env = append(os.Environ(), "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/manifest.go b/internal/updatestate/manifest.go new file mode 100644 index 00000000..011eff57 --- /dev/null +++ b/internal/updatestate/manifest.go @@ -0,0 +1,120 @@ +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 cheap check: every VersionFiles entry and the +// manifest exist as regular files. It does not hash. +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..3644aa71 --- /dev/null +++ b/internal/updatestate/manifest_test.go @@ -0,0 +1,58 @@ +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") + } +} 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/pointers.go b/internal/updatestate/pointers.go new file mode 100644 index 00000000..494fe092 --- /dev/null +++ b/internal/updatestate/pointers.go @@ -0,0 +1,277 @@ +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 l.RunFile(v) + ".lock" } + +// 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 + } + 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/runs.go b/internal/updatestate/runs.go new file mode 100644 index 00000000..4cc97dc7 --- /dev/null +++ b/internal/updatestate/runs.go @@ -0,0 +1,159 @@ +package updatestate + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "time" +) + +// 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"` + Version string `json:"version"` + 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"` +} + +// ErrNoOpenRun means the run file has no unfinalized record with that run_id. +var ErrNoOpenRun = errors.New("updatestate: no open run record") + +// ReadRuns returns the newest MaxRunRecords records, oldest first. 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 { + data, err := readBounded(path, maxRunsSize) + if err != nil { + return nil + } + return decodeRuns(data) +} + +func decodeRuns(data []byte) []RunRecord { + var f runFile + if json.Unmarshal(data, &f) != nil { + return nil + } + out := f.Records[:0] + for _, r := range f.Records { + if validVersion(r.Version) { + out = append(out, r) + } + } + if len(out) > MaxRunRecords { + out = out[len(out)-MaxRunRecords:] + } + if len(out) == 0 { + return nil + } + return out +} + +func writeRuns(path string, recs []RunRecord) error { + if len(recs) > MaxRunRecords { + recs = recs[len(recs)-MaxRunRecords:] + } + data, err := json.Marshal(runFile{Records: recs}) + 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) +} + +// 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") + } + return writeRuns(path, append(ReadRuns(path), r)) +} + +// 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 }) +} + +// 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 := ReadRuns(path) + for i := range recs { + if recs[i].RunID == runID && ClassifyRun(recs[i]) == RunOpen { + fn(&recs[i]) + return writeRuns(path, recs) + } + } + 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 +) + +// 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.WatchdogKilled: + return RunBad + case r.ExitCode == nil: + return RunOpen + case *r.ExitCode == panicExitCode: + return RunBad + case r.Started: + return RunHealthy + case *r.ExitCode == 0 || *r.ExitCode == 1: + 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..ffc1a561 --- /dev/null +++ b/internal/updatestate/runs_test.go @@ -0,0 +1,175 @@ +package updatestate + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +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{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) + } + } +} + +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{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) + } + } +} diff --git a/internal/updatestate/state.go b/internal/updatestate/state.go new file mode 100644 index 00000000..8bce2496 --- /dev/null +++ b/internal/updatestate/state.go @@ -0,0 +1,218 @@ +package updatestate + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "time" + + "github.com/step-security/dev-machine-guard/internal/faultinject" +) + +// SchemaVersion is the only state.json schema this package reads or writes. +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). + + // 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"` + + // Extra holds keys this version doesn't know; Save writes them back. + Extra map[string]json.RawMessage `json:"-"` +} + +// 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"` +} + +// 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"` + + 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: + 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 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 + out.Schema = SchemaVersion + 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") } + } + return writeAtomic(path, append(data, '\n'), afterTemp, afterRename) +} diff --git a/internal/updatestate/state_test.go b/internal/updatestate/state_test.go new file mode 100644 index 00000000..10948dd5 --- /dev/null +++ b/internal/updatestate/state_test.go @@ -0,0 +1,303 @@ +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": `{"schema":2,"current":"1.2.3"}`, + "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}, + "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) + 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) + } +} diff --git a/internal/updatestate/unknownfields.go b/internal/updatestate/unknownfields.go new file mode 100644 index 00000000..3536c7c0 --- /dev/null +++ b/internal/updatestate/unknownfields.go @@ -0,0 +1,114 @@ +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) +} + +// 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/packaging/windows/Product.wxs b/packaging/windows/Product.wxs index a7fb8750..324f4902 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 @@