Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 23 additions & 87 deletions .github/workflows/spm-smoke-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,95 +123,31 @@ jobs:
done
exit "$status"

# Functionally exercises verify_binary_integrity (DEVA11Y-473/474) across its full
# fail-open / fail-closed matrix against a local python fixture server. Because the
# control fails OPEN today (no sidecars published yet), a `bash -n` gate alone would
# give ZERO red signal if this function were broken — the regression would only surface
# the day the sidecars go live. This job is the guard against that. No secrets needed.
verify-integrity-fn:
name: CLI binary integrity check (functional matrix)
runs-on: ubuntu-latest
timeout-minutes: 5
# Functionally exercises the launchers' verify_binary_signature (DEVA11Y-473/474),
# lifted verbatim from each of the bash/zsh/fish copies: the real CLI must pass, and
# flipped-byte, ad-hoc-signed and unsigned copies must be refused (fail closed). A
# `bash -n` gate alone gives no signal if the check were broken. macOS because the
# check shells out to /usr/bin/codesign. No secrets needed.
verify-signature-fn:
name: Launcher signature verification (functional matrix)
runs-on: macos-14
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Exercise verify_binary_integrity (fail-open / fail-closed matrix)
run: |
set -uo pipefail
SRC=scripts/bash/cli.sh
# Lift the two functions verbatim off the branch (name() { ... } to first bare }).
extract() { awk -v fn="$1" 'index($0,fn"() {")==1{p=1} p{print} p&&/^}$/{exit}' "$SRC"; }
eval "$(extract _self_update_sha256)"
eval "$(extract verify_binary_integrity)"
WORK=$(mktemp -d); cd "$WORK"
printf 'REAL-BINARY-PAYLOAD' > good.zip
GOOD=$(_self_update_sha256 good.zip)
mkdir srv
( cd srv && python3 -m http.server 8799 >/dev/null 2>&1 & echo $! > "$WORK/pid" )
sleep 1
BASE="http://127.0.0.1:8799/asset.zip"
pass=0; fail=0
# NOTE: GitHub's default `shell: bash -e {0}` injects -e, which `set -uo
# pipefail` does not undo. verify_binary_integrity intentionally returns
# non-zero on the fail-closed cases, so capture rc with `&& rc=0 || rc=$?`
# rather than `; rc=$?` — the latter aborts the whole step under -e before
# the `[ "$fail" -eq 0 ]` gate at the end ever runs.
check() { local d="$1" erc="$2" ez="$3" url="$4" out rc z
cp good.zip z.zip
out=$(verify_binary_integrity z.zip "$url" 2>&1) && rc=0 || rc=$?
z=gone; [ -f z.zip ] && z=kept
if [ "$rc" = "$erc" ] && [ "$z" = "$ez" ]; then echo "PASS | $d | rc=$rc zip=$z"; pass=$((pass+1))
else echo "FAIL | $d | got rc=$rc zip=$z want rc=$erc zip=$ez | $out"; fail=$((fail+1)); fi
}
check "empty resolved URL (skip)" 0 kept ""
printf '%s asset.zip\n' "$GOOD" > srv/asset.zip.sha256
check "good sidecar" 0 kept "$BASE"
check "good + ?token= query" 0 kept "${BASE}?token=abc"
rm -f srv/asset.zip.sha256
check "missing sidecar (404)" 0 kept "$BASE"
printf '%s asset.zip\n' "$(echo "$GOOD" | tr 'a-z' 'A-Z')" > srv/asset.zip.sha256
check "uppercase published hash" 0 kept "$BASE"
printf '%s asset.zip\n' "0000000000000000000000000000000000000000000000000000000000000000" > srv/asset.zip.sha256
check "wrong checksum" 2 gone "$BASE"
: > srv/asset.zip.sha256
check "empty sidecar (200)" 2 gone "$BASE"
printf '<Error><Code>AccessDenied</Code></Error>' > srv/asset.zip.sha256
check "malformed 200 (error page)" 0 kept "$BASE"
kill "$(cat "$WORK/pid")" 2>/dev/null || true
echo "----"; echo "PASS=$pass FAIL=$fail"
[ "$fail" -eq 0 ]
- name: Exercise verify_binary_signature (genuine / tampered / ad-hoc / unsigned)
run: tests/launcher-signature/run_tests.sh matrix

# Positive assertion that the SERVER half (DEVA11Y-473/474) has shipped: resolves the
# real (unauthenticated) download redirect to the versioned asset and probes the
# <asset>.sha256 sidecar the client verifies against. Advisory (::warning) today because
# the sidecars are not published yet and verification is inert by design; flip the marked
# line to a hard failure once SDK-assets publishes them so this proves verification runs.
sidecar-availability:
name: CLI checksum sidecar published (advisory until server half ships)
runs-on: ubuntu-latest
timeout-minutes: 5
# The CDN publishes no checksum, so the launchers and the SwiftPM plugin trust the
# binary's code signature, pinned to BrowserStack's Team ID. If a release is ever
# signed differently (new team, lapsed certificate, unsigned build), every user's
# launcher would refuse to run; this turns that into a red check here first.
published-signature:
name: Published CLI signed by BrowserStack (Team YQ5FZQ855D)
runs-on: macos-14
timeout-minutes: 10
steps:
- name: Assert <asset>.sha256 resolves to a 64-hex digest
run: |
set -uo pipefail
resolved=$(curl -fsSL -o /dev/null -w '%{url_effective}' \
"https://api.browserstack.com/sdk/v1/download_cli?os=macos&os_arch=arm64" || true)
if [ -z "$resolved" ]; then
echo "::warning::Could not resolve the CLI asset URL; skipping sidecar availability check."
exit 0
fi
sum_url="${resolved%%\?*}.sha256"
code=$(curl -fsSL -o body.txt -w '%{http_code}' "$sum_url" || echo 000)
# `|| true`: on the expected 403 (server half not shipped) curl writes no
# body.txt, so awk exits non-zero and pipefail propagates it — which under
# the GHA-injected `bash -e` would abort the step and turn this advisory
# ::warning into a red check. Keep it advisory until the sidecars ship.
first=$(awk '{print $1; exit}' body.txt 2>/dev/null | tr 'A-Z' 'a-z' || true)
if [ "$code" = "200" ] && printf '%s' "$first" | grep -Eq '^[0-9a-f]{64}$'; then
echo "Sidecar published and well-formed at ${sum_url}"
else
# TODO(DEVA11Y-473/474 server half): change the next block to `exit 1` once
# SDK-assets publishes <asset>.sha256 (public-read). Until then verification
# is inert, so this stays advisory rather than red-blocking every PR.
echo "::warning::No well-formed checksum sidecar at ${sum_url} yet (HTTP ${code}); CLI binary integrity verification is INERT until the server half ships. Flip this check to a hard failure once sidecars are published."
fi
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Verify the latest published macOS arm64 and x64 CLIs
run: tests/launcher-signature/run_tests.sh published
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ The SwiftPM plugin verifies the BrowserStack CLI binary's code signature before
- **Windows:** the Authenticode signature must be valid and issued to BrowserStack, Inc.
- **Linux:** the CLI build is not signed, so there is nothing to verify; the plugin relies on HTTPS alone. This residual risk is accepted on DEVA11Y-473 until the Linux build is signed or the API serves a digest.

The plugin downloads only over HTTPS and only follows download redirects to `https://` URLs on `browserstack.com`. The launcher scripts do not yet verify the CLI binary's signature (tracked in DEVA11Y-474).
The plugin downloads only over HTTPS and only follows download redirects to `https://` URLs on `browserstack.com`. The launcher scripts apply the same check on macOS, on every run before the binary is made executable or run: a binary that fails is refused and the cached download is discarded, so the next run fetches a fresh copy. On Linux they rely on HTTPS alone, under the same accepted risk.

Zsh
```zsh
Expand Down
98 changes: 41 additions & 57 deletions scripts/bash/cli.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
#!/usr/bin/env bash -il

GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
Expand Down Expand Up @@ -190,52 +190,29 @@
fi
}

# DEVA11Y-473/474: verify the downloaded CLI archive against a server-published
# SHA-256 sidecar before extracting and executing it. api.browserstack.com (the
# control plane) 302-redirects to a versioned, immutable asset on the CDN/S3 (the
# data plane); a checksum published next to that asset lets us detect a tampered
# or corrupted binary before `chmod 0755` + exec. Semantics mirror self-update:
# fail CLOSED on a checksum mismatch, fail OPEN (warn + proceed) when no sidecar
# is published yet, so this stays non-breaking until the SDK-assets team ships the
# sidecars (the server-side half of DEVA11Y-473/474).
verify_binary_integrity() {
local zip_path="$1" resolved_url="$2" sum_url tmp_sum expected actual
if [[ -z "$resolved_url" ]]; then
echo "CLI download: could not resolve asset URL; skipping integrity check (DEVA11Y-473/474)." >&2
return 0
fi
# Derive the sidecar from the asset path only. Stripping any query string keeps
# signed/presigned URLs (…zip?token=) from deriving a permanently-404 sidecar
# (…zip?token=.sha256), which would silently disable verification.
sum_url="${resolved_url%%\?*}.sha256"
tmp_sum=$(mktemp "${TMPDIR:-/tmp}/bs-a11y-clisum.XXXXXX") || return 0
# shellcheck disable=SC2064
trap "rm -f -- '${tmp_sum}'" RETURN
if ! curl -fsSL --connect-timeout 10 --max-time 30 "$sum_url" -o "$tmp_sum" 2>/dev/null; then
echo "CLI download: no published checksum at ${sum_url}; proceeding WITHOUT integrity verification (DEVA11Y-473/474)." >&2
return 0
fi
expected=$(awk '{print $1; exit}' "$tmp_sum" | tr 'A-Z' 'a-z')
actual=$(_self_update_sha256 "$zip_path" | tr 'A-Z' 'a-z')
# A present-but-empty sidecar body is a hard failure: once the server publishes
# checksums, a blank value must not silently downgrade to "no verification".
if [[ -z "$expected" ]]; then
echo "CLI download: empty checksum at ${sum_url}; refusing to use the downloaded binary." >&2
rm -f -- "$zip_path"
return 2
fi
# A non-empty body that is NOT a 64-char hex digest is a CDN/S3 error page answered 200
# (e.g. an S3 `AccessDenied` XML), not a checksum. Its first token must not become the
# "expected hash" and hard-fail every client on every run; fail OPEN instead (DEVA11Y-473/474 review).
if ! [[ "$expected" =~ ^[0-9a-f]{64}$ ]]; then
echo "CLI download: malformed checksum at ${sum_url}; proceeding WITHOUT verification (DEVA11Y-473/474)." >&2
return 0
fi
if [[ -z "$actual" || "$expected" != "$actual" ]]; then
echo "CLI download: checksum mismatch; refusing to use the downloaded binary." >&2
echo " expected: ${expected}" >&2
echo " actual: ${actual:-<empty>}" >&2
rm -f -- "$zip_path"
# DEVA11Y-473/474: verify the extracted CLI binary's code signature before it is made
# executable, published or run, failing CLOSED. The CDN publishes no checksum, so we
# check the signature BrowserStack already ships the binary with: that proves who built
# it, which a compromised CDN/origin cannot forge. Mirrors the SwiftPM plugin.
# - macOS: `codesign --verify --strict` against a Developer ID requirement pinned to
# BrowserStack's Team ID.
# - Linux: the CLI build is unsigned, so there is nothing to verify. Security accepted
# that residual risk on DEVA11Y-473 until the Linux build is signed or the API serves
# a digest (SDK-6482).
verify_binary_signature() {
local binary_path="$1" team_id="YQ5FZQ855D" requirement codesign_out
case "$OS" in
linux) return 0 ;;
macos) ;;
*)
echo "BrowserStack CLI: cannot verify the binary's signature on ${OS}; refusing to run it." >&2
return 2
;;
esac
requirement="=anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = \"${team_id}\""
if ! codesign_out=$(/usr/bin/codesign --verify --strict -R "$requirement" "$binary_path" 2>&1); then
echo "BrowserStack CLI signature verification failed; refusing to run the downloaded binary." >&2
echo " ${codesign_out}" >&2
return 2
fi
}
Expand All @@ -249,7 +226,7 @@
# exit and nothing written to disk. curl documents the flag as a no-op when the length is
# unknown (chunked responses), so the explicit size check below backstops that case —
# otherwise an attacker-controlled endpoint could exhaust the disk during download, before
# the checksum and the decompression guard ever run (DEVA11Y-484 review).
# the signature check and the decompression guard ever run (DEVA11Y-484 review).
local resolved_url curl_status=0
resolved_url=$(curl -fR --max-filesize "$max_compressed" -z "$BINARY_ZIP_PATH" -L "https://api.browserstack.com/sdk/v1/download_cli?os=${OS}&os_arch=${ARCH}" -o "$BINARY_ZIP_PATH" -w '%{url_effective}') || curl_status=$?
if [[ $curl_status -ne 0 ]]; then
Expand All @@ -267,8 +244,8 @@
# Remove unconditionally, including on a transient network error. This deliberately gives
# up the -z If-Modified-Since fast path on the next run: a partial write carries a fresh
# mtime, so keeping it risks the next -z revalidation getting a 304 and handing a
# truncated archive to verify_binary_integrity. Losing a 304 is cheaper than trusting a
# truncated payload.
# truncated archive to the extractor. Losing a 304 is cheaper than trusting a truncated
# payload.
rm -f "$BINARY_ZIP_PATH"
return 1
fi
Expand All @@ -281,13 +258,10 @@
return 1
fi

verify_binary_integrity "$BINARY_ZIP_PATH" "$resolved_url" || return $?

# Extract to a temp path and atomically publish it. `> "$BINARY_PATH"` truncates the
# destination before bsdtar is known to have succeeded, so a corrupt payload — the live
# case today, since no sidecars are published yet and verification fails open — would
# zero out a previously-good cached binary. Stage + mv keeps the cached binary intact
# unless a fresh, extractable payload is in hand (DEVA11Y-473/474 review).
# destination before bsdtar is known to have succeeded, so a corrupt payload would zero
# out a previously-good cached binary. Stage + mv keeps the cached binary intact unless a
# fresh, extractable, correctly signed payload is in hand (DEVA11Y-473/474 review).
#
# The decompression-bomb guard (DEVA11Y-484) sits on that same staged path: head -c stops
# bsdtar via SIGPIPE once the decompressed output reaches the cap, and pipefail surfaces
Expand Down Expand Up @@ -325,6 +299,16 @@
return 1
fi

# Verify BEFORE chmod/publish. The archive is re-extracted on every run (even on a 304),
# so this also covers a cached zip. On failure drop the zip too, otherwise the next run's
# If-Modified-Since request gets a 304 and keeps re-extracting the same rejected payload.
local verify_status=0
verify_binary_signature "${BINARY_PATH}.tmp" || verify_status=$?
if [[ $verify_status -ne 0 ]]; then
rm -f "${BINARY_PATH}.tmp" "$BINARY_ZIP_PATH"
return $verify_status
fi

# Clean the staged file up on *any* failure below, not just the size rejection above,
# so a failed chmod/mv never leaves a stray ${BINARY_PATH}.tmp in the cache.
if ! { chmod 0755 "${BINARY_PATH}.tmp" && mv -f "${BINARY_PATH}.tmp" "$BINARY_PATH"; }; then
Expand Down Expand Up @@ -355,7 +339,7 @@
exit 0
fi

# Abort before executing the CLI if the download or its integrity check failed
# (checksum mismatch returns 2 from download_binary -> DEVA11Y-473/474).
# Abort before executing the CLI if the download or its signature check failed
# (a failed signature check returns 2 from download_binary -> DEVA11Y-473/474).
download_binary || exit $?
a11y_scan
2 changes: 1 addition & 1 deletion scripts/bash/cli.sh.sha256
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1652f1cd582110d30832f48fc931681ee46b3cae0afad156fa64e4031ddf7804 cli.sh
563fafba772a8fa23fc149b514592ea46961c9c202ab2c5a959f347e28839c5f cli.sh
Loading
Loading