fix(cli): verify downloaded CLI binary integrity before exec (DEVA11Y-473/474) - #37
Conversation
…-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>
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 The shape of the change is right. Verification lands before extract/
The Two things worth stating for whoever closes the tickets:
1. Windows gets no verification at all — and the description says it does · must fix
try await download(from: info.resolvedURL, to: archiveURL)
#if !os(Windows)
try await verifyArchiveChecksum(archiveURL: archiveURL, resolvedURL: info.resolvedURL)
#endifSo 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 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
bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0755 "$BINARY_PATH" && strip_quarantine
bsdtar -xvf "$BINARY_ZIP_PATH" -O > "${BINARY_PATH}.tmp" \
&& chmod 0755 "${BINARY_PATH}.tmp" \
&& mv -f "${BINARY_PATH}.tmp" "$BINARY_PATH" \
&& strip_quarantine3. Nothing will tell you when this stops being inert — or when it regressesI probed the real path: That's the failure mode I'd worry about most: SDK-assets publishes the Worth adding a positive assertion in 4. No test for a security control that currently cannot fail
5. No shape validation on the published hash · minor, latentAny 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 + if ! [[ "$expected" =~ ^[0-9a-f]{64}$ ]]; then
echo "CLI download: malformed checksum at ${sum_url}; proceeding WITHOUT verification." >&2
return 0
fiplus the equivalent guard in Swift before 6.
|
…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>
|
Thanks for the thorough pass @nmnishant-browserstack — all actionable items addressed in 1. Windows verification hole (must fix) — done. 2. Extraction truncates cached binary (cheap fix) — done. All three 3. Sidecar-availability signal — added. New advisory 4. Test for the control — added. New 5. Shape validation on the published hash — done. Non-empty bodies that aren't 6. Docs — done.
Jira resolution field — flagged for cleanup. Good catch that 473/474 show 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 |
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>
Re-review —
|
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>
…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>
…[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>
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>
…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>
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
scripts/{bash,zsh,fish}/cli.sh) —download_binarycaptures the resolved, versioned asset URL (curl -w '%{url_effective}'); a newverify_binary_integrityfetches<asset>.sha256and compares (reusing the existing_self_update_sha256helper). The invocation now aborts (download_binary || exit $?) on failure instead of falling through to exec.BrowserStackAccessibilityLint.swift) — both platforms now download to a temp file, verify, then extract. This removes the streamingcurl | bsdtarpath (extractRemoteArchive) that left no opportunity to check the payload;download(from:to:)is promoted to cross-platform. SHA-256 is computed viashasum/sha256sumto avoid pulling in CryptoKit/swift-crypto (Apple-only; this path also serves Linux).cli.sh.sha256for bash/zsh/fish so the self-update checksum gate (verify-selfupdate-checksums.yml) stays green.Behaviour (mirrors the existing self-update verification)
exit 2; Swift throws).There is currently no
.sha256published for the CLI binary —api.browserstack.com/sdk/v1/download_cli302-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>.sha256next 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 -nclean on all three scripts.verify_binary_integrityexercised 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 -parseclean; brace/paren balance verified. Fullswift build/swift test(theTests/spmconsumer smoke test) not run locally — needs the plugin build env + network.scripts/**/*.sha256sidecars passsha256sum -c.🤖 Generated with Claude Code
Review round 2 (commit
3e0ff69)Addresses @nmnishant-browserstack's review:
verifyArchiveChecksum/sha256Hexmoved out of the#if !os(Windows)block and the call site is unconditional;sha256Hexgained a Windows branch (powershell -NoProfile -Command "(Get-FileHash -Algorithm SHA256 -LiteralPath '…').Hash"). OnlyextractLocalArchivestays Unix-only (Windows already usesunzip).cli.shnow extract to${BINARY_PATH}.tmp,chmod, thenmv -finto place, so a corrupt payload can't truncate a working cached binary.-zintent is satisfied. The-z(If-Modified-Since) flag stays, butverify_binary_integrityruns against$BINARY_ZIP_PATHon 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.shis not a gap.spm.shdoes not download the binary — it delegates toswift 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".AccessDeniedXML answered 200) now fail open in both shell and Swift (empty bodies still fail closed). Added a functionalverify_binary_integritymatrix job and an advisorysidecar-availabilityprobe tospm-smoke-test.yml.