diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1b3381f..ed2b875 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -55,8 +55,15 @@ jobs: shellcheck --shell=bash --severity=error scripts/check-tool-pins.sh dash -n scripts/install.sh bash -n scripts/tests/install-verify.sh + shellcheck --shell=bash --severity=error scripts/tests/install-ps1-verify.sh + bash -n scripts/tests/install-ps1-verify.sh - name: Verification harness (mandatory cosign / fail-closed) run: bash scripts/tests/install-verify.sh + # Same property on Windows (backend#2078). pwsh is preinstalled on the + # ubuntu runner image; the harness FAILS rather than skips if it isn't, + # since "cannot tell" is not evidence that verification is mandatory. + - name: Verification harness — Windows (mandatory cosign / fail-closed) + run: bash scripts/tests/install-ps1-verify.sh test: timeout-minutes: 15 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index cc63714..7d7266c 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -10,7 +10,10 @@ # 2. Resolves the latest release tag (or honors $env:RELEASE_VERSION) # 3. Downloads tracebloc--windows-amd64.exe + SHA256SUMS # 4. Verifies SHA256 -# 5. (Optional) Verifies cosign signature if cosign.exe is on PATH +# 5. Verifies the cosign signature — MANDATORY (RFC-0001 R8). If cosign +# isn't on PATH it bootstraps a pinned, checksum-verified copy; if it +# can't, the install FAILS CLOSED rather than trusting the same-channel +# SHA256 alone. TRACEBLOC_ALLOW_UNVERIFIED=1 is the one (loud) escape. # 6. Installs to $env:USERPROFILE\AppData\Local\Programs\tracebloc\tracebloc.exe # and PATH-adds it via user-scope env var # @@ -45,6 +48,111 @@ $InstallPrefix = if ($env:INSTALL_PREFIX) { $env:INSTALL_PREFIX } ` $GitHubRepo = 'tracebloc/cli' $BinaryName = 'tracebloc.exe' +# Pinned verifier. Keep in lockstep with tracebloc/client's install.sh / +# install.ps1 COSIGN_VERSION and release.yml's cosign-installer pin. +$CosignVersion = 'v2.4.1' + +# The ONE escape from mandatory verification, for a genuinely constrained +# environment. Loud, and never the default (RFC-0001 R8). +# +# Compare against '1' explicitly. NOT [bool]$env:... — PowerShell casts any +# non-empty string to $true, so TRACEBLOC_ALLOW_UNVERIFIED=0 would have +# switched the bypass ON. Matches install.sh's `[ "$ALLOW_UNVERIFIED" = "1" ]`. +$AllowUnverified = ($env:TRACEBLOC_ALLOW_UNVERIFIED -eq '1') + +# TLS 1.2 floor. PowerShell 5.1 defaults to SSL3/TLS1.0 on older Windows, and +# every fetch below carries either the binary we are about to run or the +# verifier that authenticates it — neither may negotiate down. PS7+ already +# defaults higher; setting it is harmless there. +try { + [Net.ServicePointManager]::SecurityProtocol = + [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +} catch { } + +# --------------------------------------------------------------------- +# cosign bootstrap (RFC-0001 R8). +# --------------------------------------------------------------------- + +function Get-Sha256([string]$Path) { + return (Get-FileHash -Algorithm SHA256 -Path $Path).Hash.ToLower() +} + +# Resolve a cosign we can vouch for: one already on PATH, else a pinned build +# fetched and checked against sigstore's own published checksums. Returns the +# path, or $null when it cannot be obtained — the caller decides what that means. +# +# A cosign we cannot vouch for is no better than no cosign, so a checksum +# mismatch returns $null rather than a usable path. +function Resolve-Cosign([string]$TmpDir) { + $onPath = Get-Command cosign -ErrorAction SilentlyContinue + if ($onPath) { return $onPath.Source } + + # BOTH architectures fetch the amd64 build, deliberately. + # + # Sigstore has never published a Windows arm64 cosign — not at $CosignVersion, + # not at any release. `cosign-windows-amd64.exe` is the only Windows asset + # there is, so asking for a per-arch name 404s and blocks Windows-on-ARM + # permanently (tracebloc/client#734, fixed there the same way). + # + # Running it under Windows-on-ARM's x64 emulation costs nothing that matters: + # cosign verifies a signature over BYTES, so the instruction set it was + # compiled for cannot change the verdict, and the artifact we hand it is + # still the native arm64 binary. It is checksum-verified below exactly as on + # amd64, so the trust chain is identical. + # + # Do not "fix" this to $arch. There is nothing on the other end — and + # since the asset is arch-independent there is nothing to branch on + # either; whether it RUNS here is Test-CosignRuns' question, not ours. + $base = "https://github.com/sigstore/cosign/releases/download/$CosignVersion" + $asset = 'cosign-windows-amd64.exe' + $bin = Join-Path $TmpDir 'cosign.exe' + $sums = Join-Path $TmpDir 'cosign_checksums.txt' + + Write-Host " cosign not found — downloading pinned cosign $CosignVersion (~17 MB) to verify the signature..." + try { + Invoke-WebRequest -Uri "$base/$asset" -OutFile $bin -UseBasicParsing + Invoke-WebRequest -Uri "$base/cosign_checksums.txt" -OutFile $sums -UseBasicParsing + } catch { + Write-Host " ⚠ couldn't download cosign: $($_.Exception.Message)" + return $null + } + + # cosign_checksums.txt lines: " ". + $want = $null + foreach ($line in Get-Content -LiteralPath $sums) { + $parts = @($line -split '\s+' | Where-Object { $_ -ne '' }) + if ($parts.Count -ge 2 -and $parts[-1] -eq $asset) { $want = $parts[0].ToLower(); break } + } + if (-not $want) { return $null } + if ((Get-Sha256 $bin) -ne $want) { + Write-Host " Bootstrapped cosign failed its own checksum — not using it." -ForegroundColor Red + return $null + } + Write-Host " ✓ cosign $CosignVersion downloaded and checksum-verified" + return $bin +} + +# Can this cosign actually EXECUTE here? A trivial `cosign version`. +# +# A binary that will not start reports through the same channel as a signature +# that did not verify, and those warrant opposite reactions — only one of them +# means the artifact may be tampered with. Windows-on-ARM makes it real: the +# amd64 build needs x64 emulation, and where that is absent cosign never runs. +# The 255 preset means a binary that never starts cannot leave a stale 0 behind. +function Test-CosignRuns([string]$Cosign) { + $global:LASTEXITCODE = 255 + $prev = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + & $Cosign version 2>&1 | Out-Null + } catch { + return $false + } finally { + $ErrorActionPreference = $prev + } + return ($LASTEXITCODE -eq 0) +} + # --------------------------------------------------------------------- # Detect arch. # --------------------------------------------------------------------- @@ -137,45 +245,102 @@ try { Write-Host " ✓ checksum matches" # ------------------------------------------------------------- - # Cosign signature verification (optional). + # Cosign signature verification — MANDATORY (RFC-0001 R8). + # + # The SHA256 above is same-channel: it comes from the same GitHub + # release as the binary, so whoever could swap the binary could swap + # SHA256SUMS with it. It proves the download completed, not who built + # it. The cosign signature is the independent, Sigstore-rooted proof + # that tracebloc's release workflow produced these bytes. + # + # So this no longer skips when cosign is absent — it bootstraps a + # pinned, checksum-verified cosign, and FAILS CLOSED when it cannot. + # This mirrors install.sh exactly; Windows was the one platform still + # installing on the checksum alone (backend#2078). + # + # TRACEBLOC_ALLOW_UNVERIFIED=1 covers "cannot verify" — no cosign, no + # .sig/.cert. It deliberately does NOT cover a verification that ran + # and FAILED: that is evidence of tampering, and no env var overrides + # it. # ------------------------------------------------------------- - if (Get-Command cosign -ErrorAction SilentlyContinue) { + $cosign = Resolve-Cosign $tmpDir + + if ($cosign -and -not (Test-CosignRuns $cosign)) { + # Distinct from "no cosign": we have one, it just won't start here. + # On Windows-on-ARM that means x64 emulation is missing or blocked; + # it can also be SmartScreen/AV quarantine or a policy block. Saying + # "install cosign" here would be useless advice — one is installed. + if ($AllowUnverified) { + Write-Host " WARNING: cosign is present but won't run here — signature NOT" -ForegroundColor Yellow + Write-Host " verified (TRACEBLOC_ALLOW_UNVERIFIED=1)." -ForegroundColor Yellow + $cosign = $null + } else { + Write-Host "Error: cosign was found but won't execute on this machine, so the" -ForegroundColor Red + Write-Host " signature can't be verified (RFC-0001 R8)." -ForegroundColor Red + Write-Host " On Windows-on-ARM this usually means x64 emulation is" -ForegroundColor Red + Write-Host " unavailable; it can also be a quarantine or policy block." -ForegroundColor Red + Write-Host " Fix that, or for a constrained environment re-run with" -ForegroundColor Red + Write-Host " TRACEBLOC_ALLOW_UNVERIFIED=1." -ForegroundColor Red + exit 1 + } + } + + if (-not $cosign) { + if (-not $AllowUnverified) { + Write-Host "Error: cosign is required to verify the binary's signature and" -ForegroundColor Red + Write-Host " could not be found or bootstrapped — refusing to install on" -ForegroundColor Red + Write-Host " an unauthenticated, same-channel checksum alone (RFC-0001 R8)." -ForegroundColor Red + Write-Host " Fix: install cosign and re-run —" -ForegroundColor Red + Write-Host " https://docs.sigstore.dev/cosign/system_config/installation/" -ForegroundColor Red + Write-Host " or for a constrained environment re-run with" -ForegroundColor Red + Write-Host " TRACEBLOC_ALLOW_UNVERIFIED=1." -ForegroundColor Red + exit 1 + } + Write-Host " WARNING: cosign unavailable and couldn't be bootstrapped —" -ForegroundColor Yellow + Write-Host " signature NOT verified (TRACEBLOC_ALLOW_UNVERIFIED=1). The SHA256" -ForegroundColor Yellow + Write-Host " above is same-channel only; do not use this path in production." -ForegroundColor Yellow + } else { Write-Host "Verifying cosign signature..." - # Separate "download .sig/.cert" (recoverable if absent — old - # releases predate signing) from "verify the downloaded sig" - # (NOT recoverable — a failed verification means the binary - # is potentially tampered, refuse to install). Bugbot PR #11 - # caught the prior structure: with $ErrorActionPreference = - # 'Stop', Write-Error inside the try-block was thrown and - # caught by the same catch that handled missing-sig, so a - # failed verify silently downgraded to "skip + continue." + + # "download .sig/.cert" and "verify the downloaded sig" must stay + # separate. With $ErrorActionPreference = 'Stop', a Write-Error + # inside the try-block is thrown and caught by the same catch that + # handles a missing sig — so a FAILED verify silently downgrades to + # "skip + continue" (Bugbot, PR #11). The verify below therefore + # runs OUTSIDE any try/catch: & invokes cosign as an external + # process, whose non-zero $LASTEXITCODE cannot be caught anyway. $sigDownloaded = $false try { Invoke-WebRequest -Uri "$baseUrl/$binaryFile.sig" -OutFile (Join-Path $tmpDir "$binaryFile.sig") -UseBasicParsing Invoke-WebRequest -Uri "$baseUrl/$binaryFile.cert" -OutFile (Join-Path $tmpDir "$binaryFile.cert") -UseBasicParsing $sigDownloaded = $true } catch { - Write-Host " ⚠ couldn't download .sig/.cert — release may pre-date signing." + if (-not $AllowUnverified) { + Write-Host "Error: couldn't download $binaryFile.sig / .cert for $tag — the" -ForegroundColor Red + Write-Host " release is unsigned or incomplete. Every supported release" -ForegroundColor Red + Write-Host " is cosign-signed; refusing to install unverified (RFC-0001 R8)." -ForegroundColor Red + Write-Host ' Pin a signed $env:RELEASE_VERSION, or re-run with TRACEBLOC_ALLOW_UNVERIFIED=1.' -ForegroundColor Red + exit 1 + } + Write-Host " WARNING: .sig/.cert not published for $tag — signature NOT" -ForegroundColor Yellow + Write-Host " verified (TRACEBLOC_ALLOW_UNVERIFIED=1)." -ForegroundColor Yellow } + if ($sigDownloaded) { - # Verify OUTSIDE the try/catch: a non-zero $LASTEXITCODE - # from cosign is a hard refusal, not a swallowed - # exception. & invokes cosign as an external process, - # which doesn't interact with $ErrorActionPreference. - & cosign verify-blob ` + & $cosign verify-blob ` --certificate-identity-regexp "https://github.com/$GitHubRepo/.github/workflows/release.yml@refs/tags/v.*" ` --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' ` --certificate (Join-Path $tmpDir "$binaryFile.cert") ` --signature (Join-Path $tmpDir "$binaryFile.sig") ` (Join-Path $tmpDir $binaryFile) 2>$null if ($LASTEXITCODE -ne 0) { + # No TRACEBLOC_ALLOW_UNVERIFIED branch here, deliberately. + # Verification RAN and said no. Write-Host "Error: cosign signature verification FAILED — refusing to install." -ForegroundColor Red exit 1 } Write-Host " ✓ cosign signature valid" } - } else { - Write-Host " (cosign not installed; SHA256 verified, signature skipped)" } # ------------------------------------------------------------- diff --git a/scripts/tests/install-ps1-functions.tests.ps1 b/scripts/tests/install-ps1-functions.tests.ps1 new file mode 100644 index 0000000..d696c96 --- /dev/null +++ b/scripts/tests/install-ps1-functions.tests.ps1 @@ -0,0 +1,287 @@ +# ============================================================================= +# install-ps1-functions.tests.ps1 — behavioural tests for install.ps1's +# verification helpers (RFC-0001 R8, backend#2078). +# +# install.ps1 is a `irm | iex` entrypoint that ends in Windows-registry PATH +# writes, so it cannot be driven end-to-end on the Linux runner the way +# install-verify.sh drives install.sh. What CAN be driven — and is where the +# security decisions actually live — are its pure helpers. +# +# They are EXTRACTED FROM THE REAL FILE by AST and evaluated here. Nothing +# below re-implements a rule from install.ps1: if a helper changes, this test +# runs the changed helper. A copy would pass while production broke, which is +# the failure mode CLAUDE.md rule 9 names. +# +# No Pester: this repo has no Pester tier, and standing one up to assert four +# things costs more than it returns. Exit code is the contract. +# ============================================================================= +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:Pass = 0 +$script:Fail = 0 +function ok ([string]$m) { Write-Host " ok $m"; $script:Pass++ } +function bad ([string]$m) { Write-Host " FAIL $m"; $script:Fail++ } +function is ([string]$m, $got, $want) { + if ($got -eq $want) { ok $m } else { bad "$m (got '$got', want '$want')" } +} + +$installer = Join-Path $PSScriptRoot '..' 'install.ps1' | Resolve-Path + +# ── extract, never restate ────────────────────────────────────────────────── +$errs = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile( + "$installer", [ref]$null, [ref]$errs) +if ($errs) { + # A parse error is a finding, not a skip: we cannot tell whether the + # helpers are correct, and "cannot tell" never passes (CLAUDE.md rule 3). + $errs | ForEach-Object { Write-Host " FAIL parse: $($_.Message)" } + exit 1 +} +ok 'install.ps1 parses' + +function Get-Fn([string]$Name) { + $hit = $ast.FindAll({ + param($n) + $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $n.Name -eq $Name + }, $true) + if ($hit.Count -ne 1) { + bad "expected exactly one definition of $Name, found $($hit.Count)" + return $null + } + return $hit[0].Extent.Text +} + +foreach ($n in 'Get-Sha256', 'Resolve-Cosign', 'Test-CosignRuns') { + $src = Get-Fn $n + if (-not $src) { Write-Host "install-ps1-functions: $script:Pass passed, $script:Fail failed"; exit 1 } + Invoke-Expression $src +} +ok 'Get-Sha256 / Resolve-Cosign / Test-CosignRuns extracted' + +# ── 1. $AllowUnverified: only the literal '1' opts out ────────────────────── +# The bypass is the single thing standing between a user and an unverified +# binary, so its parsing gets a truth table rather than a spot check. +# +# '0' is the case that matters. [bool]'0' is $true in PowerShell — any +# non-empty string is — so the natural-looking `[bool]$env:...` turns +# TRACEBLOC_ALLOW_UNVERIFIED=0 into "verification off". Caught here before it +# shipped; this test is why it stays caught. +$assign = $ast.FindAll({ + param($n) + $n -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $n.Left -is [System.Management.Automation.Language.VariableExpressionAst] -and + $n.Left.VariablePath.UserPath -eq 'AllowUnverified' +}, $true) +if ($assign.Count -ne 1) { + bad "expected one \$AllowUnverified assignment, found $($assign.Count)" +} else { + $expr = $assign[0].Right.Extent.Text + # Written down independently of the expression under test — the values come + # from what a user might plausibly set, not from reading the matcher + # (CLAUDE.md rule 9's "never test a list against itself"). + $cases = @( + @{ v = $null; want = $false; why = 'unset' }, + @{ v = ''; want = $false; why = 'empty' }, + @{ v = '0'; want = $false; why = "the string 0" }, + @{ v = 'false'; want = $false; why = "'false'" }, + @{ v = 'no'; want = $false; why = "'no'" }, + @{ v = 'true'; want = $false; why = "'true' is not the documented opt-in" }, + @{ v = '1'; want = $true; why = 'the documented opt-in' } + ) + foreach ($c in $cases) { + if ($null -eq $c.v) { Remove-Item Env:TRACEBLOC_ALLOW_UNVERIFIED -ErrorAction SilentlyContinue } + else { $env:TRACEBLOC_ALLOW_UNVERIFIED = $c.v } + is "AllowUnverified is $($c.want) for $($c.why)" (Invoke-Expression $expr) $c.want + } + Remove-Item Env:TRACEBLOC_ALLOW_UNVERIFIED -ErrorAction SilentlyContinue +} + +# ── 2. Test-CosignRuns distinguishes "won't start" from "ran and said no" ─── +# This is the Windows-on-ARM case in miniature: a cosign that cannot execute +# reports through the same channel as a signature that failed to verify, and +# those two warrant opposite messages. /bin/true and /bin/false stand in for a +# cosign that starts and one that doesn't. +$tmp = Join-Path ([IO.Path]::GetTempPath()) ("tb-ps1-" + [Guid]::NewGuid()) +New-Item -ItemType Directory -Path $tmp -Force | Out-Null +try { + # Resolved, not hardcoded: /bin/true on Linux, /usr/bin/true on macOS. + $trueBin = (Get-Command true -CommandType Application).Source | Select-Object -First 1 + $falseBin = (Get-Command false -CommandType Application).Source | Select-Object -First 1 + is 'Test-CosignRuns true for a binary that exits 0' (Test-CosignRuns $trueBin) $true + is 'Test-CosignRuns false for a binary that exits 1' (Test-CosignRuns $falseBin) $false + + # The one that actually reproduces Windows-on-ARM without emulation: the + # file is there, it just cannot be executed. Must be $false, not a throw — + # a throw would escape into the installer's `Stop` preference and surface + # as a stack trace instead of the actionable message. + $noexec = Join-Path $tmp 'not-executable' + Set-Content -LiteralPath $noexec -Value 'not a binary' + is 'Test-CosignRuns false for a non-executable file' (Test-CosignRuns $noexec) $false + is 'Test-CosignRuns false for a path that is absent' (Test-CosignRuns (Join-Path $tmp 'nope')) $false + + # A stale success must not leak through, and this is the input that proves + # it. An absent path throws and returns early, so it never reads + # $LASTEXITCODE — a "stale 0" assertion built on one is vacuous (it was, and + # the mutation caught it). The reachable case is a PowerShell SHIM: scoop and + # chocolatey install tools as cosign.ps1, Get-Command finds .ps1 on PATH + # whatever PATHEXT says, and `&` dispatches it IN-PROCESS — setting no + # $LASTEXITCODE at all. Without the 255 preset the helper then reads the 0 + # left by the last successful command and reports that a shim which did + # nothing is a working cosign. + $shim = Join-Path $tmp 'cosign.ps1' + Set-Content -LiteralPath $shim -Value 'Write-Output "shim: nothing to do"' + & $trueBin # arm a stale 0, exactly as the happy path does + is 'Test-CosignRuns false for a PowerShell shim that sets no exit code' ` + (Test-CosignRuns $shim) $false + + # ── 3. Resolve-Cosign returns the one on PATH without a download ───────── + $onpath = Join-Path $tmp 'pathbin' + New-Item -ItemType Directory -Path $onpath -Force | Out-Null + $fake = Join-Path $onpath 'cosign' + Set-Content -LiteralPath $fake -Value "#!/bin/sh`nexit 0" + & chmod +x $fake + $savedPath = $env:PATH + try { + $env:PATH = "${onpath}:${savedPath}" + $got = Resolve-Cosign $tmp + if ($got -and (Resolve-Path $got).Path -eq (Resolve-Path $fake).Path) { + ok 'Resolve-Cosign short-circuits to the cosign already on PATH' + } else { + bad "Resolve-Cosign short-circuits to the cosign already on PATH (got '$got')" + } + } finally { $env:PATH = $savedPath } + + # ── 4. a bootstrapped cosign that fails its own checksum is refused ────── + # The security-critical branch. A verifier we cannot vouch for is worth no + # more than no verifier, so Resolve-Cosign must return $null — NOT a usable + # path — and let the caller's fail-closed logic run. + # + # Shadowing the cmdlet: a function defined here wins over Invoke-WebRequest + # for the duration, so the extracted Resolve-Cosign hits this instead of + # the network. $CosignVersion is what the real file pins. + $CosignVersion = 'v0.0.0-test' + function Invoke-WebRequest { + param($Uri, $OutFile) + if ($Uri -match 'checksums') { + # A well-formed checksums file naming the right asset — with the + # WRONG digest. Nothing about the transfer failed; the bytes are + # simply not the bytes sigstore published. + Set-Content -LiteralPath $OutFile -Value ("{0} cosign-windows-amd64.exe" -f ('0' * 64)) + } else { + Set-Content -LiteralPath $OutFile -Value 'pretend-cosign-bytes' + } + } + $bootstrapDir = Join-Path $tmp 'boot' + New-Item -ItemType Directory -Path $bootstrapDir -Force | Out-Null + is 'Resolve-Cosign returns $null when the bootstrap fails its checksum' ` + (Resolve-Cosign $bootstrapDir) $null + + # …and returns a path when the digest DOES match, so the check above is + # failing for the reason it claims and not because the mock never worked. + # Without this pair, a Resolve-Cosign that always returned $null would look + # perfectly healthy (CLAUDE.md rule 5: assert the anchor applied). + $good = Join-Path $tmp 'good' + New-Item -ItemType Directory -Path $good -Force | Out-Null + $payload = 'pretend-cosign-bytes' + function Invoke-WebRequest { + param($Uri, $OutFile) + if ($Uri -match 'checksums') { + $probe = Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid()) + Set-Content -LiteralPath $probe -Value 'pretend-cosign-bytes' -NoNewline + # Match how Set-Content writes the payload below, byte for byte. + $h = (Get-FileHash -Algorithm SHA256 -Path $probe).Hash.ToLower() + Remove-Item $probe -Force + Set-Content -LiteralPath $OutFile -Value "$h cosign-windows-amd64.exe" + } else { + Set-Content -LiteralPath $OutFile -Value 'pretend-cosign-bytes' -NoNewline + } + } + $gotGood = Resolve-Cosign $good + if ($gotGood -and (Test-Path $gotGood)) { + ok 'Resolve-Cosign returns the bootstrapped path when the checksum matches' + } else { + bad "Resolve-Cosign returns the bootstrapped path when the checksum matches (got '$gotGood')" + } +} finally { + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue +} + +# ── 5. the verification that RAN and failed has no escape hatch ───────────── +# TRACEBLOC_ALLOW_UNVERIFIED covers "cannot verify" — no cosign, no .sig/.cert. +# It must NOT cover "verified and FAILED": that is evidence of tampering, and no +# env var overrides it. Asserted against the AST rather than by reading the +# file, so it holds however the branch is reformatted. +$verifyCall = $ast.FindAll({ + param($n) + $n -is [System.Management.Automation.Language.CommandAst] -and + $n.Extent.Text -match 'verify-blob' +}, $true) +if ($verifyCall.Count -ne 1) { + bad "expected one cosign verify-blob call, found $($verifyCall.Count)" +} else { + ok 'exactly one cosign verify-blob call' + # The refusal is the `if ($LASTEXITCODE -ne 0)` that follows it. + $guard = $ast.FindAll({ + param($n) + $n -is [System.Management.Automation.Language.IfStatementAst] -and + $n.Extent.StartOffset -gt $verifyCall[0].Extent.EndOffset -and + $n.Clauses[0].Item1.Extent.Text -match 'LASTEXITCODE' + }, $true) | Sort-Object { $_.Extent.StartOffset } | Select-Object -First 1 + + if (-not $guard) { + bad 'no $LASTEXITCODE guard follows the verify-blob call' + } else { + $body = $guard.Clauses[0].Item2.Extent.Text + if ($body -match '\bexit\b') { ok 'a failed verification exits' } + else { bad 'a failed verification does not exit' } + if ($body -match 'AllowUnverified') { + bad 'the failed-verification branch has a TRACEBLOC_ALLOW_UNVERIFIED escape' + } else { + ok 'no TRACEBLOC_ALLOW_UNVERIFIED escape from a FAILED verification' + } + } +} + +# ── 6. every "cannot verify" path is a refusal by default ─────────────────── +# Each branch that gives up on verifying must sit under an $AllowUnverified +# test AND exit when that test is false. Counted from the AST: three such +# branches exist (no cosign, cosign won't run, no .sig/.cert). A fourth added +# later without a refusal shows up here as a count change rather than passing +# silently. +$escapes = $ast.FindAll({ + param($n) + $n -is [System.Management.Automation.Language.IfStatementAst] -and + $n.Clauses[0].Item1.Extent.Text -match 'AllowUnverified' +}, $true) +$withExit = @($escapes | Where-Object { $_.Extent.Text -match '(?m)^\s*exit 1\s*$' }) +if ($escapes.Count -ge 3 -and $withExit.Count -eq $escapes.Count) { + ok "all $($escapes.Count) cannot-verify branches refuse unless opted out" +} else { + bad ("cannot-verify branches: $($escapes.Count) found, " + + "$($withExit.Count) refuse by default (want >=3, all refusing)") +} + +# ── 7. no user-facing message loses text to a bash-shaped escape ──────────── +# `\$` is not an escape in PowerShell — the escape character is a backtick. In a +# double-quoted string it renders as a literal backslash followed by the +# EXPANDED variable, so "Pin a signed \$env:RELEASE_VERSION" prints +# "Pin a signed ," silently dropping the one thing the user needed. Written +# during this change and caught by rendering the messages; asserted here so the +# next person reaching for bash muscle memory gets told. +$backslashDollar = $ast.FindAll({ + param($n) + $n -is [System.Management.Automation.Language.ExpandableStringExpressionAst] -and + $n.Value -match '\\\$' +}, $true) +if ($backslashDollar.Count -eq 0) { + ok 'no double-quoted message uses a bash-style \$ escape' +} else { + $backslashDollar | ForEach-Object { bad "bash-style \$ escape in: $($_.Extent.Text)" } +} + +Write-Host '' +Write-Host "install-ps1-functions: $script:Pass passed, $script:Fail failed" +if ($script:Fail -gt 0) { exit 1 } +exit 0 diff --git a/scripts/tests/install-ps1-verify.sh b/scripts/tests/install-ps1-verify.sh new file mode 100755 index 0000000..7d9bd3e --- /dev/null +++ b/scripts/tests/install-ps1-verify.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# ============================================================================= +# install-ps1-verify.sh — assert the MANDATORY cosign verification in +# install.ps1 (RFC-0001 R8, backend#2078). +# +# install-verify.sh's sibling. Same property, other platform: the Windows +# installer must NOT install on the same-channel SHA256 alone when cosign is +# absent. Until backend#2078 it did exactly that, printing +# "(cosign not installed; SHA256 verified, signature skipped)" — so Windows +# was the one platform where the README's "verification is mandatory … fails +# closed" was false. +# +# Two tiers: +# 1. string-level, here — the old degrade path is gone and stays gone. +# 2. behavioural, in install-ps1-functions.tests.ps1 — the helpers are +# extracted from install.ps1 by AST and actually executed. +# +# Why not drive install.ps1 end-to-end the way install-verify.sh drives +# install.sh: it finishes with Windows-registry PATH writes that throw on a +# Linux runner, so a full run would fail for reasons unrelated to what is +# under test. Tier 2 runs the parts that hold the security decisions. +# ============================================================================= +# Deliberately NO -e: this harness counts pass/fail itself and must keep going +# after a failed assertion (same shape as install-verify.sh). +set -uo pipefail + +SELF_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALLER="$SELF_DIR/../install.ps1" + +PASS=0 +FAIL=0 +ok() { printf ' ok %s\n' "$1"; PASS=$((PASS+1)); } +bad() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL+1)); } + +if [ ! -f "$INSTALLER" ]; then + # Fail closed: an installer we cannot read is not an installer that verifies. + printf ' FAIL install.ps1 not found at %s\n' "$INSTALLER" + exit 1 +fi + +# ── 1. the exact old-behaviour string must never come back ────────────────── +# Named in the ticket's acceptance criteria. install-verify.sh asserts this +# against install.sh; this is the Windows equivalent. +if grep -q 'signature skipped' "$INSTALLER"; then + bad "found the old 'signature skipped' degrade path" +else + ok "no 'signature skipped' degrade path" +fi + +# ── 2. verification is not gated on cosign happening to be installed ──────── +# The old shape was `if (Get-Command cosign …) { verify } else { skip }`, which +# makes the default — a fresh Windows box, where cosign is never present — +# the unverified one. +if grep -Eq 'if *\( *Get-Command +cosign' "$INSTALLER"; then + bad 'verification is still gated on cosign being pre-installed' +else + ok 'verification is not gated on cosign being pre-installed' +fi + +# ── 3. the header no longer advertises verification as optional ───────────── +# A stale header is how the next reader concludes the skip is intended. +if grep -Eq '^# *[0-9]+\. *\(Optional\).*cosign' "$INSTALLER"; then + bad 'the header still describes cosign verification as (Optional)' +else + ok 'the header describes verification as mandatory' +fi + +# ── 4. the bootstrap fetches the only Windows asset sigstore publishes ────── +# There has never been a cosign-windows-arm64.exe. Asking for one 404s and +# blocks Windows-on-ARM permanently — the bug this repo's sibling hit in +# tracebloc/client#734. The amd64 build under emulation is correct: cosign +# verifies a signature over bytes. +if grep -q 'cosign-windows-amd64.exe' "$INSTALLER" \ + && ! grep -q 'cosign-windows-arm64' "$INSTALLER"; then + ok 'the cosign bootstrap asks for the amd64 asset on both architectures' +else + bad 'the cosign bootstrap asks for an asset sigstore does not publish' +fi + +# ── 5. behavioural tier ───────────────────────────────────────────────────── +# pwsh is preinstalled on GitHub-hosted ubuntu runners. If it is missing we +# cannot tell whether the helpers behave, and "cannot tell" is a finding, not a +# pass (CLAUDE.md rule 3). Set ALLOW_NO_PWSH=1 to downgrade it on a dev box +# that genuinely has no pwsh — CI never sets it. +if command -v pwsh >/dev/null 2>&1; then + echo + if pwsh -NoProfile -File "$SELF_DIR/install-ps1-functions.tests.ps1"; then + ok 'behavioural tier (install-ps1-functions.tests.ps1)' + else + bad 'behavioural tier (install-ps1-functions.tests.ps1)' + fi + echo +elif [ "${ALLOW_NO_PWSH:-0}" = "1" ]; then + printf ' SKIP behavioural tier — no pwsh, ALLOW_NO_PWSH=1\n' +else + bad 'pwsh not found, so the behavioural tier could not run (ALLOW_NO_PWSH=1 to allow)' +fi + +echo "install-ps1-verify: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ]