fix(security): cap bsdtar extraction size to prevent decompression bomb DoS [DEVA11Y-484] - #25
fix(security): cap bsdtar extraction size to prevent decompression bomb DoS [DEVA11Y-484]#25maunilm wants to merge 19 commits into
Conversation
…mb DoS [DEVA11Y-484] CWE-400 / OWASP A05. bsdtar was invoked with no decompressed-size or entry-count limit in both the Swift SPM plugin and the bash/zsh/fish CLI wrappers, so an attacker who can influence the download URL (the HTTPS-only --download-url / BROWSERSTACK_A11Y_CLI_DOWNLOAD_URL override, or TLS interception) could serve a decompression bomb that exhausts the developer/CI disk. Swift plugin (BrowserStackAccessibilityLint.swift): - curl now passes --max-filesize (100 MB) to cap the compressed download. - A background watchdog terminates bsdtar once the *decompressed* footprint on disk exceeds 200 MB (a pipe-level cap would only bound compressed bytes, which is useless against a bomb). Applied to both the remote and local extraction paths. - locateExecutable now bounds enumeration at 10,000 entries. Shell wrappers (bash/zsh/fish cli.sh): - curl --max-filesize caps the compressed download. - bsdtar output is piped through `head -c` (200 MB) with pipefail so an oversized archive aborts instead of filling the disk. Real CLI artifact is ~34 MB compressed / ~64 MB decompressed, so the caps leave ~3x headroom and do not affect legitimate downloads. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on guard [DEVA11Y-484] Adds local integration tests (no mocks) that exercise the decompression-bomb guards against real curl/bsdtar/head and the real Swift watchdog, plus hardens the guard itself based on what the tests surfaced. Guard hardening (Plugins/BrowserStackAccessibilityLint.swift): - The watchdog now also terminates bsdtar on an entry-count ceiling, closing the "millions of tiny files" bomb that stays small on disk (previously only locateExecutable caught it, after the fact). - Added a post-extraction footprint check so detection is deterministic on fast disks: a bomb that finishes decompressing within a single 200ms poll interval is now caught and cleaned up rather than slipping past the live watchdog. - Refactored the guard into a self-contained, marked block of free functions so it can be mirrored and drift-checked. Tests (scripts/test/, run via run_tests.sh): - Shell: extracts the REAL download_binary from bash/zsh/fish verbatim and runs it against a local server (only the hardcoded URL is redirected, via a curl shim). - Swift: a mirror harness compiles the guard block verbatim and drives real curl/bsdtar; check_drift.sh fails CI if the mirror diverges from the plugin (SwiftPM command plugins can't be imported by a test target). - Scenarios: legit (downloads/extracts/runs), 400MB bomb, 20k-entry bomb, oversized (>100MB) download, corrupt archive, multi-file, missing URL. - Fixtures are bounded (≤400MB, gitignored) and bomb tests use a small cap, so a regressed guard can never exhaust the disk. Full run ~9s, disk usage flat. - CI: .github/workflows/extraction-guard-tests.yml runs the suite on macOS for PRs touching the download/extract path. 53/53 assertions green locally; real production artifact (34MB/64MB) verified to pass through the new extraction path and run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… live termination [DEVA11Y-484] Addresses gaps found by stress-testing the guard rather than just asserting the happy path: - Measured overshoot: at a 200ms poll, bsdtar could write ~270-380MB past the cap on a fast disk before the watchdog tripped (the cap was far softer than the "200 MB" message implied). Tightened the poll to 50ms — a 10MB cap now peaks at ~34MB and a 2GB bomb is killed at ~224MB. Documented the cap as an explicit SOFT ceiling whose purpose is preventing disk *exhaustion*, not exact byte enforcement. - Windows Expand-Archive path was completely unguarded. Added a platform-agnostic post-extraction footprint backstop in the common path (typecheckable on macOS) so Windows rejects + cleans up a bomb before the binary is used. - Strengthened tests to assert the LIVE watchdog fires (bsdtar SIGTERM, status 15) and that peak disk stays bounded below the bomb size — previously the bomb tests would have passed even if only the post-extraction check worked (which would let a multi-GB bomb fill the disk). - Added test_large_bomb.sh (opt-in via DEVA11Y_DEEP=1): proves a 2GB bomb is bounded to ~224MB. Kept out of the default CI run to keep it fast/bounded. - README now documents the real limitations: soft cap + overshoot, Windows is post-hoc only, the Swift suite tests a mirror (not the compiled plugin) with the call sites typecheck-only, and locateExecutable's cap is defense-in-depth. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings the branch up to date with main (e4bb5dc) to clear the merge conflict and regenerates the self-update checksum sidecars. Conflicts (4) and how they were resolved: * Plugins/.../BrowserStackAccessibilityLint.swift — main (#32, DEVA11Y-482) refactored prepareArtifact to extract into a staging directory and atomically publish it to the version directory. This branch's decompression-bomb backstop was written against the old flow and checked versionDirectory after extraction. Kept main's staging/publish architecture and moved the backstop to check stagingDirectory *before* publishVersionDirectory, cleaning up staging on rejection. This is stricter than the original: a rejected archive now never becomes a visible version directory at all. * scripts/{bash,zsh,fish}/cli.sh — main (#36, DEVA11Y-752) added strip_quarantine and tightened chmod 0775 -> 0755; this branch added the compressed/decompressed size caps. Both were kept: curl --max-filesize plus the bsdtar | head -c guard and the size assertion, then main's chmod 0755 and strip_quarantine. main's chained `&&` is unnecessary here because the size guard exits non-zero on failure, so reaching the chmod means extraction succeeded. Took main's 0755 (dropping group-write) rather than reverting its hardening. Test fixes required by the merge: * test_shell_extraction.sh asserted chmod 775; updated to 755 to match main. * load_download_binary awk-extracts only download_binary() and sources it in isolation, so the newly-called strip_quarantine was undefined and every success-path case exited 127 after an otherwise correct extraction. The loader now extracts strip_quarantine too, with a faithfulness check for it. Verification on this merge commit: * scripts/test/run_tests.sh — ALL GREEN: drift check passed, shell wrappers 36/36, Swift plugin guard 19/19 (baseline pre-merge was also 36/36 and 19/19). * Merged plugin typechecks clean (swiftc -typecheck -parse-as-library against the PackagePlugin API), matching main's baseline. * All six scripts/*/{cli,spm}.sh.sha256 sidecars verify with sha256sum -c; the three cli.sh sidecars were regenerated (they failed before this commit, which would have broken the verify-selfupdate-checksums gate added in #30). * bash -n clean on all three wrappers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… [DEVA11Y-484] The verify-selfupdate-checksums gate (added on main in #30, DEVA11Y-475) globs `scripts/**/*.sh` with globstar and requires a committed `<script>.sha256` sidecar for every match. This branch added seven support scripts under scripts/test/ — run_tests.sh, check_drift.sh, make_fixtures.sh, lib/assert.sh, test_{shell,swift}_extraction.sh, test_large_bomb.sh — none of which has a sidecar, so the gate failed as soon as main was merged in. Generating sidecars for them would be wrong: that workflow exists because self-update *fetches each launcher script from main and verifies it against its sidecar*. These test scripts are never fetched or verified at runtime, so a sidecar would assert a protection that does not exist, and every future edit to a test script would need a checksum regen. Moving the suite under tests/ fixes it at the source and needs no change to the security workflow: scripts/ once again contains only the six self-updating launchers (bash/zsh/fish x cli.sh,spm.sh), all of which have matching sidecars. It also matches the convention main established in #35, which put its own harnesses (and tests/spm/scripts/run-a11y-scan.sh) under tests/. The move is path-transparent: every script resolves paths via HERE="$(dirname "${BASH_SOURCE[0]}")" and REPO="$HERE/../..", and tests/extraction-guard/../.. is still the repo root, so no script body changed. Updated references: * .github/workflows/extraction-guard-tests.yml — path filter and the run: line * Plugins/.../BrowserStackAccessibilityLint.swift — drift-mirror doc comments * swift-harness/Sources/ExtractionHarness/Guard.swift — same doc comments * tests/extraction-guard/README.md — invocation path * tests/README.md — added an index row for the suite, labelled as a security regression suite rather than a consumer-project harness Verification after the move: * bash tests/extraction-guard/run_tests.sh — ALL GREEN: drift check passed, shell wrappers 36/36, Swift plugin guard 19/19. * verify-selfupdate-checksums logic replicated locally: scripts/**/*.sh now matches exactly the six launchers, every sidecar present and matching — gate passes with the workflow file unmodified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PR had grown to +1044/-7 across 18 files for a ticket sized XS. Only 216 of those lines were the production fix; the rest was test infrastructure and CI the ticket never asked for. Narrowed to exactly what DEVA11Y-484's Remediation section specifies, so the security change is reviewable on its own. Kept — the ticket's three requirements: 1. Swift streaming guard, 200 MB decompressed. "interpose a byte-counting wrapper ... that calls Process.terminate() on bsdtar if a threshold is crossed" — implemented as startExtractionWatchdog on both bsdtar paths (remote stream and local archive), with a post-exit footprint re-check to catch a bomb that completes inside one poll interval. 2. Shell guard. "pipe the curl output through head -c 209715200 (200 MB)" — implemented verbatim in all three launchers, with pipefail so bsdtar's SIGPIPE surfaces as a failure, plus an explicit size assertion. 3. locateExecutable entry cap. "the locateExecutable enumerator should impose a maximum file-count cap" — maxArchiveEntries = 10_000, throws when exceeded. Removed — out of scope, deferred (all preserved on chore/DEVA11Y-484-followup-extraction-guard-harness): * tests/extraction-guard/ — the 13-file, ~799-line regression harness (shell variants, Swift mirror harness, drift check, fixture generator). * .github/workflows/extraction-guard-tests.yml — the CI job that runs it. * Compressed-size cap: maxCompressedBytes and curl --max-filesize in the plugin, and the same in all three launchers. The ticket asks for a 200 MB *decompressed* cap; capping the wire size is separate hardening. The curl invocation now matches main byte-for-byte. * The prepareArtifact-level footprintExceeded backstop. It covered the Windows Expand-Archive path, which the ticket did not scope (it targets the bsdtar paths). Windows therefore remains unguarded — carried on the follow-up branch. * tests/README.md index row and the plugin's drift-mirror comment, both of which referenced the removed harness. The three cli.sh.sha256 sidecars were regenerated after dropping --max-filesize. Verification (the harness is gone, so this was done directly): * Real endpoint, merged download_binary, macos/arm64: exit 0, 38,017,898 B archive -> 69,391,104 B binary, perms 755, not truncated. * Guard proven to fire: same archive with a 1 MB cap gives pipeline status 1 at exactly the cap, so the abort path triggers; with the real 200 MB cap the pipeline is clean. Worst-case platform is macos/x64 at ~75.6 MB decompressed, 2.65x headroom. * swiftc -typecheck -parse-as-library against the PackagePlugin API: clean. * bash -n clean on all three launchers. * verify-selfupdate-checksums logic replicated locally: all six sidecars present and matching. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Crash0v3rrid3
left a comment
There was a problem hiding this comment.
Multi-agent code review — decompression-bomb guard (DEVA11Y-484)
Reviewed across security/adversarial, Swift concurrency, and shell-correctness lenses plus first-party verification against the PR head. The guard that ships is correctly implemented — but the PR description overstates the implementation on two verified counts, and I'd hold merge until those are reconciled.
What's correct (verified — no action needed)
- Swift concurrency is sound: watchdog thread lifecycle has no race/leak,
terminate()→waitUntilExit()→removeItemordering is safe,ExtractionLimitState'sNSLockis correct, andforwardExitis-> Never/exit(no fall-through). - Shell logic is correct: the bomb is genuinely caught (
head -ccap → SIGPIPE → 141 viapipefail, with-geas a backstop), legit downloads pass, andlocal x=$?captures the pipeline status correctly. scripts/fish/cli.shis#!/usr/bin/env bash -il(a bash script), so the guard syntax is intact in all three wrappers.- Path traversal / symlink escape is blocked by libarchive defaults (
bsdtar -xwithout-P) — writes stay inside the polled directory.
Blocking / high-priority
1. (P1) The described test suite and CI workflow do not exist in the PR. The description details scripts/test/, run_tests.sh, check_drift.sh, .github/workflows/extraction-guard-tests.yml, and "53/53 assertions green." At the PR head none of these exist — scripts/ contains only the wrappers, and the only workflows present are Semgrep.yml, spm-smoke-test.yml, and verify-selfupdate-checksums.yml. A security-critical guard would merge with no regression protection. Please commit the suite + CI, or remove the claims from the description.
2. (P2) No compressed-download size cap exists, despite the Summary claiming one. The Summary states "curl --max-filesize (100 MB) caps the compressed download," but:
scripts/*/cli.sh— the downloadcurl(curl -fR -z … -L … -o …) has no--max-filesize.BrowserStackAccessibilityLint.swift—download(...)usesURLSession.shared.download(from: url)with no Content-Length/byte limit.
In the fix's own threat model (MITM of the HTTPS endpoint, or an attacker-controlled HTTPS override URL), a multi-GB compressed payload exhausts disk during download — before checksum or extraction — bypassing the entire decompression guard. Please add --max-filesize to the curl download and a byte cap to the Swift download, or strike the claim.
3. (P2) Windows extraction path is unguarded — see the inline note; either guard it or track it as an explicit follow-up.
Lower priority
Inline P3 comments cover: shell entry-count asymmetry, set +o pipefail toggled unconditionally, .tmp cleanup on chmod/mv failure, poll-interval doc drift (50 ms vs 200 ms), SIGTERM-only kill, extractionFootprint fail-open + hidden-file inconsistency, missing private on the new decls, and the libarchive-containment assumption.
Verdict: Not ready — the core guard is correct, but the compressed-download cap (#2) and the test/CI suite (#1) are described but absent, and Windows is unguarded (#3). Land the missing pieces or correct the description and consciously accept the gaps.
🤖 Multi-agent review via Claude Code (compound-engineering). Posted as comments, not a formal request-changes.
| # that as a failure. Because the cap applies to ${BINARY_PATH}.tmp and publication is a | ||
| # later mv, a rejected bomb leaves any previously-cached binary untouched. | ||
| set -o pipefail | ||
| bsdtar -xvf "$BINARY_ZIP_PATH" -O | head -c "$max_decompressed" > "${BINARY_PATH}.tmp" |
There was a problem hiding this comment.
P3 — shell path lacks the Swift entry-count guard. In -O mode an archive of millions of tiny/empty entries streams ~0 bytes to stdout, so head -c never fills and never SIGPIPEs bsdtar. Disk stays bounded (good), but bsdtar still parses every entry (CPU/time drain) and a near-empty bogus payload passes the size check and gets chmod+mv'd into the cache. The Swift path guards this with maxArchiveEntries = 10_000; the wrappers have no equivalent. Consider an entry ceiling or --max-time on extraction.
(Applies identically to scripts/zsh/cli.sh and scripts/fish/cli.sh.)
There was a problem hiding this comment.
Acknowledged as a real gap, and deliberately not fixed in this PR — flagging rather than silently skipping.
Your analysis is right: in -O mode an archive of millions of empty entries streams ~0 bytes to stdout, so head -c never fills and never SIGPIPEs bsdtar. Disk stays bounded, but bsdtar parses every entry and a near-empty payload passes the size check and gets published. The plugin's maxArchiveEntries = 10_000 has no wrapper equivalent.
Why it is not in this commit: there is no cheap, correct mechanism in -O mode. The options I considered:
bsdtar -tfpre-pass to count entries — doubles archive parsing and is itself unbounded on a millions-of-entries archive, so it moves the CPU drain rather than removing it.--max-timeon extraction — a wall-clock proxy for an entry count; flaky on slow CI runners and does not actually bound entries.- Extract to a directory instead of
-Oso the footprint is measurable like the plugin's — the correct fix, but that is a real change to the wrapper's extraction model, and the wrappers are what self-update ships to every user frommain. Not something I want to land in the same PR as the guard, untested on Linux.
So: tracked as a follow-up on DEVA11Y-761 with your reasoning quoted, and listed under Known gaps item 4 in the rewritten PR description so it is owned rather than invisible.
Worth noting the residual is narrower than it was: the compressed-download cap added in 2c5fba8 (curl --max-filesize + post-download size check) bounds how large such an archive can be in the first place, so the CPU drain is capped at parsing a ≤100 MB archive rather than an unbounded one. That does not close the gap, but it does bound it.
Happy to take the "extract to a directory" approach as its own PR if you would rather not carry the gap.
| set -o pipefail | ||
| bsdtar -xvf "$BINARY_ZIP_PATH" -O | head -c "$max_decompressed" > "${BINARY_PATH}.tmp" | ||
| local extract_status=$? | ||
| set +o pipefail |
There was a problem hiding this comment.
P3 — set +o pipefail is toggled unconditionally. Neither wrapper sets pipefail globally today, so this is safe now, but it disables the option outright rather than restoring the prior state. If a global set -o pipefail is ever added to these scripts, this line will silently switch it off for everything after download_binary. Prefer save/restore, e.g. capture set +o | grep pipefail before and restore it after.
Also note: if chmod/mv fail on the happy path just below, ${BINARY_PATH}.tmp is left on disk — the rm -f cleanup only runs on the size-rejection branch. Minor, but inconsistent with the explicit cleanup above.
(Applies identically to scripts/zsh/cli.sh and scripts/fish/cli.sh.)
There was a problem hiding this comment.
Both fixed in 2c5fba8, in all three launchers.
pipefail — now saved and restored rather than cleared:
local pipefail_was_set=0
case "$(set +o)" in *"-o pipefail"*) pipefail_was_set=1 ;; esac
set -o pipefail
bsdtar … | head -c "$max_decompressed" > "${BINARY_PATH}.tmp"
local extract_status=$?
[[ $pipefail_was_set -eq 1 ]] || set +o pipefailVerified both directions: with set -o pipefail in the caller it is still set after download_binary returns; with it off, it stays off.
.tmp on the happy path — good catch, the asymmetry was real. chmod/mv are now guarded with cleanup on failure instead of a bare && chain:
if ! { chmod 0755 "${BINARY_PATH}.tmp" && mv -f "${BINARY_PATH}.tmp" "$BINARY_PATH"; }; then
echo "BrowserStack CLI: failed to publish the downloaded binary." >&2
rm -f "${BINARY_PATH}.tmp"
return 1
fi
strip_quarantine| process.terminate() | ||
| break | ||
| } | ||
| Thread.sleep(forTimeInterval: 0.05) |
There was a problem hiding this comment.
P3 — poll-interval doc drift. This sleeps every 50 ms (0.05), but the PR description and the overshoot math in the docstring above refer to a "200 ms poll interval" — off by 4×. Either bump this to 0.2 or correct the description/comment so the documented worst-case footprint (maxBytes + pollInterval × writeRate) matches reality.
There was a problem hiding this comment.
Fixed in 2c5fba8. You were right that it was off by 4x — and the drift was in the docstring rather than the code, so I corrected the docs to the real 50 ms rather than slowing the poll:
/// the limit before it is killed, so peak disk use is roughly `maxBytes + (50 ms x disk
/// write rate)` — the poll interval below is 50 ms.
Kept 50 ms because it is what the measurements in the description were actually taken at: against the 400 MB fixture the watchdog bounded peak disk to 58 MB, and re-verified on this head against the real 38 MB archive with a 5 MB cap it bounds to 36 MB of 66 MB. Widening to 200 ms would loosen that overshoot 4x for no benefit.
The PR description has also been rewritten (it was stale in several places — see the top-level reply).
| while process.isRunning { | ||
| if let reason = footprintExceeded(at: directory, maxBytes: maxBytes, maxEntries: maxEntries) { | ||
| state.markExceeded(reason) | ||
| process.terminate() |
There was a problem hiding this comment.
P3 — SIGTERM only, no escalation. terminate() sends SIGTERM once and the loop breaks. bsdtar doesn't trap SIGTERM so this is fine in practice, but if it's ever slow to die (blocked I/O), waitUntilExit() on the main thread blocks with no SIGKILL fallback. Low impact; consider a bounded wait + kill(pid, SIGKILL) escalation for robustness.
There was a problem hiding this comment.
Agreed on the analysis, and taking your own read that it is low impact — not changing it in this PR.
For the record on why: bsdtar does not trap SIGTERM, so in practice it dies immediately; the watchdog breaks straight after terminate() and the loop condition is while process.isRunning, so the thread exits cleanly with no leak. The theoretical hang needs bsdtar blocked in uninterruptible I/O, in which case waitUntilExit() on the main thread would stall with no SIGKILL fallback.
A bounded wait plus kill(pid, SIGKILL) escalation is the right hardening and I would rather add it with a test that actually exercises the escalation path than add an untested kill to a security fix. Noted on DEVA11Y-761 alongside the other deferred items.
Verified on the current head that the non-pathological path behaves: against the real 38 MB archive with a 5 MB cap the watchdog fires and bsdtar reports terminationStatus = 15 (SIGTERM), with disk bounded to 36 MB of the 66 MB it would otherwise have written.
| /// Total bytes and entry count of all regular files under `url`. | ||
| func extractionFootprint(at url: URL) -> (bytes: Int64, entries: Int) { | ||
| let fm = FileManager.default | ||
| guard let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey]) else { |
There was a problem hiding this comment.
P3 — two small issues in extractionFootprint.
- Fails open: if
fm.enumerator(...)returnsnil(directory transiently unreadable/missing), this returns(0, 0)→footprintExceededreturnsnil→ "not exceeded" for that poll. Low risk since the plugin created the dir, but a transient failure silently disables the guard for that tick. - Inconsistent "entry" definition: this enumerator omits
.skipsHiddenFiles, whilelocateExecutable's enumerator (line ~646) passesoptions: [.skipsHiddenFiles]. The same 10 000 ceiling therefore counts hidden files here but not there. Align the two so "entries" means the same thing in both guards.
There was a problem hiding this comment.
Both fixed in 2c5fba8.
1. Fail-open → fail-closed. You are right that (0, 0) silently disabled the guard for that poll. It now fails closed:
guard let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [...]) else {
// Fail CLOSED: a directory we just created being unreadable is not a "0 bytes"
// result, and returning (0, 0) would silently disable the guard for that poll.
return (Int64.max, Int.max)
}A transient failure now trips the ceiling and aborts rather than waving the archive through. Failing closed is the right default for a guard, and the false-positive cost is an aborted download with a clear message.
2. Hidden-file inconsistency. Also real — but after looking at both call sites I kept the difference and documented it rather than aligning them, because they are measuring different things:
extractionFootprintmeasures what bsdtar actually wrote — dotfiles included, since they consume disk and count toward a "millions of tiny files" bomb. Adding.skipsHiddenFileswould let an all-dotfiles archive slip the entry ceiling.locateExecutableis searching for a binary, so skipping hidden files is correct there.
So the shared 10_000 is deliberately counting different sets. Comment added at the enumerator making that explicit so the next reader does not "fix" it:
// `.skipsHiddenFiles` is deliberately NOT set, so the entry count here matches what
// bsdtar actually wrote — including dotfiles. locateExecutable skips hidden files
// because it is searching for a binary, not measuring a footprint; the two use the
// same ceiling but count deliberately different things (DEVA11Y-484 review).Happy to split into two named constants if you would rather the shared 10_000 not imply the two are equivalent.
| // verified first, then extracted. Windows' unzip path has no streaming guard. | ||
|
|
||
| /// Thread-safe flag shared between the extraction watchdog and the main flow. | ||
| final class ExtractionLimitState { |
There was a problem hiding this comment.
P3 — hygiene: these new top-level declarations lack private. ExtractionLimitState, extractionFootprint, footprintExceeded, and startExtractionWatchdog are the only non-private helpers in the file — every other helper (isTruthy, packageCacheRoot, hardwareIdentifier, …) is private. Harmless in a single-file plugin target, but worth marking private for consistency.
There was a problem hiding this comment.
Fixed in 2c5fba8 — all four are now private:
private final class ExtractionLimitStateprivate func extractionFootprint(at:)private func footprintExceeded(at:maxBytes:maxEntries:)private func startExtractionWatchdog(on:directory:maxBytes:maxEntries:)
Agreed it was inconsistent with every other helper in the file. swiftc -typecheck -parse-as-library is clean after the change.
| // | ||
| // Applies to extractLocalArchive, which since #37 (DEVA11Y-473/474) is the single | ||
| // non-Windows extraction path: the archive is downloaded to a file and checksum- | ||
| // verified first, then extracted. Windows' unzip path has no streaming guard. |
There was a problem hiding this comment.
P2 — Windows extraction path is unguarded. This correctly notes the unzip/Expand-Archive path has no streaming guard, but Windows is a supported target (#if os(Windows) branches, browserstack-cli.exe, PowerShell checksum). A zip bomb there fully exhausts disk with no download cap, no watchdog, and no entry ceiling. It's out of this PR's stated 4-surface scope, so either add a guard to the Windows path or track it as an explicit follow-up so the gap is owned rather than just commented.
Also, defense-in-depth note for the non-Windows path: containment depends on libarchive's default behavior (bsdtar -x without -P neutralizes .., absolute paths, and symlink-through, keeping all writes inside the polled -C directory). That's correct today but load-bearing and unasserted — a future -P would let writes escape the polled dir and the footprint poll would measure nothing. Worth a comment pinning the assumption.
There was a problem hiding this comment.
Two parts here.
Windows — now tracked, not merely commented. Agreed it is a real gap, and it is explicitly owned: DEVA11Y-761 item 3, with the implementation preserved on chore/DEVA11Y-484-followup-extraction-guard-harness. That branch carries the prepareArtifact-level footprintExceeded backstop positioned against stagingDirectory before publishVersionDirectory — which is where it belongs after #32 restructured extraction, so a rejected archive never becomes a visible version directory.
It came out of this PR when the PR was narrowed to DEVA11Y-484's stated Remediation, which scopes the bsdtar paths only. I noted on the ticket that "Windows has no bomb guard" probably deserves its own security ticket rather than sitting in a cleanup task — say the word and I will raise one.
One thing that does help Windows in the meantime: the compressed-download cap added in 2c5fba8 sits in the shared download(from:to:), so it applies on Windows too. It does not bound decompression, but it stops a multi-GB archive reaching Expand-Archive at all.
libarchive containment — pinned. Good catch that it was load-bearing and unasserted. Now stated in the guard block:
// Containment assumption (load-bearing): `bsdtar -x` WITHOUT `-P` neutralises `..`,
// absolute paths and symlink-through, so every write lands inside the `-C` directory we
// poll. Adding `-P` would let writes escape that directory and the footprint poll would
// measure nothing — do not add it (DEVA11Y-484 review).
…[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>
|
Thanks — this was a genuinely useful review, and one of the two blockers was my fault in a way worth naming explicitly. Everything below is in 2c5fba8, plus a rewritten PR description. All 9 checks green. Blocker 1 (P1) — test suite and CI "described but absent": you were right, and the cause was a stale descriptionThe suite and workflow really were not there. The reason is not that they were forgotten — they were deliberately removed when this PR was narrowed to DEVA11Y-484's stated Remediation (the PR had grown to +1044/−7 across 18 files for an XS ticket, with only 216 lines of production code). I removed the code and failed to update the description, so it kept advertising a harness, a CI workflow and "53/53 assertions" that no longer existed. That is exactly the kind of claim a reviewer should not have to discover by diffing, and it wasted your time. The description is now rewritten to match the shipped code, with an explicit Known gaps section that owns all five gaps rather than implying coverage that does not exist. The suite itself is not lost: preserved verbatim on Blocker 2 (P2) — no compressed cap: half stale description, half a real hole. Fixed.Same root cause for the wording — the cap had been descoped as "not in the ticket's Remediation" and the Summary still claimed it. But your threat-model argument is the substantive part and I think it is correct: without a wire cap, a multi-GB compressed payload exhausts disk before the checksum or the decompression guard ever run, which walks around the entire fix. Deferring it on a scoping technicality was the wrong call. Reinstated in 2c5fba8:
One limitation I want to state plainly rather than let the description imply otherwise: Blocker 3 (P2) — Windows unguardedTracked as DEVA11Y-761 item 3 with the implementation preserved on the follow-up branch, per your "either guard it or track it as an explicit follow-up so the gap is owned". Detail in the inline thread. I also noted on the ticket that this probably warrants its own security ticket rather than living in a cleanup task — happy to raise one. P3sFixed: Deferred with reasoning in-thread: the launcher entry-count gap (no cheap correct mechanism in Verification on this headNo automated suite ships, so this was verified directly against the live endpoint: 27/27 assertions across bash/zsh/fish — real download through #37's integrity check, Ready for another look when you have a moment. |
Crash0v3rrid3
left a comment
There was a problem hiding this comment.
Approved — re-review of 2c5fba8
Both blockers from my earlier review are resolved, and the fix is sound and honestly documented.
Resolved
- Compressed-download cap now implemented — Swift checks
response.expectedContentLengthand the downloaded file's actual size againstmaxCompressedBytes(100 MB); the launchers usecurl --max-filesize+ awc -cbackstop before checksum/extract. The post-transfer limitation is called out plainly and deferred to DEVA11Y-761. - PR description corrected — the previously-fictional Tests/CI section was descoped (moved to the follow-up branch) and replaced with an honest "Known gaps — owned, not hidden" section. Claims now match code.
Verified
- All three
.sha256sidecars match theircli.sh(self-update verification intact); all three launchers are#!/usr/bin/env bash -il(bash syntax valid). - Decompressed guard:
bsdtar -O | head -c+pipefailcorrectly rejects a bomb via SIGPIPE (141) and passes a legit ~75 MB binary under the 200 MB cap;local extract_status=$?captures pipeline status; pipefail save/restore is correct;.tmpcleaned onchmod/mvfailure. - Swift:
NSLockstate thread-safe, watchdog has no leak, post-exit footprint recheck catches fast bombs,extractionFootprintnow fails closed on a nil enumerator (prior fail-open fixed),forwardExitis-> Never, 10k-entry guard added tolocateExecutable.
No new P0/P1. The remaining items (no automated regression tests, Windows Expand-Archive unguarded, shell entry-count gap, Swift mid-stream cap, cap duplication across 4 files) are all acknowledged and tracked under DEVA11Y-761.
One conscious sign-off for the record: the caps merge with no automated coverage — acceptable given it's explicitly the top DEVA11Y-761 follow-up.
maunilm
left a comment
There was a problem hiding this comment.
Claude Code Review (automated) — 6 inline finding(s). Full report in the PR comment below. Verdict: Failed - see PR comment.
| // bsdtar actually wrote — including dotfiles. locateExecutable skips hidden files | ||
| // because it is searching for a binary, not measuring a footprint; the two use the | ||
| // same ceiling but count deliberately different things (DEVA11Y-484 review). | ||
| guard let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey]) else { |
There was a problem hiding this comment.
[Medium] This fail-closed branch is unreachable dead code
FileManager.enumerator(at:includingPropertiesForKeys:) does not return nil for a missing or unreadable directory — enumeration errors go to an errorHandler (default: skip and continue). Measured on this platform: a missing directory and a chmod 000 directory each yield a non-nil enumerator producing 0 elements, so both fall through to the loop and return (0, 0) — exactly the silent guard-disable this comment says it prevents.
Suggestion: fail closed from the error handler instead.
var enumerationFailed = false
guard let enumerator = fm.enumerator(
at: url, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],
options: [], errorHandler: { _, _ in enumerationFailed = true; return false }
) else { return (Int64.max, Int.max) }
…
return enumerationFailed ? (Int64.max, Int.max) : (total, count)Reviewer: stack:devtools-review-changes (orchestrator-confirmed; stack-code-reviewer had this as "verified correct")
| limitState.markExceeded(reason) | ||
| } | ||
| if limitState.exceeded { | ||
| try? fileManager.removeItem(at: directory) |
There was a problem hiding this comment.
[Medium] This abort path leaks the archive it exists to bound
forwardExit is -> Never and calls exit(code), so no defer runs. prepareArtifact holds two — one for stagingDirectory, one for archiveURL. This line hand-cleans only the staging directory, so the downloaded ≤100 MB archive stays in the cache on every guard trip. sweepStaleStaging reclaims it only after 3600 s and on a later successful prepareArtifact, so repeated runs inside the hour accumulate one archive each — inside the control meant to prevent disk exhaustion.
Suggestion: throw PluginError(…) instead of forwardExit so both defers run. Also matches locateExecutable's entry cap, which already throws for the same class of rejection.
Reviewer: stack:devtools-review-changes (orchestrator-confirmed)
| try? fileManager.removeItem(at: tempURL) | ||
| throw PluginError("BrowserStack CLI archive declares \(response.expectedContentLength) bytes, above the \(Self.maxCompressedBytes)-byte limit; refusing to download it.") | ||
| } | ||
| let downloadedBytes = (try? fileManager.attributesOfItem(atPath: tempURL.path)[.size] as? Int64) ?? nil |
There was a problem hiding this comment.
[Medium] Compressed-size check fails open on an unreadable size
If either the try? swallows a throw or the cast yields nil, the 100 MB cap is skipped with no diagnostic. That matters because this is the load-bearing half: expectedContentLength is -1 for chunked/unknown-length responses, so an attacker controlling BROWSERSTACK_A11Y_CLI_DOWNLOAD_URL — the threat model the comment above cites — just omits Content-Length.
Severity note: reported as High on the grounds that NSNumber → Int64 casting is Darwin-only. Measured here, the cast does succeed (attrs[.size] as? Int64 → 123456) and there is no Linux CI leg, so this is not a live fail-open today — downgraded to Medium. The try? path and the inconsistency with this file's own fail-closed stance still warrant fixing.
Suggestion: use the .fileSizeKey idiom already used later in this file, and fail closed:
guard let downloadedBytes = (try? tempURL.resourceValues(forKeys: [.fileSizeKey]).fileSize).map(Int64.init) else {
try? fileManager.removeItem(at: tempURL)
throw PluginError("Could not determine the downloaded archive's size; refusing to use it.")
}(?? nil is also redundant — try? is already flattened.)
Reviewer: stack:devtools-review-changes (severity adjudicated by orchestrator)
| while process.isRunning { | ||
| if let reason = footprintExceeded(at: directory, maxBytes: maxBytes, maxEntries: maxEntries) { | ||
| state.markExceeded(reason) | ||
| process.terminate() |
There was a problem hiding this comment.
[Medium] SIGTERM with no escalation: fails-closed can become hangs-open
The watchdog sends one SIGTERM and breaks, while the main flow's waitUntilExit() is unbounded. If bsdtar does not die promptly — ignored or blocked in an uninterruptible write — the plugin hangs indefinitely rather than aborting with the intended "Aborting to prevent disk exhaustion" message, which is a worse failure mode than the one being guarded.
Mitigating: bsdtar does not trap SIGTERM, and the normal path was measured working (terminationStatus = 15, disk bounded to 36 MB of 66 MB).
Suggestion: after terminate(), grace-wait ~2 s then kill(process.processIdentifier, SIGKILL) if process.isRunning, or bound waitUntilExit().
Reviewer: stack-code-reviewer
| local pipefail_was_set=0 | ||
| case "$(set +o)" in *"-o pipefail"*) pipefail_was_set=1 ;; esac | ||
| set -o pipefail | ||
| bsdtar -xvf "$BINARY_ZIP_PATH" -O | head -c "$max_decompressed" > "${BINARY_PATH}.tmp" |
There was a problem hiding this comment.
[Low] Pipeline is not errexit-safe, and this script sources user rc files
The shebang is #!/usr/bin/env bash -il, so ~/.bashrc / ~/.bash_profile are sourced. If a user's rc sets -e (common in "strict mode" boilerplate, often paired with -o pipefail), this bare pipeline aborts the script immediately: local extract_status=$? never runs, the user never sees the 200 MB message, and ${BINARY_PATH}.tmp is left behind — the exact residue the publish-failure cleanup below was added to prevent.
Suggestion:
| bsdtar -xvf "$BINARY_ZIP_PATH" -O | head -c "$max_decompressed" > "${BINARY_PATH}.tmp" | |
| local extract_status=0 | |
| bsdtar -xvf "$BINARY_ZIP_PATH" -O | head -c "$max_decompressed" > "${BINARY_PATH}.tmp" || extract_status=$? |
Applies identically to scripts/zsh/cli.sh and scripts/fish/cli.sh.
Reviewer: stack:devtools-review-changes
|
|
||
| local extracted_size | ||
| extracted_size=$(wc -c < "${BINARY_PATH}.tmp" 2>/dev/null || echo 0) | ||
| if [[ $extract_status -ne 0 || $extracted_size -ge $max_decompressed ]]; then |
There was a problem hiding this comment.
[Low] -ge here vs -gt on the compressed check — correct, but undocumented
-ge is deliberate and necessary: head -c N emits exactly N bytes whether the input was N or 10 GB, so == N is indistinguishable from an overflow and must be rejected. The surrounding comment does not say so, and a future editor "fixing the inconsistency" to -gt would silently punch a hole in the guard.
Suggestion: add one line — # -ge, not -gt: head -c caps output at exactly N, so N bytes is indistinguishable from an overflow.
Reviewer: stack:devtools-review-changes + stack-code-reviewer
Claude Code PR ReviewPR: #25 • Head: efcf13c • Reviewers: stack:devtools-review-changes, stack-code-reviewer SummaryAdds a decompression-bomb / disk-exhaustion guard to the CLI download-and-extract path for DEVA11Y-484: a 100 MB compressed cap, and a 200 MB / 10,000-entry decompressed cap enforced by a polling watchdog that SIGTERMs bsdtar in the Swift plugin plus a The core guard works. Both reviewers independently confirmed it, and the orchestrator re-verified at this head: the merge is clean, all six Review Table
FindingsF1 — the fail-closed branch is unreachable dead code, so the guard still silently disables itself
F2 — the anti-disk-exhaustion abort path leaks the archive it exists to bound
F3 — compressed-size check fails open on an unreadable size
F4 — no SIGKILL escalation after
F5 — this security control ships with zero automated regression coverage
F6 — Windows
Low
Reviewer disagreement, adjudicated
Raised by other reviewers (not independently confirmed)
Orchestrator verification at this headIndependent of both reviewers: today's merge Verdict: FAIL — core guard is sound and the merge is clean, but two High-priority table rows fail on confirmed defects: a documented fail-closed protection that provably never executes (F1) and an abort path that leaks the archive it bounds (F2). Both are small, local fixes. |
Addresses the Claude Code Review FAIL on this branch. The verdict failed two
High-priority table rows — "logic is correct, handles edge cases" and "error
handling is explicit, no swallowed exceptions" — on three confirmed defects. All
three are fixed and each fix was verified by measurement, not assertion.
F1 — the fail-closed branch in extractionFootprint was unreachable dead code.
FileManager.enumerator(at:includingPropertiesForKeys:) does not return nil for a
missing or unreadable directory: it routes errors to an errorHandler whose
default is "skip and continue". Measured: a missing directory and a chmod-000
directory each returned a NON-nil enumerator yielding zero elements, so both
fell through to (0, 0) — read as "not exceeded", the exact silent guard-disable
the comment claimed to prevent. The previous round's fix landed on a branch that
never executes.
Now supplies the errorHandler and returns the ceiling when it fires. Verified
the handler actually fires for both cases, and that footprintExceeded now
reports a rejection for each instead of nil.
F2 — the abort path leaked the archive it exists to bound.
forwardExit is -> Never and calls exit(), which skips every defer, including
prepareArtifact's cleanup of the downloaded archive. Every guard trip therefore
left a <=100 MB archive in the cache, reclaimed only after 3600 s AND a later
successful prepareArtifact — inside the control whose purpose is preventing disk
exhaustion. Now throws PluginError instead, so both defers unwind normally.
performCommand is `async throws`, so the message still surfaces with a non-zero
exit, and this matches locateExecutable's entry cap, which already throws.
F3 — the compressed-size check could fail open with no diagnostic.
`(try? attributesOfItem(atPath:)[.size] as? Int64) ?? nil` skips the cap silently
if the read throws or the cast yields nil. That is the load-bearing half of the
compressed cap, because expectedContentLength is -1 for chunked responses, so an
attacker-controlled URL omitting Content-Length is caught only here. Now reads
via resourceValues(.fileSizeKey) — the idiom already used in extractionFootprint
— and fails CLOSED when the size is unreadable. Also drops the redundant `?? nil`.
For the record: the review reported F3 as High on the grounds that NSNumber ->
Int64 casting is Darwin-only. Measured on the target platform the cast does
succeed (123456), so it was not a live fail-open; it was adjudicated down to
Medium. Fixed anyway — the try? path is a real hole and the fail-open stance was
inconsistent with this file's own fail-closed handling.
Also folded in three Low findings raised by both reviewers, all in the launchers:
* The extraction pipeline is now errexit-safe (`|| extract_status=$?`). The
shebang is `bash -il`, so user rc files ARE sourced; if one sets `-e`, the
bare pipeline aborted the script before the size check — skipping the
diagnostic and leaving ${BINARY_PATH}.tmp behind. Verified: under `set -e` a
corrupt payload now returns 1, prints its diagnostic, and cleans up the .tmp.
* Documented why the decompressed check uses -ge and not -gt: head -c caps output
at exactly N, so N bytes cannot be distinguished from a truncated overflow and
must be rejected. A future "consistency fix" to -gt would silently punch a hole.
* Split the conflated failure message into separate size-rejection and
extraction-failure diagnostics, size checked first since it is the accurate one
when a bomb trips both.
Not fixed here, tracked on DEVA11Y-761: SIGKILL escalation after terminate()
(Medium — bsdtar does not trap SIGTERM and the normal path is measured working),
the O(entries)-per-tick watchdog poll, hardcoded caps with no override, the
Windows unzip path, and the absent regression suite.
Verification: 27/27 assertions across bash/zsh/fish against the live endpoint,
plus the new errexit case; Swift guard re-verified on the real archive (200 MB
cap does not flag; 5 MB cap flags with terminationStatus 15; entries=0 flags);
fail-closed probes now return a rejection for both the missing and unreadable
directory; swiftc -typecheck clean; bash -n clean; all six sidecars verify.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ock [DEVA11Y-484] Second review round found a HIGH that the first round's fixes did not cover, and that this PR's own guard cannot catch. Reproduced and fixed. HIGH — undrained stderr pipe deadlocks extraction indefinitely. extractLocalArchive set process.standardError to a Pipe, called waitUntilExit(), and only read the pipe afterwards. bsdtar's stderr pipe is 64 KB; once full, bsdtar blocks writing and waitUntilExit() never returns. Critically this is reachable from an archive that stays UNDER both ceilings, so the watchdog is no defence — it spins on `process.isRunning` at 20 Hz for as long as the hang lasts, burning CPU beside it. A CWE-400 availability failure inside the control whose ticket is CWE-400. Reproduced independently before fixing: a 4,000-entry tar whose every member name contains `..` extracts to 0 bytes / 0 entries (vs caps of 200 MB / 10,000) yet bsdtar emits 226,939 bytes of "Path contains '..'" warnings. Harnessing the two plumbings side by side against that archive: old (read after wait) : *** NO RETURN within 15s -> DEADLOCK *** new (concurrent drain): returned OK rc=1 stderr captured=65536 bytes stderr is now drained on a dedicated queue started immediately after run(), with the captured buffer capped at 64 KB while continuing to read past the cap — discarding is what stops bsdtar blocking — and the failure branch reads that buffer instead of the pipe. The plumbing is pre-existing; this PR did not introduce it. Fixed here rather than deferred because it defeats the guard this PR adds instead of sitting beside it. MEDIUM — the item-1 fail-closed fix misreported its reason (self-inflicted). extractionFootprint returned (Int64.max, Int.max) on any enumeration error, and footprintExceeded checks bytes first, so an I/O or permission failure surfaced to the user as "decompressed size exceeds 200 MB". It also meant an artifact legitimately carrying a 0500/0400 directory would pass the in-flight polls and then be hard-rejected by the post-exit re-check — and the code's own comment anticipates a binary inside a nested versioned folder, so that shape is one the codebase expects. Footprint now carries `measured: Bool` instead of signalling failure as an infinite size, and footprintExceeded returns a distinct reason. Verified: missing dir -> "extraction directory could not be measured" chmod 000 dir -> "extraction directory could not be measured" nested locked -> "extraction directory could not be measured" normal dir -> bytes=12345 entries=1 measured=true, not exceeded LOW — the sibling forwardExit one branch over also leaked the archive. The corrupt-archive branch of the same function still called forwardExit, which skips prepareArtifact's defers — the exact leak the guard branch had fixed. Now throws. SwiftPM flattens the exit code anyway, so nothing is lost. LOW — curl failure diagnostics conflated a size abort with a network error. Split, but branched on what landed on disk rather than curl's exit code: --max-filesize is documented to exit 63, yet measured against this endpoint (which 302s to sdk-assets) curl aborts during receive and exits 56, so testing for 63 alone would misreport the common case. Reviewer disagreement resolved deliberately here: one round suggested keeping the cached archive on a transient failure to preserve the -z If-Modified-Since fast path; an earlier round argued removing it is safer. Kept the unconditional removal, and documented why — a partial write carries a fresh mtime, so keeping it risks the next -z revalidation returning 304 and handing a truncated archive to verify_binary_integrity. Losing a 304 is cheaper than trusting a truncated payload. Still deferred to DEVA11Y-761: SIGKILL escalation, the O(entries)-per-tick watchdog poll (measured ~50% overshoot: 288 MB peak against a 200 MB cap on NVMe), hardcoded caps, the unguarded Windows unzip path, and the absent regression suite. The deadlock reproducer above is worth a case in that suite. Verification: 27/27 shell assertions across bash/zsh/fish against the live endpoint; deadlock reproducer confirms old hangs / new returns; measured-flag semantics verified on four directory shapes; swiftc -typecheck clean; bash -n clean; all six sidecars verify. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude Code PR ReviewContinues the previous review — changes since PR: #25 • Head: 0af9ec2 • Reviewers: stack:devtools-review-changes, stack-code-reviewer SummaryDecompression-bomb / disk-exhaustion guard for DEVA11Y-484: a 100 MB compressed cap and a 200 MB / 10,000-entry decompressed cap, enforced by a polling watchdog that SIGTERMs bsdtar in the Swift plugin and by Since the previous FAIL at Review Table
FindingsResolved since
|
…DEVA11Y-484] Regression introduced by the deadlock fix in 0af9ec2, caught while verifying the production paths end to end. Draining stderr fully is what stops bsdtar blocking, and the retained buffer is capped at 64 KB — but the failure branch then surfaced that whole buffer as the thrown error message. Measured on the 4,000-entry `..` archive: a 65,757-byte error containing ~1,169 near-identical "Path contains '..'" lines, which SwiftPM would dump into the build log. Trading an extraction hang for a log flood is a poor trade, and both are availability problems. The drain is unchanged; only the excerpt shown is now bounded — first 20 lines plus "… N further bsdtar message(s) omitted." Measured after: 1,191 bytes, a 55x reduction, with the genuine first-line diagnostic preserved. Verified through the committed extractLocalArchive plumbing, verbatim: 1 happy path, real 38 MB artifact -> returned 0.13s, 1 entry, no error 2 happy path repeated -> returned 0.13s (no intermittent hang) 3 bomb (5 MB cap) -> returned 0.07s, threw size rejection 4 chatty 4,000x '..' archive -> returned 0.07s, message 1,191 bytes 5 corrupt archive -> returned 0.07s, "Unrecognized archive format" Case 1 mattered most and had not been covered before: if the drain loop failed to terminate when bsdtar exits silently with empty stderr, stderrDrained.wait() would have hung EVERY extraction — far worse in production than the bug being fixed. It returns promptly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude Code PR ReviewContinues the previous review — changes since PR: #25 • Head: Verdict: PASSNo Critical or High findings. Both reviewers independently recommended PASS. Merge is not blocked by this review. What changed since the last reviewOne commit ( New findings in this deltaBoth are non-blocking (Medium), but both are regressions or incompleteness introduced by this commit rather than pre-existing debt, and both were reproduced empirically rather than reasoned about. Medium — head-only truncation drops bsdtar's decisive last line
Verified user-visible: a throwaway command plugin throwing the same Impact: support cannot distinguish a truncated download from a path-traversal rejection. Fix: keep both ends (head + tail), or collapse identical runs and always retain the final 3 lines. Medium — the bound is line-count only; a single long line bypasses it
Fix: add a byte cap alongside the line cap. Note that it must bound on the Low findings
Previously deferred findings — all still stand, severities held stableAll five remain open and correctly triaged to DEVA11Y-761; none regressed or improved.
Impact assessmentPublic surface unchanged. No programmatic consumer of the message. Net observable change: the text of one error, on one branch (bsdtar exited non-zero AND the archive is not already an executable). Test coverageZero coverage for this delta, and the guard as a whole remains untested — Notes on this review
|
…ytes [DEVA11Y-484] The previous commit bounded the surfaced bsdtar stderr to 20 lines. Review found two ways that bound fails, both reproduced: 1. It kept the WRONG END. bsdtar streams per-entry warnings first and puts the decisive cause last. A 25-member `../` archive followed by a truncated payload emits 27 lines / 1,314 bytes — under the 64 KB retention cap, so before the previous commit the user saw `payload1: Truncated tar archive` in full. After it, they saw 20 identical `Path contains '..'` lines and no cause at all. Support could not tell a truncated download from a path-traversal rejection. Now the excerpt keeps the first 10 AND the last 10 lines, so the cause and bsdtar's "Error exit delayed from previous errors" summary both survive. 2. It bounded LINES ONLY. One pax entry with a 60,000-character `..` path emits just 2 stderr lines totalling ~60 KB, which sails through a 20-line cap untouched — the exact build-log flood the cap was added to prevent. Now bytes are bounded too, at 4 KB. The cap is applied over the `utf8` view because String's `prefix` counts CHARACTERS, which would let ~4x through on multi-byte input. Also switches to `String(decoding:as: UTF8.self)`. `String(data:encoding:.utf8)` returns nil — not a partial string — on any invalid sequence, and the `?? ""` fallback then discarded the ENTIRE diagnostic in favour of generic text. Two paths reach that: a non-UTF-8 member name (tar names are arbitrary bytes, echoed verbatim by bsdtar), and the drain's byte-wise 64 KB cap slicing a multi-byte scalar — 14 of 129 cut points, measured. The lossy decode never returns nil. Draining itself stays unbounded; that is what closes the deadlock fixed in 0af9ec2 and is deliberately untouched. Empty input still yields an empty string, which the generic-fallback branch depends on. Verified against the verbatim committed code: 9-shape excerpt matrix (empty, whitespace, short, truncated-tar, 4,000-entry bomb, 60 KB single line, 60 KB multi-byte, 21 tiny lines, mid-scalar cut) all bounded <= 4.4 KB with empty semantics preserved; and a 6-case end-to-end matrix through the real extractLocalArchive with a 25s hang deadline — happy path 0.06s, no hangs, truncated-archive cause retained, chatty archive down from ~65 KB to 1,115 B. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ction guard [DEVA11Y-484]
Both reviews flagged that this PR ships a security control with zero automated
coverage. This closes that for the shell half: 51 assertions across
scripts/{bash,zsh,fish}/cli.sh, plus a CI job that runs them on every PR touching
a launcher.
The functions under test are extracted VERBATIM from cli.sh and run against a
local python3 http.server. Only curl's CLI boundary is shimmed — the hardcoded
api.browserstack.com URL is rewritten and every other argument passes through, so
--max-filesize, -L, -z and the `bsdtar | head -c` pipeline all execute for real.
No network egress, no credentials, no mocks of bsdtar/head/curl.
Three details exist because the naive version of this suite passed for the wrong
reasons, each caught by measurement:
- All four interdependent functions are loaded, not just download_binary.
Loading one leaves strip_quarantine and verify_binary_integrity undefined, the
function dies with exit 127, and EVERY abort assertion then passes for the wrong
reason. Faithfulness greps also assert the extracted code still contains the
guarded pipeline and both cap constants, so a refactor past them fails loudly
rather than silently testing nothing.
- Assertions check the error MESSAGE, not just exit status. A bomb trips both the
size cap and extract_status (bsdtar takes SIGPIPE when `head -c` closes the
pipe), so asserting "exit 1" alone still passed with the size cap deleted —
confirmed by mutation test. The two paths emit different messages.
- The expected file mode is read out of cli.sh rather than hardcoded. main
tightened it from 0775 to 0755 and the hardcoded expectation had already rotted.
Mutation-validated. Baseline green; disabling the decompressed-size rejection,
raising the cap to 4 GB, dropping the `head -c` truncation, and removing both
compressed-cap layers each turn it red. Removing only --max-filesize stays green
and that is correct: the explicit `compressed_size > max_compressed` backstop
still rejects (measured — the full 105 MB downloads, then the backstop fires).
Removing both layers is caught.
Lives under tests/ rather than scripts/ because verify-selfupdate-checksums globs
scripts/**/*.sh and requires a committed .sha256 sidecar per match; test scripts
are not self-updated and must not enter that glob.
Fixtures (~106 MB) are generated on first run and gitignored. The ignore
deliberately has no trailing slash: `fixtures/` matches only a directory, so a
symlink named `fixtures` slips past it and gets staged — which happened while
building this.
The Swift half (extractLocalArchive, the watchdog, the stderr excerpt) is still
uncovered: it is private on a private struct in a plugin-only package with no
library target, so no test can import it. That needs the library extraction
tracked in DEVA11Y-761.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-484] Review found that the byte cap added in 13a8111 could discard the very thing the head+tail split was added to preserve, and I reproduced it. The cap was a blind prefix over the already-joined head + notice + tail. When the head lines are individually large, that cut lands inside the head and drops both the omission notice AND the entire tail — including bsdtar's decisive last line. Measured: 25 entries with ~500-character paths plus a truncated payload produced 13,264 bytes of stderr, and the excerpt came back 4,112 bytes with "Truncated tar archive" gone. That is the same dropped-cause bug 13a8111 fixed, reached from a different direction. Head and tail now get half the byte budget each, clamped BEFORE they are joined. Clamping direction turned out to matter as much as the split. Clamping the tail with a prefix still lost the cause: the last 10 lines are 8 large warnings FOLLOWED by the two lines that matter, so keeping the tail's beginning discards exactly what the tail was retained for. clampToUTF8Bytes therefore takes `keepingEnd`, and the tail keeps its end. Verified across 11 shapes against the verbatim committed code — empty, whitespace, short, cause-with-small-head, cause-with-BIG-head (13 KB), cause-with-HUGE-head (226 KB), 4,000-entry bomb, 60 KB single line, 60 KB multi-byte single line, 21 tiny lines, mid-scalar cut. All bounded under 4.4 KB, empty semantics preserved, and both the cause and the omission notice retained in every cause case (226 KB in, 4,249 bytes out). The 6-case end-to-end matrix through the real extractLocalArchive stays green with no hangs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uite [DEVA11Y-484] A reviewer running the suite alongside another run saw it fail once in seven, and the cause is real: fixture generation is not atomic, and run_tests.sh gated on "does legit.tar.gz exist?" — which make_fixtures.sh creates FIRST. A second run starting behind a generating one saw the gate satisfied and began reading bomb/manyfiles/multifile while they were still being written. Reproduced deterministically: clear the fixtures, start one run, start a second 3 seconds later, and the second fails 5 of 51 assertions — many-files and multi-file across all three variants, which are exactly the fixtures written last. Generation now takes an atomic mkdir lock (macOS ships no flock CLI) and writes a `.complete` marker as its final act; run_tests.sh gates on that marker and hard- fails if it is still absent afterwards. A run that loses the lock waits for the marker rather than proceeding on half-written input. Validated: 4 concurrent cold starts, a staggered cold start, and 6 warm serial runs — 51/51 every time, lock cleaned up, fixtures still gitignored. Mutation validation re-run and unchanged: disabling the decompressed-size rejection, raising the cap to 4 GB, dropping the head -c truncation, and removing both compressed-cap layers are each still caught. Also documents why the corrupt fixture uses /dev/urandom rather than /dev/zero: an all-zero file is a VALID EMPTY tar archive, so zeros make bsdtar exit 0 and the corrupt assertions pass for the wrong reason. That one bit me while building this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nnot go negative [DEVA11Y-484] Review noted that `omitted = count - headLines - tailLines` stays non-negative only because `maxMessageLines` (20) happens to equal `headLines + tailLines` (10 + 10), with nothing tying the three constants together. Not reachable today, but bumping headLines alone would have put "at least -3 further bsdtar message(s) omitted" in a user-facing build error. `maxMessageLines` is now derived as `headLines + tailLines`, which makes the property algebraic rather than coincidental: the branch is only entered when `count > headLines + tailLines`, so `omitted >= 1` for any values of either. Behaviour is unchanged — verified byte-identical output across all 11 shapes (empty, whitespace, short, cause-with-small/BIG/HUGE head, 4,000-entry bomb, 60 KB single line, 60 KB multi-byte line, 21 tiny lines, mid-scalar cut), and the 6-case end-to-end matrix through the real extractLocalArchive stays green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…te [DEVA11Y-484] Addresses the impact review's Medium plus its actionable Lows. MEDIUM — the oversized-download assertion carried a false rationale. Its comment claimed that with `--max-filesize` removed the failure moves to bsdtar, so asserting "the failure came from curl" proves the flag is present. It does not: the explicit `compressed_size > max_compressed` backstop fires first and emits "maximum allowed download size", which is the second pattern the assertion already accepts. The comment also contradicted this suite's own README, which described the two-layer behaviour correctly. Reworded to claim only what it proves — "rejected before extraction" — and added a fourth faithfulness grep for `--max-filesize` itself. That grep closes the one gap in the mutation matrix. Removing the flag alone was previously undetectable by any behavioural assertion; it is now caught, so all five mutations are detected rather than four. This matters because without the flag a chunked or undeclared-length response can write unbounded bytes to disk before any check runs. Also fixed, and each one caught by re-running the validation rather than by reading: - start_server accepted ANY server answering on its PID-derived port. If an unrelated local service held it, the probe passed against that server, every fixture 404'd, and the run failed with a dozen confusing per-case errors instead of "port busy". It now serves a token and requires the responder to return it, trying other ports otherwise. The old comment promised this fallback; it had never been implemented. - My first version of that fix used a FIXED token filename, which is itself a concurrency bug: four simultaneous runs overwrite each other's token, every probe reads a foreign value, and start_server exhausts all ten ports and exits with no tests run. 2 of 4 concurrent cold starts died that way. Token files are now per-run and removed in stop_server. - Added a legit `.zip` fixture and case. Production serves a .zip (cli.sh even names the path BINARY_ZIP_PATH) while every fixture here was .tar.gz, so the suite never saw the format the guarded path actually receives. The guard is format-independent, so this is fidelity, not a correctness hole. 51 -> 60 assertions. - EXPECTED_MODE is now read per variant. The three cli.sh files are byte-identical in that region today, but reading bash's value for all three would check a zsh- or fish-only mode change against the wrong source. - The 20,000-entry case now documents that it asserts no per-entry disk amplification, NOT an entry-count cap. The shell path has no entry ceiling at all, unlike the Swift path's maxArchiveEntries = 10_000, and because -O concatenates that archive publishes a 0-byte binary. Pre-existing, outside this ticket, but not something a passing assertion should imply is covered. Workflow hygiene: pinned actions/checkout to v4.2.2 (matching the repo's three newest workflows) — v3.5.3 is a Node16 action being removed from runner images, which would have reddened this job at checkout for unrelated reasons; pinned macos-14 rather than floating macos-latest, since the suite depends on bsdtar, python3, BSD `head -c` and BSD `stat -f`; added the same `paths:` filter to the push trigger as the PR trigger, so pushes to main no longer run a 10x-billed macOS job unconditionally; added a concurrency group with cancel-in-progress. Docs: `tests/README.md` was the index of tests/ and framed everything there as an end-to-end plugin harness — it now distinguishes integration harnesses from regression suites and lists extraction-guard. And sweepStaleStaging's doc comment still said the extract helpers call forwardExit()/exit() and bypass prepareArtifact's defers; this PR converted those to throws precisely so the defers DO run, so that comment now applies only to the Windows unzip path and to SIGKILL. Re-validated after all of the above: 60/60 across 4 concurrent cold starts, a staggered cold start, and 6 warm serial runs; port-squat defence confirmed against a decoy server; all 5 mutations caught; cli.sh restored byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude Code PR ReviewContinues the previous review — FULL re-review across six commits since Head: Verdict: PASSNo Critical or High findings. Both reviewers independently recommended PASS, and every actionable finding they raised has been fixed in this branch. Scope note — read this before relying on the verdictBoth reviewers ran against head
Those two carry author validation only, not independent subagent review. They are a constant derivation, comment corrections, workflow-hygiene pins, and test-harness changes; no guard logic changed. Stated explicitly so the human reviewer knows exactly where the independent eyes stopped. What the six commits do
Findings raised this round — all fixedMedium — the oversized-download assertion carried a false rationale. Its comment claimed that removing Medium (previous round) — the byte cap could discard the tail it existed to preserve. A blind prefix over the joined head+notice+tail cut inside the head when head lines were large, dropping the notice and the decisive cause. Reproduced at 13,264 B input. Fixed in Low — Low — Low — fixtures never used production's format. The endpoint serves a Low — Low — workflow hygiene. Low — two docs-drift items. Verification
Release impactMerge = release. No tags exist; SPM consumers pin Surface unchanged: Observable changes: new hard-fail cases (archive >100 MB, footprint >200 MB or >10,000 entries); bsdtar extraction failure now throws rather than exiting with bsdtar's code (SwiftPM prints Headroom, measured against production: CLI 1.53.0 macos-arm64 is 36.2 MB compressed / 66.2 MB decompressed / 1 entry — 2.8× compressed and 3.0× decompressed headroom. Not a blocker. But there is no override env var, so a future CLI crossing either line breaks every consumer simultaneously. Worth a size assertion in the CLI release process; tracked below. Still open — deferred to DEVA11Y-761
Notes on this review
|
What
Adds a decompressed-size and entry-count guard to the CLI download/extract path, so a decompression bomb cannot exhaust developer or CI-runner disk.
Fixes DEVA11Y-484 (F-015, CWE-400, umbrella APPSEC-415).
Scope
This PR is deliberately narrowed to DEVA11Y-484's stated Remediation. Work that was previously in this branch — the regression suite, its CI workflow, and the Windows
Expand-Archivebackstop — was removed and is tracked in DEVA11Y-761, preserved on branchchore/DEVA11Y-484-followup-extraction-guard-harness. See Known gaps below.Changes
Swift plugin —
Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swiftstartExtractionWatchdogpolls the extraction directory every 50 ms while bsdtar runs andterminate()s it once the decompressed footprint crossesmaxDecompressedBytes(200 MB) ormaxArchiveEntries(10,000). A soft ceiling by design: peak disk ≈maxBytes + (50 ms × write rate).footprintExceededre-check catches a bomb that finishes inside one poll interval.locateExecutablethrows past 10,000 entries, per the ticket's ask.maxCompressedBytes(100 MB) checked againstresponse.expectedContentLengthand the downloaded file's actual size.Attached to
extractLocalArchive, which since #37 (DEVA11Y-473/474) is the single non-Windows extraction path — the archive is downloaded to a file and checksum-verified first, then extracted. The old streamingcurl | bsdtarpath that #37 deleted is gone, so there is no separate remote guard.Launchers —
scripts/{bash,zsh,fish}/cli.shbsdtar … -O | head -c 209715200withpipefail, so the cap is enforced by SIGPIPE, plus an explicit size assertion as a backstop.curl --max-filesize 104857600plus a post-download size check (curl documents the flag as a no-op when the length is unknown).${BINARY_PATH}.tmpand publication stays a latermv, so a rejected payload cannot truncate a previously-good cached binary — preserving fix(cli): verify downloaded CLI binary integrity before exec (DEVA11Y-473/474) #37's protection.download_binary || exit $?, so fix(cli): verify downloaded CLI binary integrity before exec (DEVA11Y-473/474) #37's distinctexit 2for an integrity mismatch survives.Verification
No automated suite ships with this PR (see Known gaps), so this was verified directly against the live download endpoint:
.tmpcleaned up after publish; re-run byte-identical; corrupt payload rejected; and the previously-cached binary's sha256 is unchanged after a rejected payload.termStatus=0, 69,391,104 B, 1 entry); a 5 MB cap flags and SIGTERMs bsdtar mid-stream (termStatus=15), bounding disk to 36 MB of 66 MB;maxEntries=0flags on entry count.swiftc -typecheck -parse-as-libraryclean;bash -nclean on all three launchers; all six.sha256sidecars verify; self-update's own comparison matches for all three.Headroom (CLI v1.52.1): largest platform archive is 41 MB compressed (2.4× under the 100 MB cap) and ~75.6 MB decompressed (2.65× under the 200 MB cap). The caps are duplicated in four places — they must move together when the CLI outgrows them.
Known gaps — owned, not hidden
Expand-Archiveis unguarded. No download cap enforcement mid-stream, no watchdog, no entry ceiling on that branch. Unchanged frommain, but a real gap. DEVA11Y-761 item 3.URLSession.download(from:)has no byte-level hook, so an oversized archive is stopped before checksum/extract/exec but peak temporary disk during transfer is not bounded. Needs aURLSessionDownloadDelegatecancelling indidWriteData. The launchers do abort during transfer. DEVA11Y-761.maxArchiveEntries. In-Omode an archive of millions of empty entries streams ~0 bytes, sohead -cnever fires; disk stays bounded but bsdtar still parses every entry. Open for review discussion — no cheap mechanism in-Omode.Note on the ticket's threat model
DEVA11Y-484 states the download has "no TLS, per scope.md:65". The URL in code is
https://and the live endpoint serves HTTPS with a 302 tohttps://sdk-assets.browserstack.com, which weakens the stated MitM reachability behind theAV:N/ CVSS 5.3 rating. TheBROWSERSTACK_A11Y_CLI_DOWNLOAD_URLoverride remains a genuine vector, so the fix stands — but the premise as written is inaccurate.Refs DEVA11Y-484, DEVA11Y-761, APPSEC-415.