From af769a2404e60676989213150df0692847400783 Mon Sep 17 00:00:00 2001 From: Crash0v3rrid3 Date: Wed, 26 Aug 2026 16:26:22 +0530 Subject: [PATCH 1/4] fix(cli): verify downloaded CLI binary integrity before exec (DEVA11Y-473/474) F-001 (Swift plugin) and F-002 (launcher scripts) flagged that the CLI binary was downloaded and executed with no integrity check. The plaintext-HTTP half was already fixed (HTTPS enforced); this addresses the remaining "without integrity check" half. Both download paths now fetch a server-published SHA-256 sidecar (`.sha256`) and verify the archive before it is extracted, chmod'd and run: - Shell (bash/zsh/fish cli.sh): download_binary captures the resolved, versioned asset URL (curl -w %{url_effective}), verify_binary_integrity checks it against the sidecar, and the invocation now aborts (exit) on failure instead of falling through to exec. - Swift plugin: both platforms now download to a temp file, verify, then extract. This removes the streaming `curl | bsdtar` path (extractRemoteArchive) that left no opportunity to check the payload. SHA-256 is computed via shasum/sha256sum to avoid adding CryptoKit/swift-crypto (Apple-only). Semantics mirror the existing self-update verification: fail CLOSED on a checksum mismatch, fail OPEN (warn + proceed) when no sidecar is published, so this is non-breaking until the SDK-assets team publishes the sidecars. Regenerated cli.sh.sha256 for bash/zsh/fish (self-update checksum gate). Co-Authored-By: Claude Opus 4.8 --- .../BrowserStackAccessibilityLint.swift | 129 +++++++++++------- scripts/bash/cli.sh | 44 +++++- scripts/bash/cli.sh.sha256 | 2 +- scripts/fish/cli.sh | 44 +++++- scripts/fish/cli.sh.sha256 | 2 +- scripts/zsh/cli.sh | 44 +++++- scripts/zsh/cli.sh.sha256 | 2 +- 7 files changed, 209 insertions(+), 58 deletions(-) diff --git a/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift index 506e223..789c502 100644 --- a/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift +++ b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift @@ -245,18 +245,24 @@ 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) + #if !os(Windows) + // Verify BEFORE extraction/exec. 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) + #endif 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 @@ -329,58 +335,83 @@ 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 { + guard let sidecarURL = URL(string: resolvedURL.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.") + } + 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, via the platform `shasum`/`sha256sum` + /// tool. Matches the launcher scripts and avoids pulling CryptoKit/swift-crypto into the + /// plugin (CryptoKit is Apple-only; this path also serves Linux). + private func sha256Hex(of fileURL: URL) throws -> String { + 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] + } + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [tool] + toolArgs + 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 \(tool) 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 ? tool + " 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 } private func extractLocalArchive(at archiveURL: URL, into directory: URL) throws { @@ -514,7 +545,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 +564,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/scripts/bash/cli.sh b/scripts/bash/cli.sh index 48bf59b..96e8a4e 100644 --- a/scripts/bash/cli.sh +++ b/scripts/bash/cli.sh @@ -190,8 +190,46 @@ 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 + 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") + actual=$(_self_update_sha256 "$zip_path") + if [[ -z "$expected" || -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" + local resolved_url + resolved_url=$(curl -R -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 $? bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0755 "$BINARY_PATH" && strip_quarantine } @@ -215,5 +253,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..a6fd7f2 100644 --- a/scripts/bash/cli.sh.sha256 +++ b/scripts/bash/cli.sh.sha256 @@ -1 +1 @@ -2dc6f5c62109ff1ae5185c417ea3896e4bb9f326ce91764e87161d1d27f976fb cli.sh +cb34d2d9e67f9549c97cebc21dd12ebe9079162bfa9ca17b69cf572032c9f0e2 cli.sh diff --git a/scripts/fish/cli.sh b/scripts/fish/cli.sh index c1db097..55ff305 100644 --- a/scripts/fish/cli.sh +++ b/scripts/fish/cli.sh @@ -202,8 +202,46 @@ 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 + 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") + actual=$(_self_update_sha256 "$zip_path") + if [[ -z "$expected" || -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" + local resolved_url + resolved_url=$(curl -R -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 $? bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0755 "$BINARY_PATH" && strip_quarantine } @@ -227,6 +265,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..544cc28 100644 --- a/scripts/fish/cli.sh.sha256 +++ b/scripts/fish/cli.sh.sha256 @@ -1 +1 @@ -6a83801b611b3550f46c91daeaeaa8233644d8edd0092e4dfd92098d268e63d4 cli.sh +cbad468de30213134881e329b848b8484fbea01cf63c9fa7292d7ac8ae4d61df cli.sh diff --git a/scripts/zsh/cli.sh b/scripts/zsh/cli.sh index af319e4..c96d53f 100644 --- a/scripts/zsh/cli.sh +++ b/scripts/zsh/cli.sh @@ -201,8 +201,46 @@ 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 + 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") + actual=$(_self_update_sha256 "$zip_path") + if [[ -z "$expected" || -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" + local resolved_url + resolved_url=$(curl -R -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 $? bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0755 "$BINARY_PATH" && strip_quarantine } @@ -226,7 +264,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..3341eab 100644 --- a/scripts/zsh/cli.sh.sha256 +++ b/scripts/zsh/cli.sh.sha256 @@ -1 +1 @@ -0f6344ba1db459bfa34bde971294215e883649e3107842cb585accf83349c462 cli.sh +2653d8f5c80c71cbc2eefad671bc710e4600790ea651eecb876f5b4872f793fb cli.sh From 4bf9b239c088ea151be7873d1692c2a3363d0669 Mon Sep 17 00:00:00 2001 From: Crash0v3rrid3 Date: Wed, 26 Aug 2026 16:44:44 +0530 Subject: [PATCH 2/4] fix(review): harden binary integrity checks (DEVA11Y-473/474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses code-review findings on the integrity-verification change: - Derive the SHA-256 sidecar from the asset scheme/host/path only, stripping any query string, in both the Swift plugin and the three cli.sh launchers. A signed/presigned asset URL (…zip?token=) would otherwise derive a permanently-404 sidecar (…zip?token=.sha256) and silently disable verification even after checksums are published. (adversarial reviewer) - Compare checksums case-insensitively in the shell path (lowercase both sides), matching the Swift caseInsensitiveCompare, so an uppercase published hash is not a false tamper alarm. (security reviewer) - Add curl --fail to the main binary download so an HTTP error body is never persisted as the archive. (correctness/security/adversarial) Regenerated cli.sh.sha256 for bash/zsh/fish (self-update checksum gate). Not changed (accepted, documented): fail-open-when-sidecar-absent is intentional and non-breaking today; flipping to fail-closed is tracked as the server-side dependency (publish .sha256) and a follow-up. Co-Authored-By: Claude Opus 4.8 --- .../BrowserStackAccessibilityLint.swift | 9 ++++++++- scripts/bash/cli.sh | 11 +++++++---- scripts/bash/cli.sh.sha256 | 2 +- scripts/fish/cli.sh | 11 +++++++---- scripts/fish/cli.sh.sha256 | 2 +- scripts/zsh/cli.sh | 11 +++++++---- scripts/zsh/cli.sh.sha256 | 2 +- 7 files changed, 32 insertions(+), 16 deletions(-) diff --git a/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift index 789c502..81c80d0 100644 --- a/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift +++ b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift @@ -344,7 +344,14 @@ private struct BrowserStackCLIDownloader { /// 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 { - guard let sidecarURL = URL(string: resolvedURL.absoluteString + ".sha256") else { + // 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 } diff --git a/scripts/bash/cli.sh b/scripts/bash/cli.sh index 96e8a4e..73b6417 100644 --- a/scripts/bash/cli.sh +++ b/scripts/bash/cli.sh @@ -204,7 +204,10 @@ verify_binary_integrity() { echo "CLI download: could not resolve asset URL; skipping integrity check (DEVA11Y-473/474)." >&2 return 0 fi - sum_url="${resolved_url}.sha256" + # 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 @@ -212,8 +215,8 @@ verify_binary_integrity() { 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") - actual=$(_self_update_sha256 "$zip_path") + expected=$(awk '{print $1; exit}' "$tmp_sum" | tr 'A-Z' 'a-z') + actual=$(_self_update_sha256 "$zip_path" | tr 'A-Z' 'a-z') if [[ -z "$expected" || -z "$actual" || "$expected" != "$actual" ]]; then echo "CLI download: checksum mismatch; refusing to use the downloaded binary." >&2 echo " expected: ${expected:-}" >&2 @@ -225,7 +228,7 @@ verify_binary_integrity() { download_binary() { local resolved_url - resolved_url=$(curl -R -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}') || { + 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 } diff --git a/scripts/bash/cli.sh.sha256 b/scripts/bash/cli.sh.sha256 index a6fd7f2..7b49a92 100644 --- a/scripts/bash/cli.sh.sha256 +++ b/scripts/bash/cli.sh.sha256 @@ -1 +1 @@ -cb34d2d9e67f9549c97cebc21dd12ebe9079162bfa9ca17b69cf572032c9f0e2 cli.sh +794a0652df73efcf2e6a964bde2f5462f60598996a2587f57e00066446572970 cli.sh diff --git a/scripts/fish/cli.sh b/scripts/fish/cli.sh index 55ff305..ca502ee 100644 --- a/scripts/fish/cli.sh +++ b/scripts/fish/cli.sh @@ -216,7 +216,10 @@ verify_binary_integrity() { echo "CLI download: could not resolve asset URL; skipping integrity check (DEVA11Y-473/474)." >&2 return 0 fi - sum_url="${resolved_url}.sha256" + # 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 @@ -224,8 +227,8 @@ verify_binary_integrity() { 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") - actual=$(_self_update_sha256 "$zip_path") + expected=$(awk '{print $1; exit}' "$tmp_sum" | tr 'A-Z' 'a-z') + actual=$(_self_update_sha256 "$zip_path" | tr 'A-Z' 'a-z') if [[ -z "$expected" || -z "$actual" || "$expected" != "$actual" ]]; then echo "CLI download: checksum mismatch; refusing to use the downloaded binary." >&2 echo " expected: ${expected:-}" >&2 @@ -237,7 +240,7 @@ verify_binary_integrity() { download_binary() { local resolved_url - resolved_url=$(curl -R -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}') || { + 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 } diff --git a/scripts/fish/cli.sh.sha256 b/scripts/fish/cli.sh.sha256 index 544cc28..e7a826c 100644 --- a/scripts/fish/cli.sh.sha256 +++ b/scripts/fish/cli.sh.sha256 @@ -1 +1 @@ -cbad468de30213134881e329b848b8484fbea01cf63c9fa7292d7ac8ae4d61df cli.sh +aee8b78eeba1ef304f934281ae20a5633cd49aca8e3627295a46308259d4db40 cli.sh diff --git a/scripts/zsh/cli.sh b/scripts/zsh/cli.sh index c96d53f..b4307d3 100644 --- a/scripts/zsh/cli.sh +++ b/scripts/zsh/cli.sh @@ -215,7 +215,10 @@ verify_binary_integrity() { echo "CLI download: could not resolve asset URL; skipping integrity check (DEVA11Y-473/474)." >&2 return 0 fi - sum_url="${resolved_url}.sha256" + # 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 @@ -223,8 +226,8 @@ verify_binary_integrity() { 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") - actual=$(_self_update_sha256 "$zip_path") + expected=$(awk '{print $1; exit}' "$tmp_sum" | tr 'A-Z' 'a-z') + actual=$(_self_update_sha256 "$zip_path" | tr 'A-Z' 'a-z') if [[ -z "$expected" || -z "$actual" || "$expected" != "$actual" ]]; then echo "CLI download: checksum mismatch; refusing to use the downloaded binary." >&2 echo " expected: ${expected:-}" >&2 @@ -236,7 +239,7 @@ verify_binary_integrity() { download_binary() { local resolved_url - resolved_url=$(curl -R -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}') || { + 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 } diff --git a/scripts/zsh/cli.sh.sha256 b/scripts/zsh/cli.sh.sha256 index 3341eab..26677d6 100644 --- a/scripts/zsh/cli.sh.sha256 +++ b/scripts/zsh/cli.sh.sha256 @@ -1 +1 @@ -2653d8f5c80c71cbc2eefad671bc710e4600790ea651eecb876f5b4872f793fb cli.sh +0d911f3eb3234948f3abe33bee86800378f63443678bbd587db4cae042296c2c cli.sh From 3e0ff693e684cadf6f83914e7a0bd205bd53a493 Mon Sep 17 00:00:00 2001 From: Crash0v3rrid3 Date: Wed, 26 Aug 2026 18:28:40 +0530 Subject: [PATCH 3/4] =?UTF-8?q?fix(review):=20address=20PR=20#37=20review?= =?UTF-8?q?=20=E2=80=94=20Windows=20verification,=20atomic=20extract,=20CI?= =?UTF-8?q?=20gates=20(DEVA11Y-473/474)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves Nishant Maurya's review on PR #37. - Windows was left unverified: verifyArchiveChecksum/sha256Hex were inside `#if !os(Windows)` and the call site was guarded. Move both out of the guard, make the call site unconditional, and add a Windows sha256Hex branch using PowerShell Get-FileHash. Only extractLocalArchive stays Unix-only (Windows uses unzip). - Extraction no longer truncates a good cached binary: bsdtar now writes to "${BINARY_PATH}.tmp" and is atomically `mv -f`'d into place, so a corrupt payload (the live case today) can't zero out a previously-good binary. All 3 shells. - Hash shape validation: a non-empty sidecar body that isn't a 64-hex digest (e.g. an S3 AccessDenied XML answered 200) now fails OPEN instead of hard-failing every client; an empty body still fails CLOSED. Applied in all 3 shells and the Swift plugin. - CI: add a functional verify_binary_integrity matrix job (fail-open/closed, python fixture) so a broken control produces red signal even while verification is inert, and an advisory sidecar-availability probe (::warning until the server half ships). - Regenerate cli.sh.sha256 self-update sidecars; README documents the new binary gate. Server-side dependency (SDK-assets publishing .sha256) still blocks closing the tickets; keep the PR open. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/spm-smoke-test.yml | 84 +++++++++++++++++++ .../BrowserStackAccessibilityLint.swift | 39 ++++++--- README.md | 2 + scripts/bash/cli.sh | 28 ++++++- scripts/bash/cli.sh.sha256 | 2 +- scripts/fish/cli.sh | 28 ++++++- scripts/fish/cli.sh.sha256 | 2 +- scripts/zsh/cli.sh | 28 ++++++- scripts/zsh/cli.sh.sha256 | 2 +- 9 files changed, 193 insertions(+), 22 deletions(-) diff --git a/.github/workflows/spm-smoke-test.yml b/.github/workflows/spm-smoke-test.yml index 572468e..7c7d9b7 100644 --- a/.github/workflows/spm-smoke-test.yml +++ b/.github/workflows/spm-smoke-test.yml @@ -122,3 +122,87 @@ 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 + 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=$? + 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) + first=$(awk '{print $1; exit}' body.txt 2>/dev/null | tr 'A-Z' 'a-z') + 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 81c80d0..34c74dc 100644 --- a/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift +++ b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift @@ -253,11 +253,10 @@ private struct BrowserStackCLIDownloader { let archiveURL = cacheRoot.appendingPathComponent(".tmp.\(info.version).\(UUID().uuidString).zip") defer { try? fileManager.removeItem(at: archiveURL) } try await download(from: info.resolvedURL, to: archiveURL) - #if !os(Windows) - // Verify BEFORE extraction/exec. Streaming curl | bsdtar straight to disk (the old + // 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) - #endif Diagnostics.remark("BrowserStackAccessibilityLint: Extracting CLI \(info.version)...") #if os(Windows) try unzip(archive: archiveURL, into: stagingDirectory) @@ -334,7 +333,6 @@ private struct BrowserStackCLIDownloader { } } -#if !os(Windows) /// 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 @@ -378,16 +376,35 @@ private struct BrowserStackCLIDownloader { !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, via the platform `shasum`/`sha256sum` - /// tool. Matches the launcher scripts and avoids pulling CryptoKit/swift-crypto into the - /// plugin (CryptoKit is Apple-only; this path also serves Linux). + /// 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") { @@ -397,9 +414,10 @@ private struct BrowserStackCLIDownloader { tool = "sha256sum" toolArgs = [fileURL.path] } - let process = Process() + launchName = tool process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = [tool] + toolArgs + #endif let out = Pipe() process.standardOutput = out let err = Pipe() @@ -407,12 +425,12 @@ private struct BrowserStackCLIDownloader { do { try process.run() } catch { - throw PluginError("Unable to launch \(tool) to verify the downloaded archive: \(error.localizedDescription)") + throw PluginError("Unable to launch \(launchName) to verify the downloaded archive: \(error.localizedDescription)") } 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 ? tool + " exited \(process.terminationStatus)" : message)") + throw PluginError("Failed to compute SHA-256 of the downloaded archive: \(message.isEmpty ? launchName + " exited \(process.terminationStatus)" : 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 { @@ -421,6 +439,7 @@ private struct BrowserStackCLIDownloader { 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") 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 73b6417..5912a57 100644 --- a/scripts/bash/cli.sh +++ b/scripts/bash/cli.sh @@ -217,9 +217,23 @@ verify_binary_integrity() { fi expected=$(awk '{print $1; exit}' "$tmp_sum" | tr 'A-Z' 'a-z') actual=$(_self_update_sha256 "$zip_path" | tr 'A-Z' 'a-z') - if [[ -z "$expected" || -z "$actual" || "$expected" != "$actual" ]]; then + # 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 " expected: ${expected}" >&2 echo " actual: ${actual:-}" >&2 rm -f -- "$zip_path" return 2 @@ -233,7 +247,15 @@ download_binary() { return 1 } verify_binary_integrity "$BINARY_ZIP_PATH" "$resolved_url" || return $? - bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0755 "$BINARY_PATH" && strip_quarantine + # 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` diff --git a/scripts/bash/cli.sh.sha256 b/scripts/bash/cli.sh.sha256 index 7b49a92..089b5d5 100644 --- a/scripts/bash/cli.sh.sha256 +++ b/scripts/bash/cli.sh.sha256 @@ -1 +1 @@ -794a0652df73efcf2e6a964bde2f5462f60598996a2587f57e00066446572970 cli.sh +14b7e853e5cbd233aa402a6be434cd860ee7cc4037f0e653752d6867c99bb7f2 cli.sh diff --git a/scripts/fish/cli.sh b/scripts/fish/cli.sh index ca502ee..5ad931f 100644 --- a/scripts/fish/cli.sh +++ b/scripts/fish/cli.sh @@ -229,9 +229,23 @@ verify_binary_integrity() { fi expected=$(awk '{print $1; exit}' "$tmp_sum" | tr 'A-Z' 'a-z') actual=$(_self_update_sha256 "$zip_path" | tr 'A-Z' 'a-z') - if [[ -z "$expected" || -z "$actual" || "$expected" != "$actual" ]]; then + # 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 " expected: ${expected}" >&2 echo " actual: ${actual:-}" >&2 rm -f -- "$zip_path" return 2 @@ -245,7 +259,15 @@ download_binary() { return 1 } verify_binary_integrity "$BINARY_ZIP_PATH" "$resolved_url" || return $? - bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0755 "$BINARY_PATH" && strip_quarantine + # 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` diff --git a/scripts/fish/cli.sh.sha256 b/scripts/fish/cli.sh.sha256 index e7a826c..bad1cac 100644 --- a/scripts/fish/cli.sh.sha256 +++ b/scripts/fish/cli.sh.sha256 @@ -1 +1 @@ -aee8b78eeba1ef304f934281ae20a5633cd49aca8e3627295a46308259d4db40 cli.sh +0d2ca5760c849521d4d5a74fc418c168f76ed129af4a6c1f763003b50a3a4f51 cli.sh diff --git a/scripts/zsh/cli.sh b/scripts/zsh/cli.sh index b4307d3..1d554d8 100644 --- a/scripts/zsh/cli.sh +++ b/scripts/zsh/cli.sh @@ -228,9 +228,23 @@ verify_binary_integrity() { fi expected=$(awk '{print $1; exit}' "$tmp_sum" | tr 'A-Z' 'a-z') actual=$(_self_update_sha256 "$zip_path" | tr 'A-Z' 'a-z') - if [[ -z "$expected" || -z "$actual" || "$expected" != "$actual" ]]; then + # 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 " expected: ${expected}" >&2 echo " actual: ${actual:-}" >&2 rm -f -- "$zip_path" return 2 @@ -244,7 +258,15 @@ download_binary() { return 1 } verify_binary_integrity "$BINARY_ZIP_PATH" "$resolved_url" || return $? - bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0755 "$BINARY_PATH" && strip_quarantine + # 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` diff --git a/scripts/zsh/cli.sh.sha256 b/scripts/zsh/cli.sh.sha256 index 26677d6..c161ba4 100644 --- a/scripts/zsh/cli.sh.sha256 +++ b/scripts/zsh/cli.sh.sha256 @@ -1 +1 @@ -0d911f3eb3234948f3abe33bee86800378f63443678bbd587db4cae042296c2c cli.sh +aeb2333296f5b2b25c48a420abd0e89834fc3bf1b2e73acf782f223041dc3edf cli.sh From 66249feb89b84e0b7d0e9202891953bb5661b18c Mon Sep 17 00:00:00 2001 From: Rishabh Jain <43724509+Crash0v3rrid3@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:13:12 +0530 Subject: [PATCH 4/4] fix(ci): make binary-integrity CI jobs survive GHA-injected `bash -e` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The functional-matrix and sidecar-availability jobs both intentionally trigger non-zero exits (an intended checksum mismatch; the expected 403 until the server half ships). GitHub's default `shell: bash -e {0}` aborted both before their real gate ran — `set -uo pipefail` doesn't cancel the harness `-e`. Passed locally only because they were run as plain `bash script` (no -e). - verify-integrity-fn: `out=$(...) && rc=0 || rc=$?` so the intended rc=2 "wrong checksum" case no longer aborts the step before the `[ "$fail" -eq 0 ]` gate. - sidecar-availability: `|| true` on the awk|tr pipe so a missing body.txt (expected on the 403) doesn't turn the advisory ::warning into a red check. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/spm-smoke-test.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/spm-smoke-test.yml b/.github/workflows/spm-smoke-test.yml index 7c7d9b7..f74267d 100644 --- a/.github/workflows/spm-smoke-test.yml +++ b/.github/workflows/spm-smoke-test.yml @@ -151,9 +151,14 @@ jobs: 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=$? + 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 @@ -197,7 +202,11 @@ jobs: fi sum_url="${resolved%%\?*}.sha256" code=$(curl -fsSL -o body.txt -w '%{http_code}' "$sum_url" || echo 000) - first=$(awk '{print $1; exit}' body.txt 2>/dev/null | tr 'A-Z' 'a-z') + # `|| 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