diff --git a/.github/workflows/spm-smoke-test.yml b/.github/workflows/spm-smoke-test.yml
index 572468e..f74267d 100644
--- a/.github/workflows/spm-smoke-test.yml
+++ b/.github/workflows/spm-smoke-test.yml
@@ -122,3 +122,96 @@ jobs:
fi
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
+ 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 'AccessDenied' > 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 ]
+
+ # Positive assertion that the SERVER half (DEVA11Y-473/474) has shipped: resolves the
+ # real (unauthenticated) download redirect to the versioned asset and probes the
+ # .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
+ steps:
+ - name: Assert .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 .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
diff --git a/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift
index 506e223..34c74dc 100644
--- a/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift
+++ b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift
@@ -245,18 +245,23 @@ private struct BrowserStackCLIDownloader {
Diagnostics.remark("BrowserStackAccessibilityLint: Downloading CLI \(info.version)...")
- #if os(Windows)
// Download the archive to a sibling temp file *outside* the staging directory so a
- // failed cleanup (e.g. an AV scanner or indexer holding a handle on Windows) can
- // never bake the .zip into the published version directory. A leftover is a `.tmp.*`
- // sibling that sweepStaleStaging reclaims later.
+ // failed cleanup (e.g. an AV scanner or indexer holding a handle) can never bake the
+ // .zip into the published version directory, and — crucially (DEVA11Y-473/474) — so we
+ // can verify the archive's integrity before it is extracted, made executable, and run.
+ // A leftover is a `.tmp.*` sibling that sweepStaleStaging reclaims later.
let archiveURL = cacheRoot.appendingPathComponent(".tmp.\(info.version).\(UUID().uuidString).zip")
defer { try? fileManager.removeItem(at: archiveURL) }
try await download(from: info.resolvedURL, to: archiveURL)
+ // Verify BEFORE extraction/exec, on every platform (DEVA11Y-473/474 review: Windows
+ // was previously left unverified). Streaming curl | bsdtar straight to disk (the old
+ // path) left no opportunity to check the payload; downloading to a file first does.
+ try await verifyArchiveChecksum(archiveURL: archiveURL, resolvedURL: info.resolvedURL)
Diagnostics.remark("BrowserStackAccessibilityLint: Extracting CLI \(info.version)...")
+ #if os(Windows)
try unzip(archive: archiveURL, into: stagingDirectory)
#else
- try extractWithBsdtar(from: info.resolvedURL, into: stagingDirectory)
+ try extractLocalArchive(at: archiveURL, into: stagingDirectory)
#endif
// Normalise the binary to the expected name *inside* the staging directory so the
@@ -328,61 +333,113 @@ private struct BrowserStackCLIDownloader {
}
}
-#if !os(Windows)
- private func extractWithBsdtar(from url: URL, into directory: URL) throws {
- if url.isFileURL {
- try extractLocalArchive(at: url, into: directory)
- } else {
- try extractRemoteArchive(from: url, into: directory)
+ /// DEVA11Y-473/474: verify the downloaded CLI archive against a server-published
+ /// SHA-256 sidecar (`.sha256`) before it is extracted, made executable and run.
+ /// api.browserstack.com (control plane) 302-redirects to a versioned, immutable asset on
+ /// the CDN/S3 (data plane); a checksum published next to that asset lets us detect a
+ /// tampered or corrupted binary. Semantics mirror the launcher self-update: fail CLOSED on
+ /// a mismatch, fail OPEN (warn + proceed) when no sidecar is published yet, so this is
+ /// non-breaking until the SDK-assets team ships the sidecars (the server-side half of the
+ /// fix). This is a download-integrity check, NOT an authenticity signature.
+ private func verifyArchiveChecksum(archiveURL: URL, resolvedURL: URL) async throws {
+ // Derive the sidecar from the asset's scheme/host/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.
+ var sidecarComponents = URLComponents(url: resolvedURL, resolvingAgainstBaseURL: false)
+ sidecarComponents?.query = nil
+ sidecarComponents?.fragment = nil
+ guard let strippedURL = sidecarComponents?.url,
+ let sidecarURL = URL(string: strippedURL.absoluteString + ".sha256") else {
+ Diagnostics.remark("BrowserStackAccessibilityLint: could not derive checksum URL; skipping integrity check (DEVA11Y-473/474).")
+ return
}
- }
-
- private func extractRemoteArchive(from url: URL, into directory: URL) throws {
- let pipe = Pipe()
-
- let curl = Process()
- curl.executableURL = URL(fileURLWithPath: "/usr/bin/env")
- curl.arguments = ["curl", "-fsSL", url.absoluteString]
- curl.standardOutput = pipe
- let curlError = Pipe()
- curl.standardError = curlError
-
- let bsdtar = Process()
- bsdtar.executableURL = URL(fileURLWithPath: "/usr/bin/env")
- bsdtar.arguments = ["bsdtar", "-xpf", "-", "-C", directory.path]
- bsdtar.standardInput = pipe
- let tarError = Pipe()
- bsdtar.standardError = tarError
-
+ var request = URLRequest(url: sidecarURL)
+ request.httpShouldHandleCookies = false
+ request.timeoutInterval = 30
+ let body: Data
do {
- try bsdtar.run()
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
+ Diagnostics.remark("BrowserStackAccessibilityLint: no published checksum at \(sidecarURL.absoluteString); proceeding WITHOUT integrity verification (DEVA11Y-473/474).")
+ return
+ }
+ body = data
} catch {
- throw PluginError("Unable to launch bsdtar: \(error.localizedDescription)")
+ Diagnostics.remark("BrowserStackAccessibilityLint: checksum fetch failed (\(error.localizedDescription)); proceeding WITHOUT integrity verification (DEVA11Y-473/474).")
+ return
+ }
+ // A published sidecar that is present but empty/unreadable is treated as a hard failure:
+ // once the server publishes checksums, a missing value must not silently downgrade to
+ // "no verification".
+ guard let text = String(data: body, encoding: .utf8),
+ let expected = text.split(whereSeparator: { $0 == " " || $0 == "\n" || $0 == "\r" || $0 == "\t" }).first.map(String.init),
+ !expected.isEmpty else {
+ throw PluginError("BrowserStack CLI checksum sidecar was empty or unreadable; refusing to use the downloaded binary.")
+ }
+ // 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. Fail OPEN rather than turning
+ // its first token into the "expected hash" and hard-failing every client on every
+ // run (DEVA11Y-473/474 review).
+ guard expected.count == 64, expected.allSatisfy({ $0.isHexDigit }) else {
+ Diagnostics.remark("BrowserStackAccessibilityLint: malformed checksum at \(sidecarURL.absoluteString); proceeding WITHOUT integrity verification (DEVA11Y-473/474).")
+ return
}
+ let actual = try sha256Hex(of: archiveURL)
+ guard actual.caseInsensitiveCompare(expected) == .orderedSame else {
+ throw PluginError("BrowserStack CLI checksum mismatch; refusing to use the downloaded binary.\n expected: \(expected)\n actual: \(actual)")
+ }
+ }
+ /// SHA-256 of a file as a lowercase hex string. On Unix (macOS/Linux) it shells out to
+ /// `shasum`/`sha256sum`; on Windows it uses PowerShell's built-in `Get-FileHash`. This
+ /// avoids pulling CryptoKit/swift-crypto into the plugin (CryptoKit is Apple-only) while
+ /// still verifying on every platform the plugin builds for (DEVA11Y-473/474 review).
+ private func sha256Hex(of fileURL: URL) throws -> String {
+ let process = Process()
+ let launchName: String
+ #if os(Windows)
+ // Windows ships no shasum/sha256sum; Get-FileHash is the built-in equivalent. The
+ // archive is a UUID-named temp file under the cache root, so single-quoting the
+ // literal path is safe (no embedded quotes to escape).
+ launchName = "powershell.exe"
+ process.executableURL = URL(fileURLWithPath: "powershell.exe")
+ process.arguments = ["-NoProfile", "-Command", "(Get-FileHash -Algorithm SHA256 -LiteralPath '\(fileURL.path)').Hash"]
+ #else
+ let tool: String
+ let toolArgs: [String]
+ if fileManager.isExecutableFile(atPath: "/usr/bin/shasum") || fileManager.isExecutableFile(atPath: "/bin/shasum") {
+ tool = "shasum"
+ toolArgs = ["-a", "256", fileURL.path]
+ } else {
+ tool = "sha256sum"
+ toolArgs = [fileURL.path]
+ }
+ launchName = tool
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
+ process.arguments = [tool] + toolArgs
+ #endif
+ let out = Pipe()
+ process.standardOutput = out
+ let err = Pipe()
+ process.standardError = err
do {
- try curl.run()
+ try process.run()
} catch {
- bsdtar.terminate()
- bsdtar.waitUntilExit()
- throw PluginError("Unable to launch curl: \(error.localizedDescription)")
+ throw PluginError("Unable to launch \(launchName) to verify the downloaded archive: \(error.localizedDescription)")
}
-
- curl.waitUntilExit()
- pipe.fileHandleForWriting.closeFile()
- bsdtar.waitUntilExit()
-
- if curl.terminationStatus != 0 {
- let message = String(data: curlError.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- forwardExit(code: curl.terminationStatus, message: message)
+ process.waitUntilExit()
+ guard process.terminationStatus == 0 else {
+ let message = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ throw PluginError("Failed to compute SHA-256 of the downloaded archive: \(message.isEmpty ? launchName + " exited \(process.terminationStatus)" : message)")
}
-
- guard bsdtar.terminationReason == .exit, bsdtar.terminationStatus == 0 else {
- let message = String(data: tarError.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- forwardExit(code: bsdtar.terminationStatus, message: message.isEmpty ? "bsdtar failed to extract BrowserStack CLI." : message)
+ let output = String(data: out.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
+ guard let hash = output.split(whereSeparator: { $0 == " " || $0 == "\n" || $0 == "\r" || $0 == "\t" }).first.map(String.init), !hash.isEmpty else {
+ throw PluginError("Could not parse SHA-256 output for the downloaded archive.")
}
+ return hash
}
+ #if !os(Windows)
private func extractLocalArchive(at archiveURL: URL, into directory: URL) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
@@ -514,7 +571,6 @@ private struct BrowserStackCLIDownloader {
return finalURL
}
- #if os(Windows)
private func download(from url: URL, to destination: URL) async throws {
if url.isFileURL {
if fileManager.fileExists(atPath: destination.path) {
@@ -534,6 +590,7 @@ private struct BrowserStackCLIDownloader {
try fileManager.moveItem(at: tempURL, to: destination)
}
+ #if os(Windows)
private func unzip(archive: URL, into destination: URL) throws {
let powershell = Process()
powershell.executableURL = URL(fileURLWithPath: "powershell")
diff --git a/README.md b/README.md
index 64ef9b5..c1626f5 100644
--- a/README.md
+++ b/README.md
@@ -213,6 +213,8 @@ You can then edit the `.git/hooks/pre-commit` file to customise the registered p
## Updating the launcher script
The launcher scripts no longer update themselves automatically on every run. To pull the latest launcher on demand, run the `self-update` subcommand — the download is checked against a published SHA-256 checksum (an integrity check against corruption in transit, not an authenticity signature, since the script and its checksum share one origin) and is only applied if it matches.
+The BrowserStack CLI binary the scripts and the SwiftPM plugin download is verified the same way: when a `.sha256` checksum is published next to the binary, the download is checked against it (over HTTPS, on macOS/Linux/Windows) before it is extracted, made executable, or run, and a mismatch aborts. This is an integrity check, not an authenticity signature. Until that server-side checksum is published the verification is inert by design — the binary still downloads and runs — so it hardens the download as defense-in-depth rather than being a guarantee that holds today.
+
Zsh
```zsh
./browserstack-a11y-scan-spm-zsh.sh self-update
diff --git a/scripts/bash/cli.sh b/scripts/bash/cli.sh
index 48bf59b..5912a57 100644
--- a/scripts/bash/cli.sh
+++ b/scripts/bash/cli.sh
@@ -190,9 +190,72 @@ strip_quarantine() {
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:-}" >&2
+ rm -f -- "$zip_path"
+ return 2
+ fi
+}
+
download_binary() {
- curl -R -z "$BINARY_ZIP_PATH" -L "https://api.browserstack.com/sdk/v1/download_cli?os=${OS}&os_arch=${ARCH}" -o "$BINARY_ZIP_PATH"
- bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0755 "$BINARY_PATH" && strip_quarantine
+ local resolved_url
+ resolved_url=$(curl -fR -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}') || {
+ echo "CLI download failed." >&2
+ return 1
+ }
+ 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).
+ bsdtar -xvf "$BINARY_ZIP_PATH" -O > "${BINARY_PATH}.tmp" \
+ && chmod 0755 "${BINARY_PATH}.tmp" \
+ && mv -f "${BINARY_PATH}.tmp" "$BINARY_PATH" \
+ && strip_quarantine
}
# Self-update is opt-in (DEVA11Y-475): it runs only via the explicit `self-update`
@@ -215,5 +278,7 @@ if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then
exit 0
fi
-download_binary
+# Abort before executing the CLI if the download or its integrity check failed
+# (checksum mismatch returns 2 from download_binary -> DEVA11Y-473/474).
+download_binary || exit $?
a11y_scan
diff --git a/scripts/bash/cli.sh.sha256 b/scripts/bash/cli.sh.sha256
index f898cc6..089b5d5 100644
--- a/scripts/bash/cli.sh.sha256
+++ b/scripts/bash/cli.sh.sha256
@@ -1 +1 @@
-2dc6f5c62109ff1ae5185c417ea3896e4bb9f326ce91764e87161d1d27f976fb cli.sh
+14b7e853e5cbd233aa402a6be434cd860ee7cc4037f0e653752d6867c99bb7f2 cli.sh
diff --git a/scripts/fish/cli.sh b/scripts/fish/cli.sh
index c1db097..5ad931f 100644
--- a/scripts/fish/cli.sh
+++ b/scripts/fish/cli.sh
@@ -202,9 +202,72 @@ strip_quarantine() {
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:-}" >&2
+ rm -f -- "$zip_path"
+ return 2
+ fi
+}
+
download_binary() {
- curl -R -z "$BINARY_ZIP_PATH" -L "https://api.browserstack.com/sdk/v1/download_cli?os=${OS}&os_arch=${ARCH}" -o "$BINARY_ZIP_PATH"
- bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0755 "$BINARY_PATH" && strip_quarantine
+ local resolved_url
+ resolved_url=$(curl -fR -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}') || {
+ echo "CLI download failed." >&2
+ return 1
+ }
+ 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).
+ bsdtar -xvf "$BINARY_ZIP_PATH" -O > "${BINARY_PATH}.tmp" \
+ && chmod 0755 "${BINARY_PATH}.tmp" \
+ && mv -f "${BINARY_PATH}.tmp" "$BINARY_PATH" \
+ && strip_quarantine
}
# Self-update is opt-in (DEVA11Y-475): it runs only via the explicit `self-update`
@@ -227,6 +290,8 @@ if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then
exit 0
fi
-download_binary
+# Abort before executing the CLI if the download or its integrity check failed
+# (checksum mismatch returns 2 from download_binary -> DEVA11Y-473/474).
+download_binary || exit $?
a11y_scan
diff --git a/scripts/fish/cli.sh.sha256 b/scripts/fish/cli.sh.sha256
index 480886b..bad1cac 100644
--- a/scripts/fish/cli.sh.sha256
+++ b/scripts/fish/cli.sh.sha256
@@ -1 +1 @@
-6a83801b611b3550f46c91daeaeaa8233644d8edd0092e4dfd92098d268e63d4 cli.sh
+0d2ca5760c849521d4d5a74fc418c168f76ed129af4a6c1f763003b50a3a4f51 cli.sh
diff --git a/scripts/zsh/cli.sh b/scripts/zsh/cli.sh
index af319e4..1d554d8 100644
--- a/scripts/zsh/cli.sh
+++ b/scripts/zsh/cli.sh
@@ -201,9 +201,72 @@ strip_quarantine() {
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:-}" >&2
+ rm -f -- "$zip_path"
+ return 2
+ fi
+}
+
download_binary() {
- curl -R -z "$BINARY_ZIP_PATH" -L "https://api.browserstack.com/sdk/v1/download_cli?os=${OS}&os_arch=${ARCH}" -o "$BINARY_ZIP_PATH"
- bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0755 "$BINARY_PATH" && strip_quarantine
+ local resolved_url
+ resolved_url=$(curl -fR -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}') || {
+ echo "CLI download failed." >&2
+ return 1
+ }
+ 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).
+ bsdtar -xvf "$BINARY_ZIP_PATH" -O > "${BINARY_PATH}.tmp" \
+ && chmod 0755 "${BINARY_PATH}.tmp" \
+ && mv -f "${BINARY_PATH}.tmp" "$BINARY_PATH" \
+ && strip_quarantine
}
# Self-update is opt-in (DEVA11Y-475): it runs only via the explicit `self-update`
@@ -226,7 +289,9 @@ if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then
exit 0
fi
-download_binary
+# Abort before executing the CLI if the download or its integrity check failed
+# (checksum mismatch returns 2 from download_binary -> DEVA11Y-473/474).
+download_binary || exit $?
a11y_scan
diff --git a/scripts/zsh/cli.sh.sha256 b/scripts/zsh/cli.sh.sha256
index 912f7f7..c161ba4 100644
--- a/scripts/zsh/cli.sh.sha256
+++ b/scripts/zsh/cli.sh.sha256
@@ -1 +1 @@
-0f6344ba1db459bfa34bde971294215e883649e3107842cb585accf83349c462 cli.sh
+aeb2333296f5b2b25c48a420abd0e89834fc3bf1b2e73acf782f223041dc3edf cli.sh