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