From 1483c93215595e3789c8a2d8dcd5e15e844360a9 Mon Sep 17 00:00:00 2001 From: Sunny Sethi Date: Tue, 26 May 2026 14:28:03 +0530 Subject: [PATCH 01/12] fix(security): use HTTPS for CLI binary download URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F-001 / DEVA11Y-473 — The default download URL used plaintext HTTP (CWE-319), allowing MitM to substitute a malicious binary. Switch to HTTPS to enforce TLS. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../BrowserStackAccessibilityLint.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift index 117e362..1f54216 100644 --- a/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift +++ b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift @@ -339,7 +339,7 @@ private struct BrowserStackCLIDownloader { private func defaultDownloadURL() throws -> URL { let os = try currentOSName() let arch = try currentArchName() - guard let url = URL(string: "http://api.browserstack.com/sdk/v1/download_cli?os=\(os)&os_arch=\(arch)") else { + guard let url = URL(string: "https://api.browserstack.com/sdk/v1/download_cli?os=\(os)&os_arch=\(arch)") else { throw PluginError("Failed to create download URL for \(os) \(arch).") } return url From ac227207af60af6b2286661b8d97c668ddc13314 Mon Sep 17 00:00:00 2001 From: Sunny Sethi Date: Tue, 26 May 2026 14:28:11 +0530 Subject: [PATCH 02/12] fix(security): use HTTPS for binary download in shell scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F-002 / DEVA11Y-474 — All three cli.sh variants (bash, zsh, fish) downloaded the CLI binary over plaintext HTTP (CWE-319), enabling MitM binary substitution. Switch to HTTPS. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/bash/cli.sh | 2 +- scripts/fish/cli.sh | 2 +- scripts/zsh/cli.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/bash/cli.sh b/scripts/bash/cli.sh index d39f524..818d993 100644 --- a/scripts/bash/cli.sh +++ b/scripts/bash/cli.sh @@ -88,7 +88,7 @@ script_self_update() { } download_binary() { - curl -R -z "$BINARY_ZIP_PATH" -L "http://api.browserstack.com/sdk/v1/download_cli?os=${OS}&os_arch=${ARCH}" -o "$BINARY_ZIP_PATH" + 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 0775 "$BINARY_PATH" } diff --git a/scripts/fish/cli.sh b/scripts/fish/cli.sh index 6bf3d8b..e509be7 100644 --- a/scripts/fish/cli.sh +++ b/scripts/fish/cli.sh @@ -100,7 +100,7 @@ script_self_update() { } download_binary() { - curl -R -z "$BINARY_ZIP_PATH" -L "http://api.browserstack.com/sdk/v1/download_cli?os=${OS}&os_arch=${ARCH}" -o "$BINARY_ZIP_PATH" + 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 0775 "$BINARY_PATH" } diff --git a/scripts/zsh/cli.sh b/scripts/zsh/cli.sh index 697ad4e..a7e6e4c 100644 --- a/scripts/zsh/cli.sh +++ b/scripts/zsh/cli.sh @@ -99,7 +99,7 @@ script_self_update() { } download_binary() { - curl -R -z "$BINARY_ZIP_PATH" -L "http://api.browserstack.com/sdk/v1/download_cli?os=${OS}&os_arch=${ARCH}" -o "$BINARY_ZIP_PATH" + 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 0775 "$BINARY_PATH" } From 54872ace1edf9796e913536564d64297a4d22d32 Mon Sep 17 00:00:00 2001 From: Sunny Sethi Date: Tue, 26 May 2026 14:28:35 +0530 Subject: [PATCH 03/12] fix(security): restrict SPM plugin network scope to .all(ports: []) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F-012 / DEVA11Y-481 — The plugin declared unrestricted .all() network scope (CWE-250) which amplifies blast radius of other findings. Switch to .all(ports: []) matching what shell scripts already enforce. Co-Authored-By: Claude Opus 4.6 (1M context) --- Package.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 1bf5ed7..1a8b046 100644 --- a/Package.swift +++ b/Package.swift @@ -19,8 +19,7 @@ let package = Package( ), permissions: [ .allowNetworkConnections( - // scope: .all(ports: []), - scope: .all(), + scope: .all(ports: []), reason: "Please allow network connection permission to authenticate and run accessibility rules." ), .writeToPackageDirectory(reason: "Please allow writing to package directory for logging.") From 1f0dc127e5358fe0dc50042bcb39c84f3f326575 Mon Sep 17 00:00:00 2001 From: Sunny Sethi Date: Tue, 26 May 2026 14:28:41 +0530 Subject: [PATCH 04/12] fix(security): pin Semgrep CI container image by SHA digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F-004 / DEVA11Y-476 — The Semgrep workflow used an unpinned image tag (CWE-829), enabling tag-poisoning attacks. Pin to SHA256 digest. This is the chain-breaker for C-001 (DEVA11Y-485, CVSS 10.0). Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/Semgrep.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/Semgrep.yml b/.github/workflows/Semgrep.yml index 5398af9..a5a7a59 100644 --- a/.github/workflows/Semgrep.yml +++ b/.github/workflows/Semgrep.yml @@ -26,8 +26,9 @@ jobs: runs-on: ubuntu-latest container: - # A Docker image with Semgrep installed. Do not change this. - image: returntocorp/semgrep + # Pinned by digest for supply-chain integrity (DEVA11Y-476). + # To update: docker manifest inspect returntocorp/semgrep:latest + image: returntocorp/semgrep@sha256:f682953ce85e3725f4a4dd94bd7ad13e570bb6b2c7a8cf7c6e38a9eac89239b2 # Skip any PR created by dependabot to avoid permission issues: if: (github.actor != 'dependabot[bot]') From ecd1c1ab6dea1766eda5c5722c34142f25d0996a Mon Sep 17 00:00:00 2001 From: Sunny Sethi Date: Tue, 26 May 2026 14:29:26 +0530 Subject: [PATCH 05/12] fix(security): remove insecure self-update from cli.sh scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F-003 / DEVA11Y-475 — script_self_update() fetched the script from a mutable branch head with no integrity verification (CWE-494). The ^#! regex check is trivially bypassed. Remove self-update entirely; users should update via git pull or package manager. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/bash/cli.sh | 10 ---------- scripts/fish/cli.sh | 10 ---------- scripts/zsh/cli.sh | 10 ---------- 3 files changed, 30 deletions(-) diff --git a/scripts/bash/cli.sh b/scripts/bash/cli.sh index d39f524..29a17d1 100644 --- a/scripts/bash/cli.sh +++ b/scripts/bash/cli.sh @@ -78,21 +78,11 @@ a11y_scan() { $BINARY_PATH a11y $EXTRA_ARGS } -script_self_update() { - local remote_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/main/scripts/bash/cli.sh" - - updated_script=$(curl -R -z "$SCRIPT_PATH" "$remote_url") - if [[ $updated_script =~ ^#! ]]; then - echo "$updated_script" > "$SCRIPT_PATH" - fi -} - download_binary() { curl -R -z "$BINARY_ZIP_PATH" -L "http://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 0775 "$BINARY_PATH" } -script_self_update if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then register_git_hook exit 0 diff --git a/scripts/fish/cli.sh b/scripts/fish/cli.sh index 6bf3d8b..cc411dd 100644 --- a/scripts/fish/cli.sh +++ b/scripts/fish/cli.sh @@ -90,21 +90,11 @@ a11y_scan() { $BINARY_PATH a11y $EXTRA_ARGS } -script_self_update() { - local remote_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/main/scripts/fish/cli.sh" - - updated_script=$(curl -R -z "$SCRIPT_PATH" "$remote_url") - if [[ $updated_script =~ ^#! ]]; then - echo "$updated_script" > "$SCRIPT_PATH" - fi -} - download_binary() { curl -R -z "$BINARY_ZIP_PATH" -L "http://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 0775 "$BINARY_PATH" } -script_self_update if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then register_git_hook exit 0 diff --git a/scripts/zsh/cli.sh b/scripts/zsh/cli.sh index 697ad4e..80c957b 100644 --- a/scripts/zsh/cli.sh +++ b/scripts/zsh/cli.sh @@ -89,21 +89,11 @@ a11y_scan() { $BINARY_PATH a11y $EXTRA_ARGS } -script_self_update() { - local remote_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/main/scripts/zsh/cli.sh" - - updated_script=$(curl -R -z "$SCRIPT_PATH" "$remote_url") - if [[ $updated_script =~ ^#! ]]; then - echo "$updated_script" > "$SCRIPT_PATH" - fi -} - download_binary() { curl -R -z "$BINARY_ZIP_PATH" -L "http://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 0775 "$BINARY_PATH" } -script_self_update if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then register_git_hook exit 0 From bd124c0edefdfe17c28e25e299bbfc0cd3520634 Mon Sep 17 00:00:00 2001 From: Sunny Sethi Date: Tue, 26 May 2026 14:29:37 +0530 Subject: [PATCH 06/12] fix(security): pin SPM dependency to revision SHA instead of branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F-005 / DEVA11Y-477 — The generated Package.swift pinned the AccessibilityDevTools dependency to branch "main" (CWE-829), allowing any push to main to execute in the plugin sandbox. Pin to a specific revision SHA for supply-chain integrity. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/bash/spm.sh | 2 +- scripts/fish/spm.sh | 2 +- scripts/zsh/spm.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/bash/spm.sh b/scripts/bash/spm.sh index 1202e11..aa1ca7f 100644 --- a/scripts/bash/spm.sh +++ b/scripts/bash/spm.sh @@ -60,7 +60,7 @@ import PackageDescription let package = Package( name: "Dummy", dependencies: [ - .package(url: "https://github.com/browserstack/AccessibilityDevTools.git", branch: "main") + .package(url: "https://github.com/browserstack/AccessibilityDevTools.git", revision: "0428b322b00494b19e44c20c37502a0ee31af642") ], targets: [] ) diff --git a/scripts/fish/spm.sh b/scripts/fish/spm.sh index 9ac8a67..8f5cc9a 100644 --- a/scripts/fish/spm.sh +++ b/scripts/fish/spm.sh @@ -73,7 +73,7 @@ import PackageDescription let package = Package( name: "Dummy", dependencies: [ - .package(url: "https://github.com/browserstack/AccessibilityDevTools.git", branch: "main") + .package(url: "https://github.com/browserstack/AccessibilityDevTools.git", revision: "0428b322b00494b19e44c20c37502a0ee31af642") ], targets: [] ) diff --git a/scripts/zsh/spm.sh b/scripts/zsh/spm.sh index 35df10f..4f8f184 100644 --- a/scripts/zsh/spm.sh +++ b/scripts/zsh/spm.sh @@ -72,7 +72,7 @@ import PackageDescription let package = Package( name: "Dummy", dependencies: [ - .package(url: "https://github.com/browserstack/AccessibilityDevTools.git", branch: "main") + .package(url: "https://github.com/browserstack/AccessibilityDevTools.git", revision: "0428b322b00494b19e44c20c37502a0ee31af642") ], targets: [] ) From 0cfc181d6ffcf99c9214f35b71899940a280f848 Mon Sep 17 00:00:00 2001 From: Sunny Sethi Date: Tue, 26 May 2026 14:29:41 +0530 Subject: [PATCH 07/12] fix(security): remove insecure self-update from spm.sh scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F-006 / DEVA11Y-478 — script_self_update() in spm.sh scripts fetched from a mutable branch head with no integrity verification (CWE-494). Same pattern as F-003. Remove self-update entirely. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/bash/spm.sh | 10 ---------- scripts/fish/spm.sh | 10 ---------- scripts/zsh/spm.sh | 10 ---------- 3 files changed, 30 deletions(-) diff --git a/scripts/bash/spm.sh b/scripts/bash/spm.sh index 1202e11..033f57c 100644 --- a/scripts/bash/spm.sh +++ b/scripts/bash/spm.sh @@ -83,16 +83,6 @@ EOF scan $EXTRA_ARGS } -script_self_update() { - local remote_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/main/scripts/bash/spm.sh" - - updated_script=$(curl -R -z "$SCRIPT_PATH" "$remote_url") - if [[ $updated_script =~ ^#! ]]; then - echo "$updated_script" > "$SCRIPT_PATH" - fi -} - -script_self_update if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then register_git_hook exit 0 diff --git a/scripts/fish/spm.sh b/scripts/fish/spm.sh index 9ac8a67..953dad1 100644 --- a/scripts/fish/spm.sh +++ b/scripts/fish/spm.sh @@ -96,16 +96,6 @@ EOF scan $EXTRA_ARGS } -script_self_update() { - local remote_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/main/scripts/fish/spm.sh" - - updated_script=$(curl -R -z "$SCRIPT_PATH" "$remote_url") - if [[ $updated_script =~ ^#! ]]; then - echo "$updated_script" > "$SCRIPT_PATH" - fi -} - -script_self_update if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then register_git_hook exit 0 diff --git a/scripts/zsh/spm.sh b/scripts/zsh/spm.sh index 35df10f..26ecccb 100644 --- a/scripts/zsh/spm.sh +++ b/scripts/zsh/spm.sh @@ -95,16 +95,6 @@ EOF scan $EXTRA_ARGS } -script_self_update() { - local remote_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/main/scripts/zsh/spm.sh" - - updated_script=$(curl -R -z "$SCRIPT_PATH" "$remote_url") - if [[ $updated_script =~ ^#! ]]; then - echo "$updated_script" > "$SCRIPT_PATH" - fi -} - -script_self_update if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then register_git_hook exit 0 From 7e920214243e7c03a7b0952e1ca192c176aacd6e Mon Sep 17 00:00:00 2001 From: Sunny Sethi Date: Tue, 26 May 2026 14:29:48 +0530 Subject: [PATCH 08/12] fix(security): restrict download URL override to HTTPS only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F-008 / DEVA11Y-479 — parseOverride() accepted file:// URLs and bare paths (CWE-918), enabling SSRF and local-file exfiltration via bsdtar. Restrict to HTTPS-only to prevent local file access. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../BrowserStackAccessibilityLint.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift index 117e362..c1013d4 100644 --- a/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift +++ b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift @@ -100,10 +100,13 @@ private func parseOverride(urlString: String?) throws -> URL? { guard let urlString = urlString, !urlString.isEmpty else { return nil } - if let url = URL(string: urlString), let scheme = url.scheme, ["http", "https", "file"].contains(scheme.lowercased()) { - return url + guard let url = URL(string: urlString), let scheme = url.scheme else { + throw PluginError("Invalid download URL: \(urlString). Only HTTPS URLs are supported.") + } + guard scheme.lowercased() == "https" else { + throw PluginError("Unsupported URL scheme '\(scheme)' in download URL. Only HTTPS is allowed.") } - return URL(fileURLWithPath: urlString) + return url } private func sanitizeArguments(_ arguments: [String]) -> [String] { From 95f7990d7125a604c1101036c5e4897628667986 Mon Sep 17 00:00:00 2001 From: Sunny Sethi Date: Tue, 26 May 2026 14:30:16 +0530 Subject: [PATCH 09/12] fix(security): sanitize version string to prevent path traversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F-010 / DEVA11Y-480 — extractVersion() did not validate the version string parsed from HTTP redirect filenames (CWE-22). A crafted filename with ../ segments could write outside the cache directory. Add character allowlist and reject traversal sequences. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../BrowserStackAccessibilityLint.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift index 117e362..c23e564 100644 --- a/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift +++ b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift @@ -557,8 +557,13 @@ private func hardwareIdentifier() throws -> String { private func extractVersion(from url: URL) -> String? { let filename = url.deletingPathExtension().lastPathComponent if let range = filename.range(of: "-", options: .backwards) { - let version = filename[range.upperBound...] - return version.isEmpty ? nil : String(version) + let version = String(filename[range.upperBound...]) + if version.isEmpty { return nil } + // Reject path traversal and non-semver characters + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-+")) + guard version.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil } + guard !version.contains("..") else { return nil } + return version } return nil } From f00a0a3bf910fc166608e8d6ff41e84ba02ae7fa Mon Sep 17 00:00:00 2001 From: Sunny Sethi Date: Tue, 26 May 2026 14:31:03 +0530 Subject: [PATCH 10/12] fix(security): use atomic temp-dir swap in prepareArtifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F-013 / DEVA11Y-482 — prepareArtifact had a TOCTOU race (CWE-362) where the check-delete-create-download sequence left a large window for parallel builds to corrupt state. Download into a temp directory and atomically move to the version directory after completion. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../BrowserStackAccessibilityLint.swift | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift index 117e362..c901879 100644 --- a/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift +++ b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift @@ -199,33 +199,40 @@ private struct BrowserStackCLIDownloader { return BrowserStackCLIArtifact(version: info.version, executableURL: expectedExecutableURL) } - if fileManager.fileExists(atPath: versionDirectory.path) { - try fileManager.removeItem(at: versionDirectory) - } - try fileManager.createDirectory(at: versionDirectory, withIntermediateDirectories: true) - Diagnostics.remark("BrowserStackAccessibilityLint: Downloading CLI \(info.version)...") + // Download into a temporary directory to avoid TOCTOU races + let tempDirectory = cacheRoot.appendingPathComponent(".download-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: tempDirectory) } + #if os(Windows) - let archiveURL = versionDirectory.appendingPathComponent("browserstack-cli.zip") + let archiveURL = tempDirectory.appendingPathComponent("browserstack-cli.zip") try await download(from: info.resolvedURL, to: archiveURL) Diagnostics.remark("BrowserStackAccessibilityLint: Extracting CLI \(info.version)...") - try unzip(archive: archiveURL, into: versionDirectory) + try unzip(archive: archiveURL, into: tempDirectory) try? fileManager.removeItem(at: archiveURL) #else - try extractWithBsdtar(from: info.resolvedURL, into: versionDirectory) + try extractWithBsdtar(from: info.resolvedURL, into: tempDirectory) #endif - let locatedBinary = try locateExecutable(in: versionDirectory, preferredName: executableName) - let finalBinaryURL: URL - if locatedBinary.lastPathComponent == executableName { - finalBinaryURL = locatedBinary - } else { - finalBinaryURL = expectedExecutableURL - if fileManager.fileExists(atPath: finalBinaryURL.path) { - try fileManager.removeItem(at: finalBinaryURL) + let locatedBinary = try locateExecutable(in: tempDirectory, preferredName: executableName) + + // Atomically swap: remove old version dir, move temp into place + if fileManager.fileExists(atPath: versionDirectory.path) { + try fileManager.removeItem(at: versionDirectory) + } + try fileManager.moveItem(at: tempDirectory, to: versionDirectory) + + let finalBinaryURL = versionDirectory.appendingPathComponent(locatedBinary.lastPathComponent, isDirectory: false) + if locatedBinary.lastPathComponent != executableName { + let expectedURL = versionDirectory.appendingPathComponent(executableName, isDirectory: false) + if fileManager.fileExists(atPath: expectedURL.path) { + try fileManager.removeItem(at: expectedURL) } - try fileManager.moveItem(at: locatedBinary, to: finalBinaryURL) + try fileManager.moveItem(at: finalBinaryURL, to: expectedURL) + try ensureExecutablePermissions(at: expectedURL) + return BrowserStackCLIArtifact(version: info.version, executableURL: expectedURL) } try ensureExecutablePermissions(at: finalBinaryURL) From 2d72ba0cb58e5a84668b2a6ca234bfe51e66b164 Mon Sep 17 00:00:00 2001 From: Sunny Sethi Date: Tue, 26 May 2026 14:31:41 +0530 Subject: [PATCH 11/12] fix(security): use isolated temp directory in spm.sh scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F-014 / DEVA11Y-483 — Concurrent spm.sh instances shared CWD (CWE-362), causing cleanup trap to delete sibling's Package.swift. Use mktemp -d for an isolated working directory per invocation. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/bash/spm.sh | 18 ++++++++++++------ scripts/fish/spm.sh | 18 ++++++++++++------ scripts/zsh/spm.sh | 18 ++++++++++++------ 3 files changed, 36 insertions(+), 18 deletions(-) diff --git a/scripts/bash/spm.sh b/scripts/bash/spm.sh index 1202e11..f09c66b 100644 --- a/scripts/bash/spm.sh +++ b/scripts/bash/spm.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash -il -[ -f "${PWD}/Package.swift" ] -PACKAGE_EXISTS="$?" +ORIGINAL_DIR="${PWD}" +HAS_EXISTING_PACKAGE=0 +[ -f "${PWD}/Package.swift" ] && HAS_EXISTING_PACKAGE=1 GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) SCRIPT_PATH=$(realpath --relative-to="$GIT_ROOT" "$0" 2>/dev/null || realpath "$0") SUBCOMMAND="$1" @@ -41,19 +42,23 @@ EOF a11y_scan() { # Ensure Package.swift is removed on exit (acts like a finally block) cleanup() { - if [ $PACKAGE_EXISTS -eq 0 ]; then + if [ $HAS_EXISTING_PACKAGE -eq 1 ]; then return fi - rm -f -- "${PWD}/Package.swift" "${PWD}/Package.resolved" + if [ -n "$WORK_DIR" ] && [ -d "$WORK_DIR" ]; then + rm -rf -- "$WORK_DIR" + fi } trap cleanup EXIT setup() { - if [ $PACKAGE_EXISTS -eq 0 ]; then + if [ $HAS_EXISTING_PACKAGE -eq 1 ]; then + WORK_DIR="$ORIGINAL_DIR" return fi - cat > Package.swift < "$WORK_DIR/Package.swift" </dev/null) SCRIPT_PATH=$(realpath --relative-to="$GIT_ROOT" "$0" 2>/dev/null || realpath "$0") SUBCOMMAND="$1" @@ -54,19 +55,23 @@ EOF a11y_scan() { # Ensure Package.swift is removed on exit (acts like a finally block) cleanup() { - if [ $PACKAGE_EXISTS -eq 0 ]; then + if [ $HAS_EXISTING_PACKAGE -eq 1 ]; then return fi - rm -f -- "${PWD}/Package.swift" "${PWD}/Package.resolved" + if [ -n "$WORK_DIR" ] && [ -d "$WORK_DIR" ]; then + rm -rf -- "$WORK_DIR" + fi } trap cleanup EXIT setup() { - if [ $PACKAGE_EXISTS -eq 0 ]; then + if [ $HAS_EXISTING_PACKAGE -eq 1 ]; then + WORK_DIR="$ORIGINAL_DIR" return fi - cat > Package.swift < "$WORK_DIR/Package.swift" </dev/null) SCRIPT_PATH=$(realpath --relative-to="$GIT_ROOT" "$0" 2>/dev/null || realpath "$0") SUBCOMMAND="$1" @@ -53,19 +54,23 @@ EOF a11y_scan() { # Ensure Package.swift is removed on exit (acts like a finally block) cleanup() { - if [ $PACKAGE_EXISTS -eq 0 ]; then + if [ $HAS_EXISTING_PACKAGE -eq 1 ]; then return fi - rm -f -- "${PWD}/Package.swift" "${PWD}/Package.resolved" + if [ -n "$WORK_DIR" ] && [ -d "$WORK_DIR" ]; then + rm -rf -- "$WORK_DIR" + fi } trap cleanup EXIT setup() { - if [ $PACKAGE_EXISTS -eq 0 ]; then + if [ $HAS_EXISTING_PACKAGE -eq 1 ]; then + WORK_DIR="$ORIGINAL_DIR" return fi - cat > Package.swift < "$WORK_DIR/Package.swift" < Date: Tue, 26 May 2026 14:31:47 +0530 Subject: [PATCH 12/12] fix(security): add extraction size limit to prevent decompression bomb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F-015 / DEVA11Y-484 — bsdtar extraction had no size or entry-count limit (CWE-400), allowing decompression bomb DoS. Add a 100 MB post- extraction size check that removes the output and errors on violation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../BrowserStackAccessibilityLint.swift | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift index 117e362..5f64997 100644 --- a/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift +++ b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift @@ -285,6 +285,8 @@ private struct BrowserStackCLIDownloader { 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) } + + try validateExtractedSize(of: directory) } private func extractLocalArchive(at archiveURL: URL, into directory: URL) throws { @@ -301,6 +303,11 @@ private struct BrowserStackCLIDownloader { throw PluginError("Failed to launch bsdtar: \(error.localizedDescription)") } + if process.terminationReason == .exit && process.terminationStatus == 0 { + try validateExtractedSize(of: directory) + return + } + if process.terminationReason != .exit || process.terminationStatus != 0 { // Fall back to copying the file directly if it's already an executable. let message = String(data: errorPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -315,6 +322,24 @@ private struct BrowserStackCLIDownloader { } } } + + private func validateExtractedSize(of directory: URL, maxBytes: UInt64 = 100 * 1024 * 1024) throws { + var totalSize: UInt64 = 0 + let enumerator = fileManager.enumerator( + at: directory, + includingPropertiesForKeys: [.fileSizeKey], + options: [.skipsHiddenFiles] + ) + while let fileURL = enumerator?.nextObject() as? URL { + if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize { + totalSize += UInt64(size) + if totalSize > maxBytes { + try? fileManager.removeItem(at: directory) + throw PluginError("Extracted archive exceeds maximum allowed size (\(maxBytes / (1024 * 1024)) MB). Possible decompression bomb.") + } + } + } + } #endif private func resolveOverrideArtifact(from url: URL) async throws -> ArtifactInfo {