Skip to content

fix(cli): verify downloaded CLI binary integrity before exec (DEVA11Y-473/474) - #37

Merged
Crash0v3rrid3 merged 4 commits into
mainfrom
fix/DEVA11Y-473-474-binary-integrity
Aug 27, 2026
Merged

fix(cli): verify downloaded CLI binary integrity before exec (DEVA11Y-473/474)#37
Crash0v3rrid3 merged 4 commits into
mainfrom
fix/DEVA11Y-473-474-binary-integrity

Conversation

@Crash0v3rrid3

@Crash0v3rrid3 Crash0v3rrid3 commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

What & why

DEVA11Y-473 (F-001, Swift plugin) and DEVA11Y-474 (F-002, launcher scripts) flagged that the BrowserStack CLI binary was downloaded and executed with no integrity check. The plaintext-HTTP half of both findings was already fixed (HTTPS is now enforced); this PR addresses the remaining "without integrity check" half.

Both download paths now verify the archive against a server-published SHA-256 sidecar (<versioned-asset>.sha256) before it is extracted, chmod 0755'd, and run.

Changes

  • Shell (scripts/{bash,zsh,fish}/cli.sh)download_binary captures the resolved, versioned asset URL (curl -w '%{url_effective}'); a new verify_binary_integrity fetches <asset>.sha256 and compares (reusing the existing _self_update_sha256 helper). The invocation now aborts (download_binary || exit $?) on failure instead of falling through to exec.
  • Swift plugin (BrowserStackAccessibilityLint.swift) — both platforms now download to a temp file, verify, then extract. This removes the streaming curl | bsdtar path (extractRemoteArchive) that left no opportunity to check the payload; download(from:to:) is promoted to cross-platform. SHA-256 is computed via shasum/sha256sum to avoid pulling in CryptoKit/swift-crypto (Apple-only; this path also serves Linux).
  • Regenerated cli.sh.sha256 for bash/zsh/fish so the self-update checksum gate (verify-selfupdate-checksums.yml) stays green.

Behaviour (mirrors the existing self-update verification)

  • Fail CLOSED on a checksum mismatch → refuse to use the binary (shell exit 2; Swift throws).
  • Fail OPEN (warn + proceed) when no sidecar is published yet, so this is non-breaking today.
  • Download-integrity check, not an authenticity signature (the same caveat the self-update code documents).

⚠️ Requires a server-side dependency to fully close these tickets

There is currently no .sha256 published for the CLI binary — api.browserstack.com/sdk/v1/download_cli 302-redirects to a versioned immutable asset (sdk-assets.browserstack.com/binary-<os>-<arch>-<ver>.zip), and every checksum/signature path probed returns 403/404. Until the SDK-assets/API team publishes <asset>.sha256 next to each binary, this verification is inert (fail-open).

Publishing the checksum from the API/control plane (distinct infra from the CDN/S3 data plane that serves the binary) is what gives the check real anti-tamper value. This PR is the client half; the tickets can't be marked Done until the server half lands — hence draft.

Testing

  • bash -n clean on all three scripts.
  • verify_binary_integrity exercised for all four cases: empty URL → skip; missing sidecar (404) → fail-open, keep zip; wrong checksum → fail-closed, zip removed, exit 2; correct checksum → pass.
  • swiftc -parse clean; brace/paren balance verified. Full swift build/swift test (the Tests/spm consumer smoke test) not run locally — needs the plugin build env + network.
  • All 6 scripts/**/*.sha256 sidecars pass sha256sum -c.

🤖 Generated with Claude Code


Review round 2 (commit 3e0ff69)

Addresses @nmnishant-browserstack's review:

  • Windows now verifies. verifyArchiveChecksum / sha256Hex moved out of the #if !os(Windows) block and the call site is unconditional; sha256Hex gained a Windows branch (powershell -NoProfile -Command "(Get-FileHash -Algorithm SHA256 -LiteralPath '…').Hash"). Only extractLocalArchive stays Unix-only (Windows already uses unzip).
  • Atomic extraction. All three cli.sh now extract to ${BINARY_PATH}.tmp, chmod, then mv -f into place, so a corrupt payload can't truncate a working cached binary.
  • -z intent is satisfied. The -z (If-Modified-Since) flag stays, but verify_binary_integrity runs against $BINARY_ZIP_PATH on every invocation, including the 304 cache-hit path — so a poisoned cache is re-checked once sidecars exist. (No behavioural change needed; noting it so an auditor can see it against the diff.)
  • spm.sh is not a gap. spm.sh does not download the binary — it delegates to swift package plugin … scan, which routes through the Swift plugin this PR fixes. The full download surface is 3× cli.sh + the plugin; the scanner over-counted "six scripts".
  • Malformed-hash fail-open + CI. Non-64-hex sidecar bodies (e.g. an S3 AccessDenied XML answered 200) now fail open in both shell and Swift (empty bodies still fail closed). Added a functional verify_binary_integrity matrix job and an advisory sidecar-availability probe to spm-smoke-test.yml.
  • README documents the new binary-integrity gate, phrased so it reads as "verified when a checksum is published", not a guarantee that holds today.

Crash0v3rrid3 and others added 2 commits August 26, 2026 16:26
…-473/474)

F-001 (Swift plugin) and F-002 (launcher scripts) flagged that the CLI
binary was downloaded and executed with no integrity check. The plaintext-HTTP
half was already fixed (HTTPS enforced); this addresses the remaining
"without integrity check" half.

Both download paths now fetch a server-published SHA-256 sidecar
(`<versioned-asset>.sha256`) and verify the archive before it is extracted,
chmod'd and run:

- Shell (bash/zsh/fish cli.sh): download_binary captures the resolved,
  versioned asset URL (curl -w %{url_effective}), verify_binary_integrity
  checks it against the sidecar, and the invocation now aborts (exit) on
  failure instead of falling through to exec.
- Swift plugin: both platforms now download to a temp file, verify, then
  extract. This removes the streaming `curl | bsdtar` path (extractRemoteArchive)
  that left no opportunity to check the payload. SHA-256 is computed via
  shasum/sha256sum to avoid adding CryptoKit/swift-crypto (Apple-only).

Semantics mirror the existing self-update verification: fail CLOSED on a
checksum mismatch, fail OPEN (warn + proceed) when no sidecar is published,
so this is non-breaking until the SDK-assets team publishes the sidecars.

Regenerated cli.sh.sha256 for bash/zsh/fish (self-update checksum gate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses code-review findings on the integrity-verification change:

- Derive the SHA-256 sidecar from the asset scheme/host/path only, stripping
  any query string, in both the Swift plugin and the three cli.sh launchers.
  A signed/presigned asset URL (…zip?token=) would otherwise derive a
  permanently-404 sidecar (…zip?token=.sha256) and silently disable
  verification even after checksums are published. (adversarial reviewer)
- Compare checksums case-insensitively in the shell path (lowercase both
  sides), matching the Swift caseInsensitiveCompare, so an uppercase
  published hash is not a false tamper alarm. (security reviewer)
- Add curl --fail to the main binary download so an HTTP error body is never
  persisted as the archive. (correctness/security/adversarial)

Regenerated cli.sh.sha256 for bash/zsh/fish (self-update checksum gate).

Not changed (accepted, documented): fail-open-when-sidecar-absent is
intentional and non-breaking today; flipping to fail-closed is tracked as the
server-side dependency (publish <asset>.sha256) and a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Crash0v3rrid3
Crash0v3rrid3 marked this pull request as ready for review August 26, 2026 12:23
@Crash0v3rrid3
Crash0v3rrid3 requested a review from a team as a code owner August 26, 2026 12:23
@nmnishant-browserstack

Copy link
Copy Markdown

Review — binary integrity verification (DEVA11Y-473/474)

Read the diff against both tickets, the surrounding launcher/plugin logic, and the two CI gates. I also ran the new code rather than just reading it: lifted verify_binary_integrity verbatim off this branch and exercised it against a local fixture server, and probed the real download_clisdk-assets path.

The shape of the change is right. Verification lands before extract/chmod 0755/exec on both paths, semantics mirror the existing self-update gate, and removing the streaming curl | bsdtar path is the correct call — that pipe structurally could not be verified. All eight cases the description claims behave exactly as claimed:

case rc zip kept
empty resolved URL 0 (skip) yes
good sidecar 0 yes
good + ?token= query 0 yes
missing sidecar (404) 0 (fail open) yes
uppercase published hash 0 yes
wrong checksum 2 removed
empty sidecar (200) 2 removed

The RETURN trap leaves zero bs-a11y-clisum.* temp files behind, all six scripts/**/*.sha256 sidecars pass sha256sum -c, swiftc -parse is clean, and verify_binary_integrity/download_binary are byte-identical across bash/zsh/fish (I hashed the function bodies).

Two things worth stating for whoever closes the tickets:

  • Coverage is complete despite the ticket text. DEVA11Y-474 says "six shell installer scripts (bash/zsh/fish variants of cli.sh and spm.sh)". spm.sh doesn't download the binary — it delegates to swift package plugin … scan, which routes through the Swift plugin this PR also fixes. 3× cli.sh + the plugin is the right surface; the scanner over-counted.
  • An unintended bonus fix. On main, a failed download still ran bsdtar … -O > "$BINARY_PATH", truncating a previously-good cached binary to 0 bytes. download_binary || exit $? now aborts first. Verified: main → cached binary 0 bytes, this branch → intact.

1. Windows gets no verification at all — and the description says it does · must fix

verifyArchiveChecksum (346) and sha256Hex (390) sit inside the #if !os(Windows) block that opens at line 337 and closes at 452, and the call site at 256–260 is correspondingly guarded:

try await download(from: info.resolvedURL, to: archiveURL)
#if !os(Windows)
try await verifyArchiveChecksum(archiveURL: archiveURL, resolvedURL: info.resolvedURL)
#endif

So on Windows the archive is downloaded, extracted, made executable and run unverified — precisely the F-001 condition. The description says "both platforms now download to a temp file, verify, then extract"; for Windows the only actual change is the comment (it already downloaded to a temp file).

Windows already shells out to PowerShell for unzip, so the fix is cheap — move both functions out of the !os(Windows) block and add a Windows branch to sha256Hex:

powershell -NoProfile -Command "(Get-FileHash -Algorithm SHA256 -LiteralPath '<path>').Hash"

If Windows isn't a supported target for this plugin, that's a fine answer too — but then say so and drop the branch. Either way the description shouldn't claim coverage that isn't there.

2. Extraction still truncates a working cached binary · cheap fix

scripts/*/cli.sh:236 is unchanged:

bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0755 "$BINARY_PATH" && strip_quarantine

> truncates $BINARY_PATH before bsdtar is known to have succeeded. You fixed the network-failure route into this; the corrupt-payload route is still open, and it's the live one today — no sidecars are published, so verification fails open and hands a corrupt zip straight to bsdtar. Reproduced: download succeeds, payload corrupt, no sidecar → previously-good cached binary ends at 0 bytes.

bsdtar -xvf "$BINARY_ZIP_PATH" -O > "${BINARY_PATH}.tmp" \
  && chmod 0755 "${BINARY_PATH}.tmp" \
  && mv -f "${BINARY_PATH}.tmp" "$BINARY_PATH" \
  && strip_quarantine

3. Nothing will tell you when this stops being inert — or when it regresses

I probed the real path: api.browserstack.com/sdk/v1/download_cli?os=macos&os_arch=arm64sdk-assets.browserstack.com/binary-macos-arm64-1.52.0.zip, and that bucket returns 403 + S3 AccessDenied XML for both missing and non-public objects — a missing key and a published-but-wrong-ACL key are indistinguishable from the client.

That's the failure mode I'd worry about most: SDK-assets publishes the .sha256 objects, forgets public-read, every client keeps failing open forever, and the only evidence is a >&2 line buried in Xcode build-phase output or a Diagnostics.remark. The tickets get closed, and nothing is actually verified.

Worth adding a positive assertion in spm-smoke-test.yml: resolve the current asset URL, assert the sidecar returns 200 with a 64-hex body. ::warning today, hard failure once the server half lands. That's what turns "we shipped the client half" into "verification demonstrably runs".

4. No test for a security control that currently cannot fail

scripts-lint is bash -n only. Because verification is fail-open today, a change that breaks verify_binary_integrity outright produces zero red signal — the regression surfaces the day the sidecars go live, which is the worst possible day. The manual matrix in the description is the right set of cases; it took ~30 lines of bash and a python3 -m http.server fixture to automate the whole thing. Worth committing as a CI job.

5. No shape validation on the published hash · minor, latent

Any 2xx body's first whitespace-delimited token becomes the expected digest. Latent today given the 403 above — but if that bucket ever sits behind a CDN or S3 website endpoint that answers 200 with an XML/HTML error page, the first token of that page becomes the "expected hash" and every client hard-fails (exit 2, cached zip deleted) on every invocation. I reproduced exactly that with a 200 + AccessDenied XML body. One line restores the intended fail-open:

if ! [[ "$expected" =~ ^[0-9a-f]{64}$ ]]; then
  echo "CLI download: malformed checksum at ${sum_url}; proceeding WITHOUT verification." >&2
  return 0
fi

plus the equivalent guard in Swift before caseInsensitiveCompare.

6. -z reads as an unaddressed remediation item · description only

DEVA11Y-474's remediation says: "Remove the -z (If-Modified-Since) conditional flag — it silently skips verification when the cached file is current, creating a window where a previously poisoned cache entry is never re-checked." -z is still there.

As written the PR satisfies that intentverify_binary_integrity runs against $BINARY_ZIP_PATH on every invocation, including the 304 cache-hit path, so a poisoned cache does get re-checked once sidecars exist. But nobody auditing the ticket against this diff can see that. One line in the description saves the next retest; both tickets already carry ai-retest-status:not-fixed.


On the server half

Agree this can't close the tickets alone, and agree with your instinct in the description: a sidecar fetched from the same CDN the artifact came from detects corruption but buys little against an attacker who can write to that CDN — they can replace both. Having api.browserstack.com hand back the digest (a header on the redirect, or a small JSON endpoint) is the version with real anti-tamper value, since it's separate infrastructure. The ticket's own remediation text only asks for "same HTTPS origin", so what's here meets the bar as written — but the control-plane version is worth arguing for while the server work is still unscoped.

Is there a ticket for it? The description references the dependency but links nothing. That's the single highest-leverage follow-up here, and without it this code stays dormant indefinitely.

Also: DEVA11Y-473 and -474 both currently show resolution: Done while status is Dev in Progress — worth clearing, given the description explicitly says they can't be Done until the server half lands.

Docs

README.md:214 documents the self-update checksum gate but not this new one. Worth a sentence — phrased so it's clear the binary is verified when a checksum is published, so nobody reads it as a guarantee that holds today.


Verdict: approve once (1) is resolved — Windows is a genuine hole in the fix, not a nit — and (2), which is three lines. (3) and (4) are what decide whether this ends up delivering security value or just the appearance of it; I'd push to land them before the tickets close rather than after. Nice work on the ?token= stripping and the case-insensitive compare — both were real, and both were easy to miss.

🤖 Reviewed with Claude Code

…ract, CI gates (DEVA11Y-473/474)

Resolves Nishant Maurya's review on PR #37.

- Windows was left unverified: verifyArchiveChecksum/sha256Hex were inside
  `#if !os(Windows)` and the call site was guarded. Move both out of the guard,
  make the call site unconditional, and add a Windows sha256Hex branch using
  PowerShell Get-FileHash. Only extractLocalArchive stays Unix-only (Windows uses unzip).
- Extraction no longer truncates a good cached binary: bsdtar now writes to
  "${BINARY_PATH}.tmp" and is atomically `mv -f`'d into place, so a corrupt payload
  (the live case today) can't zero out a previously-good binary. All 3 shells.
- Hash shape validation: a non-empty sidecar body that isn't a 64-hex digest (e.g. an
  S3 AccessDenied XML answered 200) now fails OPEN instead of hard-failing every client;
  an empty body still fails CLOSED. Applied in all 3 shells and the Swift plugin.
- CI: add a functional verify_binary_integrity matrix job (fail-open/closed, python
  fixture) so a broken control produces red signal even while verification is inert,
  and an advisory sidecar-availability probe (::warning until the server half ships).
- Regenerate cli.sh.sha256 self-update sidecars; README documents the new binary gate.

Server-side dependency (SDK-assets publishing <asset>.sha256) still blocks closing the
tickets; keep the PR open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Crash0v3rrid3

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough pass @nmnishant-browserstack — all actionable items addressed in 3e0ff69.

1. Windows verification hole (must fix) — done. verifyArchiveChecksum and sha256Hex moved out of the #if !os(Windows) block; the call site is now unconditional. sha256Hex has a Windows branch using powershell -NoProfile -Command "(Get-FileHash -Algorithm SHA256 -LiteralPath '…').Hash". Only extractLocalArchive stays Unix-only (Windows path already uses unzip). swiftc -parse clean, #if/#endif balanced.

2. Extraction truncates cached binary (cheap fix) — done. All three cli.sh now extract to ${BINARY_PATH}.tmp, chmod 0755, then mv -f into place before strip_quarantine. A corrupt payload can no longer zero out a previously-good cached binary.

3. Sidecar-availability signal — added. New advisory sidecar-availability job in spm-smoke-test.yml: resolves the real (unauthenticated) download_cli redirect, strips the query, and asserts <asset>.sha256 returns 200 with a 64-hex body. ::warning today, with a marked TODO to flip to exit 1 once the server half ships — so a published-but-wrong-ACL sidecar (your 403-indistinguishability concern) surfaces instead of silently failing open forever.

4. Test for the control — added. New verify-integrity-fn job runs the full matrix against a python3 -m http.server fixture: empty-URL skip (rc0), good sidecar (rc0), good + ?token= (rc0), missing/404 (fail-open rc0), uppercase hash (rc0), wrong checksum (rc2 + zip removed), empty sidecar 200 (rc2 + removed), and malformed-200 (fail-open). All 8 pass locally.

5. Shape validation on the published hash — done. Non-empty bodies that aren't ^[0-9a-f]{64}$ (e.g. an S3 AccessDenied XML answered 200) now fail open with a stderr note, in all three shells and in Swift (guard before caseInsensitiveCompare). Empty bodies still fail closed, per the matrix.

6. -z reads as unaddressed — clarified, no code change. verify_binary_integrity runs against $BINARY_ZIP_PATH on every invocation including the 304 cache-hit path, so a poisoned cache is re-checked once sidecars exist — the remediation intent is met. Noted in the PR description so it's visible against the diff.

Docs — done. README.md (by the self-update gate) now documents the binary-integrity check, phrased so it's clear the binary is verified when a checksum is published, not a guarantee that holds today.

spm.sh over-count — confirmed, agree. spm.sh delegates to the Swift plugin rather than downloading the binary, so 3× cli.sh + the plugin is the complete surface.

Jira resolution field — flagged for cleanup. Good catch that 473/474 show resolution: Done while status is Dev in Progress; that's a Jira-side inconsistency (not touched by this PR). Leaving the tickets open and flagging the resolution field to be cleared to Unresolved — they can't be Done until the server half lands.

Server half — agreed, and it's the blocker. Fully agree the control-plane (api.browserstack.com) version has the real anti-tamper value over a same-origin CDN sidecar. Tracking the dependency on SDK-assets publishing <asset>.sha256; this PR is the client half and stays open until that lands.

The functional-matrix and sidecar-availability jobs both intentionally
trigger non-zero exits (an intended checksum mismatch; the expected 403
until the server half ships). GitHub's default `shell: bash -e {0}`
aborted both before their real gate ran — `set -uo pipefail` doesn't
cancel the harness `-e`. Passed locally only because they were run as
plain `bash script` (no -e).

- verify-integrity-fn: `out=$(...) && rc=0 || rc=$?` so the intended
  rc=2 "wrong checksum" case no longer aborts the step before the
  `[ "$fail" -eq 0 ]` gate.
- sidecar-availability: `|| true` on the awk|tr pipe so a missing
  body.txt (expected on the 403) doesn't turn the advisory ::warning
  into a red check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@nmnishant-browserstack

Copy link
Copy Markdown

Re-review — 3e0ff69 + 66249fe

Re-reviewed clean against main, then checked each claim in your reply by re-running the original repros rather than reading the diff. Everything you listed is genuinely done. Details worth recording:

1. Windows hole — closed. verifyArchiveChecksum (344) and sha256Hex (397) are now outside every conditional; the call site (259) is unconditional; extractLocalArchive correctly stays #if !os(Windows). #if/#endif balance 11/11, swiftc -parse clean.

2. Cached-binary truncation — fixed. Re-ran the exact repro (download succeeds, payload corrupt, no sidecar → fail open). Before: cached binary → 0 bytes. Now: 24 bytes before and after, download_binary returns 1, scan skipped. Confirmed.

5. Shape validation — correct in both directions. Empty body still fails closed (rc2 + zip removed); malformed 200 fails open (rc0 + zip kept). That's the right split.

4. The new test is a real test — I mutation-tested it. This is the part I most wanted to check, since a matrix that passes unconditionally is worse than no matrix. Lifting the function off the branch with awk rather than re-implementing it is the right call. Results:

A) real branch code   → PASS=8 FAIL=0, exit 0
B) control gutted     → PASS=6 FAIL=2, exit 1
   FAIL | wrong checksum     | got rc=0 zip=kept want rc=2 zip=gone
   FAIL | empty sidecar (200)| got rc=0 zip=kept want rc=2 zip=gone

It goes red when the control is removed. And your && rc=0 || rc=$? note in 66249fe is right — under GHA's injected bash -e, ; rc=$? would have aborted the step at the first fail-closed case and the [ "$fail" -eq 0 ] gate would never have run. Good catch on your own patch.

Also re-verified: all three sidecars regenerated (sha256sum -c OK), the three shell copies still byte-identical, bash -n clean on all three.


Everything below is minor. None of it blocks.

A. sidecar-availability reports HTTP 403000 · low

Confirmed live against the real endpoint:

code=$(curl -fsSL -o body.txt -w '%{http_code}' "$sum_url" || echo 000)
→ code='403000'

curl writes 403 via -w even under --fail, then || echo 000 appends. The success path is unaffected (code is exactly 200, || never fires), so the flip-to-exit 1 logic is sound — but the warning string is the thing a human reads to decide whether the server half has shipped, and it'll say 403000. Simplest fix is to drop -f, since you check code explicitly anyway: curl then exits 0, code=403, and body.txt actually gets created (right now it doesn't, which is why the awk || true guard is needed at all).

B. The matrix only covers scripts/bash/cli.sh · low

SRC=scripts/bash/cli.sh. The zsh and fish copies are byte-identical today — I hashed the function bodies — but nothing enforces that. verify-selfupdate-checksums checks each file against its own sidecar, not against each other, so a hand-copy drift in the zsh verify_binary_integrity ships with a green board. Either loop the matrix over all three SRCs, or assert the three function bodies hash equal.

C. Windows verification is implemented but never compiled · low

No workflow runs on Windows (macos-14 + ubuntu-latest only), and Package.swift declares no platforms. That branch is now load-bearing: once sidecars publish, a sha256Hex failure throws → verifyArchiveChecksum throws → hard build failure. Two things I'd want exercised on a real Windows runner before the server half lands:

  • URL(fileURLWithPath: "powershell.exe") doesn't do PATH resolution — it builds a file URL relative to cwd. The existing unzip uses the same pattern ("powershell"), so this is consistent — but consistent with code that has also never run isn't much reassurance.
  • Get-FileHash(...).Hash returns uppercase; the doc comment on sha256Hex says "lowercase hex string". Harmless because of caseInsensitiveCompare, but the comment is now wrong on one of the three platforms it describes.

Worth an explicit call either way: add a Windows build job, or say Windows is best-effort.

D. ${BINARY_PATH}.tmp — fixed name, not cleaned up · nit

Verified a 0-byte .tmp is left behind when bsdtar fails. It's also a fixed path, and cli.sh has no locking at all (spm.sh has one; cli.sh has none), so two concurrent scans race on the same temp file. Strictly better than the old > "$BINARY_PATH" — the mv makes the final publish atomic — so this is residual, not a regression. mktemp "${BINARY_PATH}.XXXXXX" plus a trap closes both.

E. Still no ticket for the server half · process

The flip-to-red is guarded by TODO(DEVA11Y-473/474 server half) in a YAML comment. Your reply says "tracking the dependency" but there's no ID here or in the description. A TODO in a workflow file isn't a tracked dependency, and this one is the only thing standing between "we shipped a security control" and "we shipped a security control that runs" — the sidecar-availability job will sit warning-yellow indefinitely and nobody will be paged about it. Please put the ticket ID in that TODO and in the description before merge; it's a 30-second action and it's the highest-leverage item left on this PR.


Verdict

Approve. Both must-fix items from the last round are genuinely fixed — verified by re-running the repros, not by reading the reply — and the CI gate is a real gate, which I confirmed by mutation-testing it. The control now does what it says on macOS, Linux and (modulo C) Windows, fails open in exactly the cases it should, fails closed in exactly the cases it should, and there's a test that goes red if that stops being true.

A–D are non-blocking; fold them into a follow-up push or a separate PR, your call. E I'd want before merge — not because the code is wrong, but because without that ticket this lands as dormant code with no owner for waking it up.

Nice work on the turnaround, and on catching the bash -e interaction yourself.

Note: this is a review comment, not a formal GitHub approval — say the word and I'll submit it as one to clear REVIEW_REQUIRED.

🤖 Reviewed with Claude Code

@Crash0v3rrid3
Crash0v3rrid3 merged commit 1ce64b6 into main Aug 27, 2026
10 checks passed
maunilm added a commit that referenced this pull request Aug 27, 2026
Resolves the conflict introduced by #37 (DEVA11Y-473/474, "verify downloaded CLI
binary integrity before exec"), which landed on main after the previous merge and
rewrote the same download/extract paths this branch guards.

Conflicts (7): the plugin, the three cli.sh launchers, and their three sidecars.

Plugin — took main's side wholesale. #37 deleted extractRemoteArchive entirely,
replacing the streaming `curl | bsdtar` with download-to-file ->
verifyArchiveChecksum -> extractLocalArchive (or unzip on Windows), precisely so
the payload can be verified before it is extracted and executed. This branch's
watchdog on that streaming path therefore no longer has a path to guard, so the
59-line block was dropped rather than reinstated.

The DEVA11Y-484 guard is unaffected in substance and is now simpler: the watchdog
already sits on extractLocalArchive, which after #37 is the single non-Windows
extraction path for both remote and local archives. The locateExecutable
10_000-entry cap is untouched. Windows' unzip path remains unguarded, as before
(tracked on DEVA11Y-761).

Launchers — combined both changes rather than picking a side:
* Kept #37's `curl -fR -z ... -w '%{url_effective}'` with its `return 1`,
  verify_binary_integrity with its `return $?` passthrough, and the
  stage-to-.tmp / chmod / `mv -f` / strip_quarantine publish chain.
* Moved this branch's `head -c "$max_decompressed"` guard onto that staged path
  (`${BINARY_PATH}.tmp`) instead of `$BINARY_PATH`. This matters: writing the cap
  directly to $BINARY_PATH would reintroduce exactly the bug #37 fixed — a
  rejected payload truncating a previously-good cached binary. The rejection path
  now removes only the .tmp file.
* Switched the guard's failure from `exit 1` to `return 1`, matching #37's
  contract (the call site is `download_binary || exit $?`, which also preserves
  the distinct exit 2 for an integrity mismatch). This removes the behaviour
  divergence the previous merge had introduced.

Sidecars regenerated for all three launchers.

Verification on this merge commit:
* 27/27 assertions across bash/zsh/fish against the live download endpoint: real
  download exits 0 through #37's integrity check, binary 69,391,104 B at perms
  0755, .tmp cleaned up after publish, re-run byte-identical, corrupt payload
  rejected — and, critically, the previously-cached binary SURVIVES a rejected
  payload with an unchanged sha256, confirming #37's protection is intact rather
  than undone by the cap.
* swiftc -typecheck -parse-as-library against the PackagePlugin API: clean.
* bash -n clean on all three launchers.
* All six sidecars verify; self-update's own comparison (awk first field vs
  shasum -a 256) matches for all three.
* Confirmed no #37 feature lost: verify_binary_integrity, mv -f, url_effective
  and `curl -fR -z` all present at main's counts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
maunilm added a commit that referenced this pull request Aug 27, 2026
…11Y-484]

Two comments this branch added still described the streaming curl | bsdtar path
that #37 (DEVA11Y-473/474) deleted, so they pointed at code that no longer exists:

* the extractLocalArchive call-site said "same rationale as the remote path"
* the EXTRACTION GUARD block's rationale was framed around capping the
  "curl→bsdtar pipe"

Reworded to describe what the guard actually attaches to now, and stated
explicitly that extractLocalArchive is the single non-Windows extraction path
since #37 (download to file, checksum-verify, then extract) and that Windows'
unzip path has no streaming guard.

Comment-only; no behaviour change. Guard block re-verified against the real CLI
archive after the edit: real 200 MB cap does not flag (termStatus 0, 69,391,104 B,
1 entry); a 5 MB cap flags and SIGTERMs bsdtar mid-stream (termStatus 15, disk
bounded to 36 MB of 66 MB); maxEntries=0 flags on entry count. swiftc
-typecheck -parse-as-library clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
maunilm added a commit that referenced this pull request Aug 27, 2026
…[DEVA11Y-484]

Addresses @Crash0v3rrid3's review. The two P1/P2 "described but absent" findings
were caused by a stale PR description (the harness and compressed cap were
descoped to DEVA11Y-761 without updating it); the description is corrected
separately. This commit lands the code changes.

P2 — compressed-download cap reinstated. The reviewer's threat-model argument is
right: without a wire cap, a multi-GB *compressed* payload from an
attacker-controlled URL exhausts disk before the checksum or the decompression
guard ever run, walking around the whole fix.

* Launchers: `curl --max-filesize 104857600`, plus an explicit post-download size
  check because curl documents --max-filesize as a no-op when the length is
  unknown (chunked). Verified against the live endpoint: with a 1 MB cap curl
  aborts non-zero with nothing written to disk; with the real 100 MB cap the
  38 MB archive passes.
* Plugin: `maxCompressedBytes = 100 MB`, checked against both
  `response.expectedContentLength` and the downloaded file's actual size, with
  the temp file removed on rejection.

  LIMITATION, stated in the code rather than papered over:
  URLSession.download(from:) has no byte-level hook, so these reject the archive
  *after* the transfer rather than aborting mid-stream. They stop an oversized
  archive being verified, extracted, published or executed, but do NOT bound peak
  temporary disk during the transfer. Doing that needs a
  URLSessionDownloadDelegate cancelling in didWriteData — deliberately left to
  DEVA11Y-761 rather than rewriting this shared download path inside a security
  fix I cannot exercise end-to-end without credentials. The launchers do abort
  during transfer.

P3 fixes:
* `private` on ExtractionLimitState, extractionFootprint, footprintExceeded and
  startExtractionWatchdog, matching every other helper in the file.
* Poll-interval doc drift: the docstring now states the actual 50 ms instead of
  reasoning about an unstated interval.
* extractionFootprint now fails CLOSED on a nil enumerator (Int64.max/Int.max)
  instead of (0, 0), which silently disabled the guard for that poll; and the
  deliberate `.skipsHiddenFiles` asymmetry with locateExecutable is documented
  rather than accidental.
* pipefail is saved and restored instead of cleared unconditionally.
* ${BINARY_PATH}.tmp is cleaned up if chmod/mv fails, not only on size rejection.
* Pinned the load-bearing libarchive containment assumption: `bsdtar -x` without
  `-P` keeps writes inside the polled -C directory; adding -P would let them
  escape and the footprint poll would measure nothing.

Not addressed here (left for review discussion): the launchers still have no
entry-count equivalent to the plugin's maxArchiveEntries — in `-O` mode a
millions-of-empty-entries archive streams ~0 bytes so `head -c` never fires. Real
gap, no cheap mechanism in `-O` mode.

Verification: 27/27 assertions across bash/zsh/fish against the live endpoint
(real download through #37's integrity check, .tmp cleanup, byte-identical
re-run, corrupt payload rejected, cached binary survives rejection); pipefail
save/restore verified in both directions; swiftc -typecheck clean; bash -n clean;
all six sidecars verify.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Crash0v3rrid3 added a commit that referenced this pull request Aug 28, 2026
Resolves Nishant's second review on PR #38:

1. cooldown is inert for the github-actions Dependabot ecosystem — kept only
   to satisfy Semgrep's dependabot-missing-cooldown rule; documented as inert
   in dependabot.yml (no real release-age protection for action bumps).
2. Semgrep image drift workflow: made the intended-failure branches reachable
   under GHA's injected `bash -e` (|| true on the grep/token/latest command
   substitutions + explicit empty-token guard), same class of fix as #37
   66249fe. Verified: happy/stale/pin-removed/registry-down all annotate+exit.
3. Reworked the alarm from "differs from :latest" (red most weeks → muted) to
   "pinned image older than 45 days" (actionable). Bumped the Semgrep image
   pin to the current :latest digest so it lands green:
   f682953… -> f1f7b71861c7b28b6e0f661225a2c4f58a484f5d0f182465c6d6b3b22f972ade
   (created 2026-08-20, ~8 days old).
4. Removed the job-level `if: github.actor != 'dependabot[bot]'` guard on
   Semgrep.yml and scoped it to only the privileged upload-sarif step, so
   Dependabot PRs that bump the action pins living in Semgrep.yml still get
   scanned; only the security-events:write SARIF upload is skipped for them.

Also documented the scheduled-workflow ops caveat (60-day inactivity disable;
failed runs notify only the last cron editor) in the drift workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Crash0v3rrid3 added a commit that referenced this pull request Aug 28, 2026
…A11Y-476] (#38)

* chore(deps): add Dependabot to rotate the pinned Semgrep CI image (DEVA11Y-476)

The Semgrep workflow container image is already pinned by immutable @sha256
digest (PR #15), which is the DEVA11Y-476 chain-breaker for the C-001 chain
(DEVA11Y-485: CI image compromise -> mutable main -> SPM plugin RCE). A static
digest, however, never receives upstream security patches — the current pin
already lags returntocorp/semgrep:latest.

Add .github/dependabot.yml with a `docker` ecosystem entry over
/.github/workflows so Dependabot bumps the pinned digest to the newest build on
a weekly cadence, keeping immutability without freezing the image.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(ci): add cooldown to Dependabot Semgrep image updates

Adds a release-age cooldown so Dependabot does not adopt a freshly-published
returntocorp/semgrep digest immediately — a poisoned-image would otherwise be
pulled in within a day of publication, the exact window digest-pinning is meant
to defend (DEVA11Y-476 / chain DEVA11Y-485). Also resolves the semgrep/ci
finding flagging the update config for lacking a minimum release age.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(ci): rotate GitHub Actions via Dependabot + drift-alarm the Semgrep image

Dependabot's `docker` ecosystem cannot discover a `container:` image ref in a
workflow file (dependabot-core#5819), so the previous `docker`/`/.github/workflows`
config would never rotate the pin and would fail a weekly Dependabot job. Pivot:

- dependabot.yml: replace the unusable `docker` entry with a `github-actions`
  entry (directory "/", weekly, cooldown default-days 7) — real, supported
  rotation for the action `uses:` pins here, several of which are 3 years stale.
  Drop the undefined `security` label and the redundant open-pull-requests-limit.
  Cooldown retained (also clears the semgrep/ci dependabot-missing-cooldown rule).
- Add semgrep-image-pin-drift.yml: read-only scheduled/dispatch job that alarms
  (fails) when the pinned returntocorp/semgrep@sha256 digest drifts from :latest,
  keeping a human in the loop on the image bump (better for the C-001 threat model
  than auto-adopting latest).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(ci): address PR#38 round-2 review (cooldown/drift/pin/guard)

Resolves Nishant's second review on PR #38:

1. cooldown is inert for the github-actions Dependabot ecosystem — kept only
   to satisfy Semgrep's dependabot-missing-cooldown rule; documented as inert
   in dependabot.yml (no real release-age protection for action bumps).
2. Semgrep image drift workflow: made the intended-failure branches reachable
   under GHA's injected `bash -e` (|| true on the grep/token/latest command
   substitutions + explicit empty-token guard), same class of fix as #37
   66249fe. Verified: happy/stale/pin-removed/registry-down all annotate+exit.
3. Reworked the alarm from "differs from :latest" (red most weeks → muted) to
   "pinned image older than 45 days" (actionable). Bumped the Semgrep image
   pin to the current :latest digest so it lands green:
   f682953… -> f1f7b71861c7b28b6e0f661225a2c4f58a484f5d0f182465c6d6b3b22f972ade
   (created 2026-08-20, ~8 days old).
4. Removed the job-level `if: github.actor != 'dependabot[bot]'` guard on
   Semgrep.yml and scoped it to only the privileged upload-sarif step, so
   Dependabot PRs that bump the action pins living in Semgrep.yml still get
   scanned; only the security-events:write SARIF upload is skipped for them.

Also documented the scheduled-workflow ops caveat (60-day inactivity disable;
failed runs notify only the last cron editor) in the drift workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants