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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions .github/workflows/spm-smoke-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<Error><Code>AccessDenied</Code></Error>' > srv/asset.zip.sha256
check "malformed 200 (error page)" 0 kept "$BASE"
kill "$(cat "$WORK/pid")" 2>/dev/null || true
echo "----"; echo "PASS=$pass FAIL=$fail"
[ "$fail" -eq 0 ]

# Positive assertion that the SERVER half (DEVA11Y-473/474) has shipped: resolves the
# real (unauthenticated) download redirect to the versioned asset and probes the
# <asset>.sha256 sidecar the client verifies against. Advisory (::warning) today because
# the sidecars are not published yet and verification is inert by design; flip the marked
# line to a hard failure once SDK-assets publishes them so this proves verification runs.
sidecar-availability:
name: CLI checksum sidecar published (advisory until server half ships)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Assert <asset>.sha256 resolves to a 64-hex digest
run: |
set -uo pipefail
resolved=$(curl -fsSL -o /dev/null -w '%{url_effective}' \
"https://api.browserstack.com/sdk/v1/download_cli?os=macos&os_arch=arm64" || true)
if [ -z "$resolved" ]; then
echo "::warning::Could not resolve the CLI asset URL; skipping sidecar availability check."
exit 0
fi
sum_url="${resolved%%\?*}.sha256"
code=$(curl -fsSL -o body.txt -w '%{http_code}' "$sum_url" || echo 000)
# `|| true`: on the expected 403 (server half not shipped) curl writes no
# body.txt, so awk exits non-zero and pipefail propagates it — which under
# the GHA-injected `bash -e` would abort the step and turn this advisory
# ::warning into a red check. Keep it advisory until the sidecars ship.
first=$(awk '{print $1; exit}' body.txt 2>/dev/null | tr 'A-Z' 'a-z' || true)
if [ "$code" = "200" ] && printf '%s' "$first" | grep -Eq '^[0-9a-f]{64}$'; then
echo "Sidecar published and well-formed at ${sum_url}"
else
# TODO(DEVA11Y-473/474 server half): change the next block to `exit 1` once
# SDK-assets publishes <asset>.sha256 (public-read). Until then verification
# is inert, so this stays advisory rather than red-blocking every PR.
echo "::warning::No well-formed checksum sidecar at ${sum_url} yet (HTTP ${code}); CLI binary integrity verification is INERT until the server half ships. Flip this check to a hard failure once sidecars are published."
fi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (`<asset>.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")
Expand Down Expand Up @@ -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) {
Expand All @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<asset>.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
Expand Down
Loading
Loading