From fc2a1efc2aa56776236655f669f9800f3726a0d0 Mon Sep 17 00:00:00 2001 From: Crash0v3rrid3 Date: Wed, 17 Jun 2026 13:51:12 +0530 Subject: [PATCH 1/2] fix(scripts): pin self-update + SPM dependency to immutable revision (DEVA11Y-475,478,477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supply-chain / integrity hardening for the shell installers (APPSEC-415). DEVA11Y-475 / F-003 + DEVA11Y-478 / F-006 — self-update from mutable branch HEAD: - Self-update is now OPT-IN via an explicit `--self-update` subcommand; it no longer runs automatically on every invocation. - Fetches from a pinned, immutable revision instead of refs/heads/main. - Verifies a SHA-256 checksum (published `.sha256` sidecar) before use and refuses to apply on mismatch or missing checksum. - Downloads to a temp file and atomically replaces the script via `mv` instead of overwriting the currently-running file in place. - Applied consistently to all six variants (bash/zsh/fish × cli.sh/spm.sh). DEVA11Y-477 / F-005 — SPM dependency pinned to branch "main": - Generated Package.swift heredoc now pins to .revision("db817c37cf74cba47e2fef535f53a35bfc88ec6a") (current origin/main SHA; no release tags exist yet) instead of branch: "main". Co-Authored-By: Claude Opus 4.8 --- scripts/bash/cli.sh | 62 +++++++++++++++++++++++++++++++++++++---- scripts/bash/spm.sh | 67 +++++++++++++++++++++++++++++++++++++++++---- scripts/fish/cli.sh | 62 +++++++++++++++++++++++++++++++++++++---- scripts/fish/spm.sh | 67 +++++++++++++++++++++++++++++++++++++++++---- scripts/zsh/cli.sh | 62 +++++++++++++++++++++++++++++++++++++---- scripts/zsh/spm.sh | 67 +++++++++++++++++++++++++++++++++++++++++---- 6 files changed, 354 insertions(+), 33 deletions(-) diff --git a/scripts/bash/cli.sh b/scripts/bash/cli.sh index 818d993..11625c3 100644 --- a/scripts/bash/cli.sh +++ b/scripts/bash/cli.sh @@ -78,12 +78,60 @@ a11y_scan() { $BINARY_PATH a11y $EXTRA_ARGS } +# Pinned, immutable git revision the self-update is allowed to fetch from. +# DEVA11Y-475: never fetch executable code from a mutable branch HEAD. +# Bump this (and the published .sha256 sidecars) on every release. +SELF_UPDATE_REF="db817c37cf74cba47e2fef535f53a35bfc88ec6a" +SELF_UPDATE_RELPATH="scripts/bash/cli.sh" + +# DEVA11Y-475 / F-003: self-update is OPT-IN (run with `--self-update`), +# fetches from a pinned revision (not a mutable branch), verifies a SHA-256 +# checksum before use, and atomically replaces the script instead of +# overwriting the currently-running file in place. script_self_update() { - local remote_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/main/scripts/bash/cli.sh" + local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/${SELF_UPDATE_REF}/${SELF_UPDATE_RELPATH}" + local tmp_dir tmp_script tmp_sum expected_sum actual_sum + + tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bs-a11y-selfupdate.XXXXXX") || { + echo "Self-update: failed to create temp dir." >&2 + return 1 + } + # shellcheck disable=SC2064 + trap "rm -rf -- '${tmp_dir}'" RETURN + tmp_script="${tmp_dir}/cli.sh" + tmp_sum="${tmp_dir}/cli.sh.sha256" + + if ! curl -fsSL "$base_url" -o "$tmp_script"; then + echo "Self-update: failed to download script from pinned revision." >&2 + return 1 + fi + if ! curl -fsSL "${base_url}.sha256" -o "$tmp_sum"; then + echo "Self-update: failed to download checksum; aborting (integrity unverifiable)." >&2 + return 1 + fi + + if ! head -c2 "$tmp_script" | grep -q '^#!'; then + echo "Self-update: downloaded file is not a script; aborting." >&2 + return 1 + fi + + # Published sidecar is " "; take the first field. + expected_sum=$(awk '{print $1; exit}' "$tmp_sum") + actual_sum=$(shasum -a 256 "$tmp_script" | awk '{print $1}') + if [[ -z "$expected_sum" || "$expected_sum" != "$actual_sum" ]]; then + echo "Self-update: checksum mismatch; refusing to apply." >&2 + echo " expected: ${expected_sum:-}" >&2 + echo " actual: ${actual_sum}" >&2 + return 1 + fi - updated_script=$(curl -R -z "$SCRIPT_PATH" "$remote_url") - if [[ $updated_script =~ ^#! ]]; then - echo "$updated_script" > "$SCRIPT_PATH" + chmod 0755 "$tmp_script" + # Atomic replace: never overwrite the running script in place. + if mv -f "$tmp_script" "$SCRIPT_PATH"; then + echo "Self-update: updated ${SCRIPT_PATH} to pinned revision ${SELF_UPDATE_REF}." + else + echo "Self-update: failed to replace ${SCRIPT_PATH}." >&2 + return 1 fi } @@ -92,7 +140,11 @@ download_binary() { bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0775 "$BINARY_PATH" } -script_self_update +if [[ $SUBCOMMAND == "--self-update" ]]; then + script_self_update + exit $? +fi + if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then register_git_hook exit 0 diff --git a/scripts/bash/spm.sh b/scripts/bash/spm.sh index 1202e11..38638dd 100644 --- a/scripts/bash/spm.sh +++ b/scripts/bash/spm.sh @@ -60,7 +60,10 @@ import PackageDescription let package = Package( name: "Dummy", dependencies: [ - .package(url: "https://github.com/browserstack/AccessibilityDevTools.git", branch: "main") + // DEVA11Y-477 / F-005: pin to an immutable revision instead of a mutable + // branch HEAD. No release tags exist yet; this is the current origin/main + // SHA. Bump to a release tag (.exact("x.y.z")) once tags are published. + .package(url: "https://github.com/browserstack/AccessibilityDevTools.git", revision: "db817c37cf74cba47e2fef535f53a35bfc88ec6a") ], targets: [] ) @@ -83,16 +86,68 @@ EOF scan $EXTRA_ARGS } +# Pinned, immutable git revision the self-update is allowed to fetch from. +# DEVA11Y-478: never fetch executable code from a mutable branch HEAD. +# Bump this (and the published .sha256 sidecars) on every release. +SELF_UPDATE_REF="db817c37cf74cba47e2fef535f53a35bfc88ec6a" +SELF_UPDATE_RELPATH="scripts/bash/spm.sh" + +# DEVA11Y-478 / F-006: self-update is OPT-IN (run with `--self-update`), +# fetches from a pinned revision (not a mutable branch), verifies a SHA-256 +# checksum before use, and atomically replaces the script instead of +# overwriting the currently-running file in place. script_self_update() { - local remote_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/main/scripts/bash/spm.sh" + local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/${SELF_UPDATE_REF}/${SELF_UPDATE_RELPATH}" + local tmp_dir tmp_script tmp_sum expected_sum actual_sum + + tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bs-a11y-selfupdate.XXXXXX") || { + echo "Self-update: failed to create temp dir." >&2 + return 1 + } + # shellcheck disable=SC2064 + trap "rm -rf -- '${tmp_dir}'" RETURN + tmp_script="${tmp_dir}/spm.sh" + tmp_sum="${tmp_dir}/spm.sh.sha256" - updated_script=$(curl -R -z "$SCRIPT_PATH" "$remote_url") - if [[ $updated_script =~ ^#! ]]; then - echo "$updated_script" > "$SCRIPT_PATH" + if ! curl -fsSL "$base_url" -o "$tmp_script"; then + echo "Self-update: failed to download script from pinned revision." >&2 + return 1 + fi + if ! curl -fsSL "${base_url}.sha256" -o "$tmp_sum"; then + echo "Self-update: failed to download checksum; aborting (integrity unverifiable)." >&2 + return 1 + fi + + if ! head -c2 "$tmp_script" | grep -q '^#!'; then + echo "Self-update: downloaded file is not a script; aborting." >&2 + return 1 + fi + + # Published sidecar is " "; take the first field. + expected_sum=$(awk '{print $1; exit}' "$tmp_sum") + actual_sum=$(shasum -a 256 "$tmp_script" | awk '{print $1}') + if [[ -z "$expected_sum" || "$expected_sum" != "$actual_sum" ]]; then + echo "Self-update: checksum mismatch; refusing to apply." >&2 + echo " expected: ${expected_sum:-}" >&2 + echo " actual: ${actual_sum}" >&2 + return 1 + fi + + chmod 0755 "$tmp_script" + # Atomic replace: never overwrite the running script in place. + if mv -f "$tmp_script" "$SCRIPT_PATH"; then + echo "Self-update: updated ${SCRIPT_PATH} to pinned revision ${SELF_UPDATE_REF}." + else + echo "Self-update: failed to replace ${SCRIPT_PATH}." >&2 + return 1 fi } -script_self_update +if [[ $SUBCOMMAND == "--self-update" ]]; then + script_self_update + exit $? +fi + 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 e509be7..dc36c59 100644 --- a/scripts/fish/cli.sh +++ b/scripts/fish/cli.sh @@ -90,12 +90,60 @@ a11y_scan() { $BINARY_PATH a11y $EXTRA_ARGS } +# Pinned, immutable git revision the self-update is allowed to fetch from. +# DEVA11Y-475: never fetch executable code from a mutable branch HEAD. +# Bump this (and the published .sha256 sidecars) on every release. +SELF_UPDATE_REF="db817c37cf74cba47e2fef535f53a35bfc88ec6a" +SELF_UPDATE_RELPATH="scripts/fish/cli.sh" + +# DEVA11Y-475 / F-003: self-update is OPT-IN (run with `--self-update`), +# fetches from a pinned revision (not a mutable branch), verifies a SHA-256 +# checksum before use, and atomically replaces the script instead of +# overwriting the currently-running file in place. script_self_update() { - local remote_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/main/scripts/fish/cli.sh" + local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/${SELF_UPDATE_REF}/${SELF_UPDATE_RELPATH}" + local tmp_dir tmp_script tmp_sum expected_sum actual_sum + + tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bs-a11y-selfupdate.XXXXXX") || { + echo "Self-update: failed to create temp dir." >&2 + return 1 + } + # shellcheck disable=SC2064 + trap "rm -rf -- '${tmp_dir}'" RETURN + tmp_script="${tmp_dir}/cli.sh" + tmp_sum="${tmp_dir}/cli.sh.sha256" + + if ! curl -fsSL "$base_url" -o "$tmp_script"; then + echo "Self-update: failed to download script from pinned revision." >&2 + return 1 + fi + if ! curl -fsSL "${base_url}.sha256" -o "$tmp_sum"; then + echo "Self-update: failed to download checksum; aborting (integrity unverifiable)." >&2 + return 1 + fi - updated_script=$(curl -R -z "$SCRIPT_PATH" "$remote_url") - if [[ $updated_script =~ ^#! ]]; then - echo "$updated_script" > "$SCRIPT_PATH" + if ! head -c2 "$tmp_script" | grep -q '^#!'; then + echo "Self-update: downloaded file is not a script; aborting." >&2 + return 1 + fi + + # Published sidecar is " "; take the first field. + expected_sum=$(awk '{print $1; exit}' "$tmp_sum") + actual_sum=$(shasum -a 256 "$tmp_script" | awk '{print $1}') + if [[ -z "$expected_sum" || "$expected_sum" != "$actual_sum" ]]; then + echo "Self-update: checksum mismatch; refusing to apply." >&2 + echo " expected: ${expected_sum:-}" >&2 + echo " actual: ${actual_sum}" >&2 + return 1 + fi + + chmod 0755 "$tmp_script" + # Atomic replace: never overwrite the running script in place. + if mv -f "$tmp_script" "$SCRIPT_PATH"; then + echo "Self-update: updated ${SCRIPT_PATH} to pinned revision ${SELF_UPDATE_REF}." + else + echo "Self-update: failed to replace ${SCRIPT_PATH}." >&2 + return 1 fi } @@ -104,7 +152,11 @@ download_binary() { bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0775 "$BINARY_PATH" } -script_self_update +if [[ $SUBCOMMAND == "--self-update" ]]; then + script_self_update + exit $? +fi + 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..69fcda0 100644 --- a/scripts/fish/spm.sh +++ b/scripts/fish/spm.sh @@ -73,7 +73,10 @@ import PackageDescription let package = Package( name: "Dummy", dependencies: [ - .package(url: "https://github.com/browserstack/AccessibilityDevTools.git", branch: "main") + // DEVA11Y-477 / F-005: pin to an immutable revision instead of a mutable + // branch HEAD. No release tags exist yet; this is the current origin/main + // SHA. Bump to a release tag (.exact("x.y.z")) once tags are published. + .package(url: "https://github.com/browserstack/AccessibilityDevTools.git", revision: "db817c37cf74cba47e2fef535f53a35bfc88ec6a") ], targets: [] ) @@ -96,16 +99,68 @@ EOF scan $EXTRA_ARGS } +# Pinned, immutable git revision the self-update is allowed to fetch from. +# DEVA11Y-478: never fetch executable code from a mutable branch HEAD. +# Bump this (and the published .sha256 sidecars) on every release. +SELF_UPDATE_REF="db817c37cf74cba47e2fef535f53a35bfc88ec6a" +SELF_UPDATE_RELPATH="scripts/fish/spm.sh" + +# DEVA11Y-478 / F-006: self-update is OPT-IN (run with `--self-update`), +# fetches from a pinned revision (not a mutable branch), verifies a SHA-256 +# checksum before use, and atomically replaces the script instead of +# overwriting the currently-running file in place. script_self_update() { - local remote_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/main/scripts/fish/spm.sh" + local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/${SELF_UPDATE_REF}/${SELF_UPDATE_RELPATH}" + local tmp_dir tmp_script tmp_sum expected_sum actual_sum + + tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bs-a11y-selfupdate.XXXXXX") || { + echo "Self-update: failed to create temp dir." >&2 + return 1 + } + # shellcheck disable=SC2064 + trap "rm -rf -- '${tmp_dir}'" RETURN + tmp_script="${tmp_dir}/spm.sh" + tmp_sum="${tmp_dir}/spm.sh.sha256" - updated_script=$(curl -R -z "$SCRIPT_PATH" "$remote_url") - if [[ $updated_script =~ ^#! ]]; then - echo "$updated_script" > "$SCRIPT_PATH" + if ! curl -fsSL "$base_url" -o "$tmp_script"; then + echo "Self-update: failed to download script from pinned revision." >&2 + return 1 + fi + if ! curl -fsSL "${base_url}.sha256" -o "$tmp_sum"; then + echo "Self-update: failed to download checksum; aborting (integrity unverifiable)." >&2 + return 1 + fi + + if ! head -c2 "$tmp_script" | grep -q '^#!'; then + echo "Self-update: downloaded file is not a script; aborting." >&2 + return 1 + fi + + # Published sidecar is " "; take the first field. + expected_sum=$(awk '{print $1; exit}' "$tmp_sum") + actual_sum=$(shasum -a 256 "$tmp_script" | awk '{print $1}') + if [[ -z "$expected_sum" || "$expected_sum" != "$actual_sum" ]]; then + echo "Self-update: checksum mismatch; refusing to apply." >&2 + echo " expected: ${expected_sum:-}" >&2 + echo " actual: ${actual_sum}" >&2 + return 1 + fi + + chmod 0755 "$tmp_script" + # Atomic replace: never overwrite the running script in place. + if mv -f "$tmp_script" "$SCRIPT_PATH"; then + echo "Self-update: updated ${SCRIPT_PATH} to pinned revision ${SELF_UPDATE_REF}." + else + echo "Self-update: failed to replace ${SCRIPT_PATH}." >&2 + return 1 fi } -script_self_update +if [[ $SUBCOMMAND == "--self-update" ]]; then + script_self_update + exit $? +fi + 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 a7e6e4c..6da0e98 100644 --- a/scripts/zsh/cli.sh +++ b/scripts/zsh/cli.sh @@ -89,12 +89,60 @@ a11y_scan() { $BINARY_PATH a11y $EXTRA_ARGS } +# Pinned, immutable git revision the self-update is allowed to fetch from. +# DEVA11Y-475: never fetch executable code from a mutable branch HEAD. +# Bump this (and the published .sha256 sidecars) on every release. +SELF_UPDATE_REF="db817c37cf74cba47e2fef535f53a35bfc88ec6a" +SELF_UPDATE_RELPATH="scripts/zsh/cli.sh" + +# DEVA11Y-475 / F-003: self-update is OPT-IN (run with `--self-update`), +# fetches from a pinned revision (not a mutable branch), verifies a SHA-256 +# checksum before use, and atomically replaces the script instead of +# overwriting the currently-running file in place. script_self_update() { - local remote_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/main/scripts/zsh/cli.sh" + local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/${SELF_UPDATE_REF}/${SELF_UPDATE_RELPATH}" + local tmp_dir tmp_script tmp_sum expected_sum actual_sum + + tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bs-a11y-selfupdate.XXXXXX") || { + echo "Self-update: failed to create temp dir." >&2 + return 1 + } + # shellcheck disable=SC2064 + trap "rm -rf -- '${tmp_dir}'" RETURN + tmp_script="${tmp_dir}/cli.sh" + tmp_sum="${tmp_dir}/cli.sh.sha256" + + if ! curl -fsSL "$base_url" -o "$tmp_script"; then + echo "Self-update: failed to download script from pinned revision." >&2 + return 1 + fi + if ! curl -fsSL "${base_url}.sha256" -o "$tmp_sum"; then + echo "Self-update: failed to download checksum; aborting (integrity unverifiable)." >&2 + return 1 + fi - updated_script=$(curl -R -z "$SCRIPT_PATH" "$remote_url") - if [[ $updated_script =~ ^#! ]]; then - echo "$updated_script" > "$SCRIPT_PATH" + if ! head -c2 "$tmp_script" | grep -q '^#!'; then + echo "Self-update: downloaded file is not a script; aborting." >&2 + return 1 + fi + + # Published sidecar is " "; take the first field. + expected_sum=$(awk '{print $1; exit}' "$tmp_sum") + actual_sum=$(shasum -a 256 "$tmp_script" | awk '{print $1}') + if [[ -z "$expected_sum" || "$expected_sum" != "$actual_sum" ]]; then + echo "Self-update: checksum mismatch; refusing to apply." >&2 + echo " expected: ${expected_sum:-}" >&2 + echo " actual: ${actual_sum}" >&2 + return 1 + fi + + chmod 0755 "$tmp_script" + # Atomic replace: never overwrite the running script in place. + if mv -f "$tmp_script" "$SCRIPT_PATH"; then + echo "Self-update: updated ${SCRIPT_PATH} to pinned revision ${SELF_UPDATE_REF}." + else + echo "Self-update: failed to replace ${SCRIPT_PATH}." >&2 + return 1 fi } @@ -103,7 +151,11 @@ download_binary() { bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0775 "$BINARY_PATH" } -script_self_update +if [[ $SUBCOMMAND == "--self-update" ]]; then + script_self_update + exit $? +fi + 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..ec719e6 100644 --- a/scripts/zsh/spm.sh +++ b/scripts/zsh/spm.sh @@ -72,7 +72,10 @@ import PackageDescription let package = Package( name: "Dummy", dependencies: [ - .package(url: "https://github.com/browserstack/AccessibilityDevTools.git", branch: "main") + // DEVA11Y-477 / F-005: pin to an immutable revision instead of a mutable + // branch HEAD. No release tags exist yet; this is the current origin/main + // SHA. Bump to a release tag (.exact("x.y.z")) once tags are published. + .package(url: "https://github.com/browserstack/AccessibilityDevTools.git", revision: "db817c37cf74cba47e2fef535f53a35bfc88ec6a") ], targets: [] ) @@ -95,16 +98,68 @@ EOF scan $EXTRA_ARGS } +# Pinned, immutable git revision the self-update is allowed to fetch from. +# DEVA11Y-478: never fetch executable code from a mutable branch HEAD. +# Bump this (and the published .sha256 sidecars) on every release. +SELF_UPDATE_REF="db817c37cf74cba47e2fef535f53a35bfc88ec6a" +SELF_UPDATE_RELPATH="scripts/zsh/spm.sh" + +# DEVA11Y-478 / F-006: self-update is OPT-IN (run with `--self-update`), +# fetches from a pinned revision (not a mutable branch), verifies a SHA-256 +# checksum before use, and atomically replaces the script instead of +# overwriting the currently-running file in place. script_self_update() { - local remote_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/main/scripts/zsh/spm.sh" + local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/${SELF_UPDATE_REF}/${SELF_UPDATE_RELPATH}" + local tmp_dir tmp_script tmp_sum expected_sum actual_sum + + tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bs-a11y-selfupdate.XXXXXX") || { + echo "Self-update: failed to create temp dir." >&2 + return 1 + } + # shellcheck disable=SC2064 + trap "rm -rf -- '${tmp_dir}'" RETURN + tmp_script="${tmp_dir}/spm.sh" + tmp_sum="${tmp_dir}/spm.sh.sha256" - updated_script=$(curl -R -z "$SCRIPT_PATH" "$remote_url") - if [[ $updated_script =~ ^#! ]]; then - echo "$updated_script" > "$SCRIPT_PATH" + if ! curl -fsSL "$base_url" -o "$tmp_script"; then + echo "Self-update: failed to download script from pinned revision." >&2 + return 1 + fi + if ! curl -fsSL "${base_url}.sha256" -o "$tmp_sum"; then + echo "Self-update: failed to download checksum; aborting (integrity unverifiable)." >&2 + return 1 + fi + + if ! head -c2 "$tmp_script" | grep -q '^#!'; then + echo "Self-update: downloaded file is not a script; aborting." >&2 + return 1 + fi + + # Published sidecar is " "; take the first field. + expected_sum=$(awk '{print $1; exit}' "$tmp_sum") + actual_sum=$(shasum -a 256 "$tmp_script" | awk '{print $1}') + if [[ -z "$expected_sum" || "$expected_sum" != "$actual_sum" ]]; then + echo "Self-update: checksum mismatch; refusing to apply." >&2 + echo " expected: ${expected_sum:-}" >&2 + echo " actual: ${actual_sum}" >&2 + return 1 + fi + + chmod 0755 "$tmp_script" + # Atomic replace: never overwrite the running script in place. + if mv -f "$tmp_script" "$SCRIPT_PATH"; then + echo "Self-update: updated ${SCRIPT_PATH} to pinned revision ${SELF_UPDATE_REF}." + else + echo "Self-update: failed to replace ${SCRIPT_PATH}." >&2 + return 1 fi } -script_self_update +if [[ $SUBCOMMAND == "--self-update" ]]; then + script_self_update + exit $? +fi + if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then register_git_hook exit 0 From 12fc1288815ae1ae5471428ecbcfc9b2ae634b9c Mon Sep 17 00:00:00 2001 From: Crash0v3rrid3 Date: Thu, 18 Jun 2026 15:14:26 +0530 Subject: [PATCH 2/2] fix(scripts): track latest main for self-update + harden mechanism (DEVA11Y-475,477,478) Addresses PR #30 review. Per maintainer intent, auto-update should always take the latest from main rather than pin to a commit, so: - Revert self-update fetch to main HEAD and restore auto-update on every run (best-effort: script_self_update || true, so offline/integrity failures never block the tool). - Keep SHA-256 verification and commit the 6 .sha256 sidecars so the integrity check actually works against main (regenerate on every script change to main). - Fetch the sidecar first and skip when the on-disk copy already matches (avoids a redundant download/rewrite each run). - Portable hashing: prefer sha256sum, fall back to shasum -a 256; guard empty actual_sum. - Resolve SCRIPT_PATH absolutely so the replace never depends on CWD; stage within the target dir then mv so the replace is atomic on the same filesystem. - Add curl --connect-timeout/--max-time; run the shebang sanity-check after checksum verification; mark the branch/relpath constants readonly. Co-Authored-By: Claude Opus 4.8 --- scripts/bash/cli.sh | 100 +++++++++++++++++++++++++------------ scripts/bash/cli.sh.sha256 | 1 + scripts/bash/spm.sh | 100 +++++++++++++++++++++++++------------ scripts/bash/spm.sh.sha256 | 1 + scripts/fish/cli.sh | 100 +++++++++++++++++++++++++------------ scripts/fish/cli.sh.sha256 | 1 + scripts/fish/spm.sh | 100 +++++++++++++++++++++++++------------ scripts/fish/spm.sh.sha256 | 1 + scripts/zsh/cli.sh | 100 +++++++++++++++++++++++++------------ scripts/zsh/cli.sh.sha256 | 1 + scripts/zsh/spm.sh | 100 +++++++++++++++++++++++++------------ scripts/zsh/spm.sh.sha256 | 1 + 12 files changed, 408 insertions(+), 198 deletions(-) create mode 100644 scripts/bash/cli.sh.sha256 create mode 100644 scripts/bash/spm.sh.sha256 create mode 100644 scripts/fish/cli.sh.sha256 create mode 100644 scripts/fish/spm.sh.sha256 create mode 100644 scripts/zsh/cli.sh.sha256 create mode 100644 scripts/zsh/spm.sh.sha256 diff --git a/scripts/bash/cli.sh b/scripts/bash/cli.sh index 11625c3..10b4eb2 100644 --- a/scripts/bash/cli.sh +++ b/scripts/bash/cli.sh @@ -78,19 +78,39 @@ a11y_scan() { $BINARY_PATH a11y $EXTRA_ARGS } -# Pinned, immutable git revision the self-update is allowed to fetch from. -# DEVA11Y-475: never fetch executable code from a mutable branch HEAD. -# Bump this (and the published .sha256 sidecars) on every release. -SELF_UPDATE_REF="db817c37cf74cba47e2fef535f53a35bfc88ec6a" +# Self-update tracks the latest launcher on `main` so users always run the +# newest version. DEVA11Y-475/477/478: we deliberately follow main HEAD rather +# than a pinned revision (per maintainer intent: always take the latest). +# Hardening retained from the pinning work: download to a temp dir, verify a +# SHA-256 sidecar (a download-integrity check, NOT an authenticity signature -- +# script and checksum share one origin), sanity-check the shebang, then +# atomically replace the on-disk script. Keep scripts/bash/cli.sh.sha256 on main in +# sync with this file (regenerate on every change) or updates will abort. +SELF_UPDATE_BRANCH="main" +readonly SELF_UPDATE_BRANCH SELF_UPDATE_RELPATH="scripts/bash/cli.sh" +readonly SELF_UPDATE_RELPATH + +# sha256 with a portable fallback: GNU `sha256sum` (Linux) or `shasum -a 256` +# (macOS / Perl Digest::SHA). +_self_update_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} -# DEVA11Y-475 / F-003: self-update is OPT-IN (run with `--self-update`), -# fetches from a pinned revision (not a mutable branch), verifies a SHA-256 -# checksum before use, and atomically replaces the script instead of -# overwriting the currently-running file in place. script_self_update() { - local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/${SELF_UPDATE_REF}/${SELF_UPDATE_RELPATH}" - local tmp_dir tmp_script tmp_sum expected_sum actual_sum + local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/${SELF_UPDATE_BRANCH}/${SELF_UPDATE_RELPATH}" + local tmp_dir tmp_script tmp_sum expected_sum actual_sum local_sum target_path stage_file + + # Resolve the on-disk target absolutely so the replace never depends on CWD. + if [[ -n "$GIT_ROOT" && "$SCRIPT_PATH" != /* ]]; then + target_path="${GIT_ROOT}/${SCRIPT_PATH}" + else + target_path="$SCRIPT_PATH" + fi tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bs-a11y-selfupdate.XXXXXX") || { echo "Self-update: failed to create temp dir." >&2 @@ -101,36 +121,50 @@ script_self_update() { tmp_script="${tmp_dir}/cli.sh" tmp_sum="${tmp_dir}/cli.sh.sha256" - if ! curl -fsSL "$base_url" -o "$tmp_script"; then - echo "Self-update: failed to download script from pinned revision." >&2 - return 1 + # Fetch the checksum first; if our on-disk copy already matches, we're current. + if ! curl -fsSL --connect-timeout 10 --max-time 30 "${base_url}.sha256" -o "$tmp_sum"; then + echo "Self-update: could not fetch checksum from ${SELF_UPDATE_BRANCH}; skipping update." >&2 + return 0 fi - if ! curl -fsSL "${base_url}.sha256" -o "$tmp_sum"; then - echo "Self-update: failed to download checksum; aborting (integrity unverifiable)." >&2 - return 1 + # Published sidecar is " "; take the first field. + expected_sum=$(awk '{print $1; exit}' "$tmp_sum") + if [[ -f "$target_path" ]]; then + local_sum=$(_self_update_sha256 "$target_path") + if [[ -n "$expected_sum" && "$local_sum" == "$expected_sum" ]]; then + return 0 + fi fi - if ! head -c2 "$tmp_script" | grep -q '^#!'; then - echo "Self-update: downloaded file is not a script; aborting." >&2 - return 1 + if ! curl -fsSL --connect-timeout 10 --max-time 30 "$base_url" -o "$tmp_script"; then + echo "Self-update: could not download latest script; skipping update." >&2 + return 0 fi - # Published sidecar is " "; take the first field. - expected_sum=$(awk '{print $1; exit}' "$tmp_sum") - actual_sum=$(shasum -a 256 "$tmp_script" | awk '{print $1}') - if [[ -z "$expected_sum" || "$expected_sum" != "$actual_sum" ]]; then + actual_sum=$(_self_update_sha256 "$tmp_script") + if [[ -z "$expected_sum" || -z "$actual_sum" || "$expected_sum" != "$actual_sum" ]]; then echo "Self-update: checksum mismatch; refusing to apply." >&2 echo " expected: ${expected_sum:-}" >&2 - echo " actual: ${actual_sum}" >&2 + echo " actual: ${actual_sum:-}" >&2 + return 1 + fi + + # Sanity check AFTER integrity: ensure the verified payload is a script. + if ! head -c2 "$tmp_script" | grep -q '^#!'; then + echo "Self-update: downloaded file is not a script; aborting." >&2 return 1 fi - chmod 0755 "$tmp_script" - # Atomic replace: never overwrite the running script in place. - if mv -f "$tmp_script" "$SCRIPT_PATH"; then - echo "Self-update: updated ${SCRIPT_PATH} to pinned revision ${SELF_UPDATE_REF}." + # Stage inside the target's directory so the rename is atomic (mv across + # filesystems would degrade to a non-atomic copy). + stage_file=$(mktemp "$(dirname "$target_path")/.bs-a11y-update.XXXXXX") || { + echo "Self-update: failed to stage update next to ${target_path}." >&2 + return 1 + } + if cp "$tmp_script" "$stage_file" && chmod 0755 "$stage_file" && mv -f "$stage_file" "$target_path"; then + echo "Self-update: updated ${target_path} to latest ${SELF_UPDATE_BRANCH}." else - echo "Self-update: failed to replace ${SCRIPT_PATH}." >&2 + rm -f -- "$stage_file" + echo "Self-update: failed to replace ${target_path}." >&2 return 1 fi } @@ -140,10 +174,10 @@ download_binary() { bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0775 "$BINARY_PATH" } -if [[ $SUBCOMMAND == "--self-update" ]]; then - script_self_update - exit $? -fi +# Best-effort auto-update: always fetch the latest launcher from main before +# running. Failures (offline, integrity) are non-fatal -- the current script +# keeps working and any update applies on the next invocation. +script_self_update || true if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then register_git_hook diff --git a/scripts/bash/cli.sh.sha256 b/scripts/bash/cli.sh.sha256 new file mode 100644 index 0000000..b213bde --- /dev/null +++ b/scripts/bash/cli.sh.sha256 @@ -0,0 +1 @@ +9af5ce77ada28741e91d2323e4664c47e7e7531e10b34168cfe6bc50a74f5d62 cli.sh diff --git a/scripts/bash/spm.sh b/scripts/bash/spm.sh index 38638dd..21da3b1 100644 --- a/scripts/bash/spm.sh +++ b/scripts/bash/spm.sh @@ -86,19 +86,39 @@ EOF scan $EXTRA_ARGS } -# Pinned, immutable git revision the self-update is allowed to fetch from. -# DEVA11Y-478: never fetch executable code from a mutable branch HEAD. -# Bump this (and the published .sha256 sidecars) on every release. -SELF_UPDATE_REF="db817c37cf74cba47e2fef535f53a35bfc88ec6a" +# Self-update tracks the latest launcher on `main` so users always run the +# newest version. DEVA11Y-475/477/478: we deliberately follow main HEAD rather +# than a pinned revision (per maintainer intent: always take the latest). +# Hardening retained from the pinning work: download to a temp dir, verify a +# SHA-256 sidecar (a download-integrity check, NOT an authenticity signature -- +# script and checksum share one origin), sanity-check the shebang, then +# atomically replace the on-disk script. Keep scripts/bash/spm.sh.sha256 on main in +# sync with this file (regenerate on every change) or updates will abort. +SELF_UPDATE_BRANCH="main" +readonly SELF_UPDATE_BRANCH SELF_UPDATE_RELPATH="scripts/bash/spm.sh" +readonly SELF_UPDATE_RELPATH + +# sha256 with a portable fallback: GNU `sha256sum` (Linux) or `shasum -a 256` +# (macOS / Perl Digest::SHA). +_self_update_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} -# DEVA11Y-478 / F-006: self-update is OPT-IN (run with `--self-update`), -# fetches from a pinned revision (not a mutable branch), verifies a SHA-256 -# checksum before use, and atomically replaces the script instead of -# overwriting the currently-running file in place. script_self_update() { - local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/${SELF_UPDATE_REF}/${SELF_UPDATE_RELPATH}" - local tmp_dir tmp_script tmp_sum expected_sum actual_sum + local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/${SELF_UPDATE_BRANCH}/${SELF_UPDATE_RELPATH}" + local tmp_dir tmp_script tmp_sum expected_sum actual_sum local_sum target_path stage_file + + # Resolve the on-disk target absolutely so the replace never depends on CWD. + if [[ -n "$GIT_ROOT" && "$SCRIPT_PATH" != /* ]]; then + target_path="${GIT_ROOT}/${SCRIPT_PATH}" + else + target_path="$SCRIPT_PATH" + fi tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bs-a11y-selfupdate.XXXXXX") || { echo "Self-update: failed to create temp dir." >&2 @@ -109,44 +129,58 @@ script_self_update() { tmp_script="${tmp_dir}/spm.sh" tmp_sum="${tmp_dir}/spm.sh.sha256" - if ! curl -fsSL "$base_url" -o "$tmp_script"; then - echo "Self-update: failed to download script from pinned revision." >&2 - return 1 + # Fetch the checksum first; if our on-disk copy already matches, we're current. + if ! curl -fsSL --connect-timeout 10 --max-time 30 "${base_url}.sha256" -o "$tmp_sum"; then + echo "Self-update: could not fetch checksum from ${SELF_UPDATE_BRANCH}; skipping update." >&2 + return 0 fi - if ! curl -fsSL "${base_url}.sha256" -o "$tmp_sum"; then - echo "Self-update: failed to download checksum; aborting (integrity unverifiable)." >&2 - return 1 + # Published sidecar is " "; take the first field. + expected_sum=$(awk '{print $1; exit}' "$tmp_sum") + if [[ -f "$target_path" ]]; then + local_sum=$(_self_update_sha256 "$target_path") + if [[ -n "$expected_sum" && "$local_sum" == "$expected_sum" ]]; then + return 0 + fi fi - if ! head -c2 "$tmp_script" | grep -q '^#!'; then - echo "Self-update: downloaded file is not a script; aborting." >&2 - return 1 + if ! curl -fsSL --connect-timeout 10 --max-time 30 "$base_url" -o "$tmp_script"; then + echo "Self-update: could not download latest script; skipping update." >&2 + return 0 fi - # Published sidecar is " "; take the first field. - expected_sum=$(awk '{print $1; exit}' "$tmp_sum") - actual_sum=$(shasum -a 256 "$tmp_script" | awk '{print $1}') - if [[ -z "$expected_sum" || "$expected_sum" != "$actual_sum" ]]; then + actual_sum=$(_self_update_sha256 "$tmp_script") + if [[ -z "$expected_sum" || -z "$actual_sum" || "$expected_sum" != "$actual_sum" ]]; then echo "Self-update: checksum mismatch; refusing to apply." >&2 echo " expected: ${expected_sum:-}" >&2 - echo " actual: ${actual_sum}" >&2 + echo " actual: ${actual_sum:-}" >&2 + return 1 + fi + + # Sanity check AFTER integrity: ensure the verified payload is a script. + if ! head -c2 "$tmp_script" | grep -q '^#!'; then + echo "Self-update: downloaded file is not a script; aborting." >&2 return 1 fi - chmod 0755 "$tmp_script" - # Atomic replace: never overwrite the running script in place. - if mv -f "$tmp_script" "$SCRIPT_PATH"; then - echo "Self-update: updated ${SCRIPT_PATH} to pinned revision ${SELF_UPDATE_REF}." + # Stage inside the target's directory so the rename is atomic (mv across + # filesystems would degrade to a non-atomic copy). + stage_file=$(mktemp "$(dirname "$target_path")/.bs-a11y-update.XXXXXX") || { + echo "Self-update: failed to stage update next to ${target_path}." >&2 + return 1 + } + if cp "$tmp_script" "$stage_file" && chmod 0755 "$stage_file" && mv -f "$stage_file" "$target_path"; then + echo "Self-update: updated ${target_path} to latest ${SELF_UPDATE_BRANCH}." else - echo "Self-update: failed to replace ${SCRIPT_PATH}." >&2 + rm -f -- "$stage_file" + echo "Self-update: failed to replace ${target_path}." >&2 return 1 fi } -if [[ $SUBCOMMAND == "--self-update" ]]; then - script_self_update - exit $? -fi +# Best-effort auto-update: always fetch the latest launcher from main before +# running. Failures (offline, integrity) are non-fatal -- the current script +# keeps working and any update applies on the next invocation. +script_self_update || true if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then register_git_hook diff --git a/scripts/bash/spm.sh.sha256 b/scripts/bash/spm.sh.sha256 new file mode 100644 index 0000000..61897ed --- /dev/null +++ b/scripts/bash/spm.sh.sha256 @@ -0,0 +1 @@ +9be47b26350acd877948997dce43c6582da9cb0206c5c2e56db88c415c63579c spm.sh diff --git a/scripts/fish/cli.sh b/scripts/fish/cli.sh index dc36c59..d710922 100644 --- a/scripts/fish/cli.sh +++ b/scripts/fish/cli.sh @@ -90,19 +90,39 @@ a11y_scan() { $BINARY_PATH a11y $EXTRA_ARGS } -# Pinned, immutable git revision the self-update is allowed to fetch from. -# DEVA11Y-475: never fetch executable code from a mutable branch HEAD. -# Bump this (and the published .sha256 sidecars) on every release. -SELF_UPDATE_REF="db817c37cf74cba47e2fef535f53a35bfc88ec6a" +# Self-update tracks the latest launcher on `main` so users always run the +# newest version. DEVA11Y-475/477/478: we deliberately follow main HEAD rather +# than a pinned revision (per maintainer intent: always take the latest). +# Hardening retained from the pinning work: download to a temp dir, verify a +# SHA-256 sidecar (a download-integrity check, NOT an authenticity signature -- +# script and checksum share one origin), sanity-check the shebang, then +# atomically replace the on-disk script. Keep scripts/fish/cli.sh.sha256 on main in +# sync with this file (regenerate on every change) or updates will abort. +SELF_UPDATE_BRANCH="main" +readonly SELF_UPDATE_BRANCH SELF_UPDATE_RELPATH="scripts/fish/cli.sh" +readonly SELF_UPDATE_RELPATH + +# sha256 with a portable fallback: GNU `sha256sum` (Linux) or `shasum -a 256` +# (macOS / Perl Digest::SHA). +_self_update_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} -# DEVA11Y-475 / F-003: self-update is OPT-IN (run with `--self-update`), -# fetches from a pinned revision (not a mutable branch), verifies a SHA-256 -# checksum before use, and atomically replaces the script instead of -# overwriting the currently-running file in place. script_self_update() { - local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/${SELF_UPDATE_REF}/${SELF_UPDATE_RELPATH}" - local tmp_dir tmp_script tmp_sum expected_sum actual_sum + local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/${SELF_UPDATE_BRANCH}/${SELF_UPDATE_RELPATH}" + local tmp_dir tmp_script tmp_sum expected_sum actual_sum local_sum target_path stage_file + + # Resolve the on-disk target absolutely so the replace never depends on CWD. + if [[ -n "$GIT_ROOT" && "$SCRIPT_PATH" != /* ]]; then + target_path="${GIT_ROOT}/${SCRIPT_PATH}" + else + target_path="$SCRIPT_PATH" + fi tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bs-a11y-selfupdate.XXXXXX") || { echo "Self-update: failed to create temp dir." >&2 @@ -113,36 +133,50 @@ script_self_update() { tmp_script="${tmp_dir}/cli.sh" tmp_sum="${tmp_dir}/cli.sh.sha256" - if ! curl -fsSL "$base_url" -o "$tmp_script"; then - echo "Self-update: failed to download script from pinned revision." >&2 - return 1 + # Fetch the checksum first; if our on-disk copy already matches, we're current. + if ! curl -fsSL --connect-timeout 10 --max-time 30 "${base_url}.sha256" -o "$tmp_sum"; then + echo "Self-update: could not fetch checksum from ${SELF_UPDATE_BRANCH}; skipping update." >&2 + return 0 fi - if ! curl -fsSL "${base_url}.sha256" -o "$tmp_sum"; then - echo "Self-update: failed to download checksum; aborting (integrity unverifiable)." >&2 - return 1 + # Published sidecar is " "; take the first field. + expected_sum=$(awk '{print $1; exit}' "$tmp_sum") + if [[ -f "$target_path" ]]; then + local_sum=$(_self_update_sha256 "$target_path") + if [[ -n "$expected_sum" && "$local_sum" == "$expected_sum" ]]; then + return 0 + fi fi - if ! head -c2 "$tmp_script" | grep -q '^#!'; then - echo "Self-update: downloaded file is not a script; aborting." >&2 - return 1 + if ! curl -fsSL --connect-timeout 10 --max-time 30 "$base_url" -o "$tmp_script"; then + echo "Self-update: could not download latest script; skipping update." >&2 + return 0 fi - # Published sidecar is " "; take the first field. - expected_sum=$(awk '{print $1; exit}' "$tmp_sum") - actual_sum=$(shasum -a 256 "$tmp_script" | awk '{print $1}') - if [[ -z "$expected_sum" || "$expected_sum" != "$actual_sum" ]]; then + actual_sum=$(_self_update_sha256 "$tmp_script") + if [[ -z "$expected_sum" || -z "$actual_sum" || "$expected_sum" != "$actual_sum" ]]; then echo "Self-update: checksum mismatch; refusing to apply." >&2 echo " expected: ${expected_sum:-}" >&2 - echo " actual: ${actual_sum}" >&2 + echo " actual: ${actual_sum:-}" >&2 + return 1 + fi + + # Sanity check AFTER integrity: ensure the verified payload is a script. + if ! head -c2 "$tmp_script" | grep -q '^#!'; then + echo "Self-update: downloaded file is not a script; aborting." >&2 return 1 fi - chmod 0755 "$tmp_script" - # Atomic replace: never overwrite the running script in place. - if mv -f "$tmp_script" "$SCRIPT_PATH"; then - echo "Self-update: updated ${SCRIPT_PATH} to pinned revision ${SELF_UPDATE_REF}." + # Stage inside the target's directory so the rename is atomic (mv across + # filesystems would degrade to a non-atomic copy). + stage_file=$(mktemp "$(dirname "$target_path")/.bs-a11y-update.XXXXXX") || { + echo "Self-update: failed to stage update next to ${target_path}." >&2 + return 1 + } + if cp "$tmp_script" "$stage_file" && chmod 0755 "$stage_file" && mv -f "$stage_file" "$target_path"; then + echo "Self-update: updated ${target_path} to latest ${SELF_UPDATE_BRANCH}." else - echo "Self-update: failed to replace ${SCRIPT_PATH}." >&2 + rm -f -- "$stage_file" + echo "Self-update: failed to replace ${target_path}." >&2 return 1 fi } @@ -152,10 +186,10 @@ download_binary() { bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0775 "$BINARY_PATH" } -if [[ $SUBCOMMAND == "--self-update" ]]; then - script_self_update - exit $? -fi +# Best-effort auto-update: always fetch the latest launcher from main before +# running. Failures (offline, integrity) are non-fatal -- the current script +# keeps working and any update applies on the next invocation. +script_self_update || true if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then register_git_hook diff --git a/scripts/fish/cli.sh.sha256 b/scripts/fish/cli.sh.sha256 new file mode 100644 index 0000000..dc810d3 --- /dev/null +++ b/scripts/fish/cli.sh.sha256 @@ -0,0 +1 @@ +637518c077cf013e1d420d49334a5bd792b674b9b9fd04469f2198875afd7ea1 cli.sh diff --git a/scripts/fish/spm.sh b/scripts/fish/spm.sh index 69fcda0..30e749b 100644 --- a/scripts/fish/spm.sh +++ b/scripts/fish/spm.sh @@ -99,19 +99,39 @@ EOF scan $EXTRA_ARGS } -# Pinned, immutable git revision the self-update is allowed to fetch from. -# DEVA11Y-478: never fetch executable code from a mutable branch HEAD. -# Bump this (and the published .sha256 sidecars) on every release. -SELF_UPDATE_REF="db817c37cf74cba47e2fef535f53a35bfc88ec6a" +# Self-update tracks the latest launcher on `main` so users always run the +# newest version. DEVA11Y-475/477/478: we deliberately follow main HEAD rather +# than a pinned revision (per maintainer intent: always take the latest). +# Hardening retained from the pinning work: download to a temp dir, verify a +# SHA-256 sidecar (a download-integrity check, NOT an authenticity signature -- +# script and checksum share one origin), sanity-check the shebang, then +# atomically replace the on-disk script. Keep scripts/fish/spm.sh.sha256 on main in +# sync with this file (regenerate on every change) or updates will abort. +SELF_UPDATE_BRANCH="main" +readonly SELF_UPDATE_BRANCH SELF_UPDATE_RELPATH="scripts/fish/spm.sh" +readonly SELF_UPDATE_RELPATH + +# sha256 with a portable fallback: GNU `sha256sum` (Linux) or `shasum -a 256` +# (macOS / Perl Digest::SHA). +_self_update_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} -# DEVA11Y-478 / F-006: self-update is OPT-IN (run with `--self-update`), -# fetches from a pinned revision (not a mutable branch), verifies a SHA-256 -# checksum before use, and atomically replaces the script instead of -# overwriting the currently-running file in place. script_self_update() { - local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/${SELF_UPDATE_REF}/${SELF_UPDATE_RELPATH}" - local tmp_dir tmp_script tmp_sum expected_sum actual_sum + local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/${SELF_UPDATE_BRANCH}/${SELF_UPDATE_RELPATH}" + local tmp_dir tmp_script tmp_sum expected_sum actual_sum local_sum target_path stage_file + + # Resolve the on-disk target absolutely so the replace never depends on CWD. + if [[ -n "$GIT_ROOT" && "$SCRIPT_PATH" != /* ]]; then + target_path="${GIT_ROOT}/${SCRIPT_PATH}" + else + target_path="$SCRIPT_PATH" + fi tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bs-a11y-selfupdate.XXXXXX") || { echo "Self-update: failed to create temp dir." >&2 @@ -122,44 +142,58 @@ script_self_update() { tmp_script="${tmp_dir}/spm.sh" tmp_sum="${tmp_dir}/spm.sh.sha256" - if ! curl -fsSL "$base_url" -o "$tmp_script"; then - echo "Self-update: failed to download script from pinned revision." >&2 - return 1 + # Fetch the checksum first; if our on-disk copy already matches, we're current. + if ! curl -fsSL --connect-timeout 10 --max-time 30 "${base_url}.sha256" -o "$tmp_sum"; then + echo "Self-update: could not fetch checksum from ${SELF_UPDATE_BRANCH}; skipping update." >&2 + return 0 fi - if ! curl -fsSL "${base_url}.sha256" -o "$tmp_sum"; then - echo "Self-update: failed to download checksum; aborting (integrity unverifiable)." >&2 - return 1 + # Published sidecar is " "; take the first field. + expected_sum=$(awk '{print $1; exit}' "$tmp_sum") + if [[ -f "$target_path" ]]; then + local_sum=$(_self_update_sha256 "$target_path") + if [[ -n "$expected_sum" && "$local_sum" == "$expected_sum" ]]; then + return 0 + fi fi - if ! head -c2 "$tmp_script" | grep -q '^#!'; then - echo "Self-update: downloaded file is not a script; aborting." >&2 - return 1 + if ! curl -fsSL --connect-timeout 10 --max-time 30 "$base_url" -o "$tmp_script"; then + echo "Self-update: could not download latest script; skipping update." >&2 + return 0 fi - # Published sidecar is " "; take the first field. - expected_sum=$(awk '{print $1; exit}' "$tmp_sum") - actual_sum=$(shasum -a 256 "$tmp_script" | awk '{print $1}') - if [[ -z "$expected_sum" || "$expected_sum" != "$actual_sum" ]]; then + actual_sum=$(_self_update_sha256 "$tmp_script") + if [[ -z "$expected_sum" || -z "$actual_sum" || "$expected_sum" != "$actual_sum" ]]; then echo "Self-update: checksum mismatch; refusing to apply." >&2 echo " expected: ${expected_sum:-}" >&2 - echo " actual: ${actual_sum}" >&2 + echo " actual: ${actual_sum:-}" >&2 + return 1 + fi + + # Sanity check AFTER integrity: ensure the verified payload is a script. + if ! head -c2 "$tmp_script" | grep -q '^#!'; then + echo "Self-update: downloaded file is not a script; aborting." >&2 return 1 fi - chmod 0755 "$tmp_script" - # Atomic replace: never overwrite the running script in place. - if mv -f "$tmp_script" "$SCRIPT_PATH"; then - echo "Self-update: updated ${SCRIPT_PATH} to pinned revision ${SELF_UPDATE_REF}." + # Stage inside the target's directory so the rename is atomic (mv across + # filesystems would degrade to a non-atomic copy). + stage_file=$(mktemp "$(dirname "$target_path")/.bs-a11y-update.XXXXXX") || { + echo "Self-update: failed to stage update next to ${target_path}." >&2 + return 1 + } + if cp "$tmp_script" "$stage_file" && chmod 0755 "$stage_file" && mv -f "$stage_file" "$target_path"; then + echo "Self-update: updated ${target_path} to latest ${SELF_UPDATE_BRANCH}." else - echo "Self-update: failed to replace ${SCRIPT_PATH}." >&2 + rm -f -- "$stage_file" + echo "Self-update: failed to replace ${target_path}." >&2 return 1 fi } -if [[ $SUBCOMMAND == "--self-update" ]]; then - script_self_update - exit $? -fi +# Best-effort auto-update: always fetch the latest launcher from main before +# running. Failures (offline, integrity) are non-fatal -- the current script +# keeps working and any update applies on the next invocation. +script_self_update || true if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then register_git_hook diff --git a/scripts/fish/spm.sh.sha256 b/scripts/fish/spm.sh.sha256 new file mode 100644 index 0000000..224af1f --- /dev/null +++ b/scripts/fish/spm.sh.sha256 @@ -0,0 +1 @@ +813d10eb97ebef74e28da3037cf87d631f771c8eb9af452c5d78434bfda586aa spm.sh diff --git a/scripts/zsh/cli.sh b/scripts/zsh/cli.sh index 6da0e98..5aaa171 100644 --- a/scripts/zsh/cli.sh +++ b/scripts/zsh/cli.sh @@ -89,19 +89,39 @@ a11y_scan() { $BINARY_PATH a11y $EXTRA_ARGS } -# Pinned, immutable git revision the self-update is allowed to fetch from. -# DEVA11Y-475: never fetch executable code from a mutable branch HEAD. -# Bump this (and the published .sha256 sidecars) on every release. -SELF_UPDATE_REF="db817c37cf74cba47e2fef535f53a35bfc88ec6a" +# Self-update tracks the latest launcher on `main` so users always run the +# newest version. DEVA11Y-475/477/478: we deliberately follow main HEAD rather +# than a pinned revision (per maintainer intent: always take the latest). +# Hardening retained from the pinning work: download to a temp dir, verify a +# SHA-256 sidecar (a download-integrity check, NOT an authenticity signature -- +# script and checksum share one origin), sanity-check the shebang, then +# atomically replace the on-disk script. Keep scripts/zsh/cli.sh.sha256 on main in +# sync with this file (regenerate on every change) or updates will abort. +SELF_UPDATE_BRANCH="main" +readonly SELF_UPDATE_BRANCH SELF_UPDATE_RELPATH="scripts/zsh/cli.sh" +readonly SELF_UPDATE_RELPATH + +# sha256 with a portable fallback: GNU `sha256sum` (Linux) or `shasum -a 256` +# (macOS / Perl Digest::SHA). +_self_update_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} -# DEVA11Y-475 / F-003: self-update is OPT-IN (run with `--self-update`), -# fetches from a pinned revision (not a mutable branch), verifies a SHA-256 -# checksum before use, and atomically replaces the script instead of -# overwriting the currently-running file in place. script_self_update() { - local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/${SELF_UPDATE_REF}/${SELF_UPDATE_RELPATH}" - local tmp_dir tmp_script tmp_sum expected_sum actual_sum + local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/${SELF_UPDATE_BRANCH}/${SELF_UPDATE_RELPATH}" + local tmp_dir tmp_script tmp_sum expected_sum actual_sum local_sum target_path stage_file + + # Resolve the on-disk target absolutely so the replace never depends on CWD. + if [[ -n "$GIT_ROOT" && "$SCRIPT_PATH" != /* ]]; then + target_path="${GIT_ROOT}/${SCRIPT_PATH}" + else + target_path="$SCRIPT_PATH" + fi tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bs-a11y-selfupdate.XXXXXX") || { echo "Self-update: failed to create temp dir." >&2 @@ -112,36 +132,50 @@ script_self_update() { tmp_script="${tmp_dir}/cli.sh" tmp_sum="${tmp_dir}/cli.sh.sha256" - if ! curl -fsSL "$base_url" -o "$tmp_script"; then - echo "Self-update: failed to download script from pinned revision." >&2 - return 1 + # Fetch the checksum first; if our on-disk copy already matches, we're current. + if ! curl -fsSL --connect-timeout 10 --max-time 30 "${base_url}.sha256" -o "$tmp_sum"; then + echo "Self-update: could not fetch checksum from ${SELF_UPDATE_BRANCH}; skipping update." >&2 + return 0 fi - if ! curl -fsSL "${base_url}.sha256" -o "$tmp_sum"; then - echo "Self-update: failed to download checksum; aborting (integrity unverifiable)." >&2 - return 1 + # Published sidecar is " "; take the first field. + expected_sum=$(awk '{print $1; exit}' "$tmp_sum") + if [[ -f "$target_path" ]]; then + local_sum=$(_self_update_sha256 "$target_path") + if [[ -n "$expected_sum" && "$local_sum" == "$expected_sum" ]]; then + return 0 + fi fi - if ! head -c2 "$tmp_script" | grep -q '^#!'; then - echo "Self-update: downloaded file is not a script; aborting." >&2 - return 1 + if ! curl -fsSL --connect-timeout 10 --max-time 30 "$base_url" -o "$tmp_script"; then + echo "Self-update: could not download latest script; skipping update." >&2 + return 0 fi - # Published sidecar is " "; take the first field. - expected_sum=$(awk '{print $1; exit}' "$tmp_sum") - actual_sum=$(shasum -a 256 "$tmp_script" | awk '{print $1}') - if [[ -z "$expected_sum" || "$expected_sum" != "$actual_sum" ]]; then + actual_sum=$(_self_update_sha256 "$tmp_script") + if [[ -z "$expected_sum" || -z "$actual_sum" || "$expected_sum" != "$actual_sum" ]]; then echo "Self-update: checksum mismatch; refusing to apply." >&2 echo " expected: ${expected_sum:-}" >&2 - echo " actual: ${actual_sum}" >&2 + echo " actual: ${actual_sum:-}" >&2 + return 1 + fi + + # Sanity check AFTER integrity: ensure the verified payload is a script. + if ! head -c2 "$tmp_script" | grep -q '^#!'; then + echo "Self-update: downloaded file is not a script; aborting." >&2 return 1 fi - chmod 0755 "$tmp_script" - # Atomic replace: never overwrite the running script in place. - if mv -f "$tmp_script" "$SCRIPT_PATH"; then - echo "Self-update: updated ${SCRIPT_PATH} to pinned revision ${SELF_UPDATE_REF}." + # Stage inside the target's directory so the rename is atomic (mv across + # filesystems would degrade to a non-atomic copy). + stage_file=$(mktemp "$(dirname "$target_path")/.bs-a11y-update.XXXXXX") || { + echo "Self-update: failed to stage update next to ${target_path}." >&2 + return 1 + } + if cp "$tmp_script" "$stage_file" && chmod 0755 "$stage_file" && mv -f "$stage_file" "$target_path"; then + echo "Self-update: updated ${target_path} to latest ${SELF_UPDATE_BRANCH}." else - echo "Self-update: failed to replace ${SCRIPT_PATH}." >&2 + rm -f -- "$stage_file" + echo "Self-update: failed to replace ${target_path}." >&2 return 1 fi } @@ -151,10 +185,10 @@ download_binary() { bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0775 "$BINARY_PATH" } -if [[ $SUBCOMMAND == "--self-update" ]]; then - script_self_update - exit $? -fi +# Best-effort auto-update: always fetch the latest launcher from main before +# running. Failures (offline, integrity) are non-fatal -- the current script +# keeps working and any update applies on the next invocation. +script_self_update || true if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then register_git_hook diff --git a/scripts/zsh/cli.sh.sha256 b/scripts/zsh/cli.sh.sha256 new file mode 100644 index 0000000..9cb4653 --- /dev/null +++ b/scripts/zsh/cli.sh.sha256 @@ -0,0 +1 @@ +b40064af6839493f5677b1935532b498933575cd8a92f008e495b0611c52b27d cli.sh diff --git a/scripts/zsh/spm.sh b/scripts/zsh/spm.sh index ec719e6..e59dc27 100644 --- a/scripts/zsh/spm.sh +++ b/scripts/zsh/spm.sh @@ -98,19 +98,39 @@ EOF scan $EXTRA_ARGS } -# Pinned, immutable git revision the self-update is allowed to fetch from. -# DEVA11Y-478: never fetch executable code from a mutable branch HEAD. -# Bump this (and the published .sha256 sidecars) on every release. -SELF_UPDATE_REF="db817c37cf74cba47e2fef535f53a35bfc88ec6a" +# Self-update tracks the latest launcher on `main` so users always run the +# newest version. DEVA11Y-475/477/478: we deliberately follow main HEAD rather +# than a pinned revision (per maintainer intent: always take the latest). +# Hardening retained from the pinning work: download to a temp dir, verify a +# SHA-256 sidecar (a download-integrity check, NOT an authenticity signature -- +# script and checksum share one origin), sanity-check the shebang, then +# atomically replace the on-disk script. Keep scripts/zsh/spm.sh.sha256 on main in +# sync with this file (regenerate on every change) or updates will abort. +SELF_UPDATE_BRANCH="main" +readonly SELF_UPDATE_BRANCH SELF_UPDATE_RELPATH="scripts/zsh/spm.sh" +readonly SELF_UPDATE_RELPATH + +# sha256 with a portable fallback: GNU `sha256sum` (Linux) or `shasum -a 256` +# (macOS / Perl Digest::SHA). +_self_update_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} -# DEVA11Y-478 / F-006: self-update is OPT-IN (run with `--self-update`), -# fetches from a pinned revision (not a mutable branch), verifies a SHA-256 -# checksum before use, and atomically replaces the script instead of -# overwriting the currently-running file in place. script_self_update() { - local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/${SELF_UPDATE_REF}/${SELF_UPDATE_RELPATH}" - local tmp_dir tmp_script tmp_sum expected_sum actual_sum + local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/${SELF_UPDATE_BRANCH}/${SELF_UPDATE_RELPATH}" + local tmp_dir tmp_script tmp_sum expected_sum actual_sum local_sum target_path stage_file + + # Resolve the on-disk target absolutely so the replace never depends on CWD. + if [[ -n "$GIT_ROOT" && "$SCRIPT_PATH" != /* ]]; then + target_path="${GIT_ROOT}/${SCRIPT_PATH}" + else + target_path="$SCRIPT_PATH" + fi tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bs-a11y-selfupdate.XXXXXX") || { echo "Self-update: failed to create temp dir." >&2 @@ -121,44 +141,58 @@ script_self_update() { tmp_script="${tmp_dir}/spm.sh" tmp_sum="${tmp_dir}/spm.sh.sha256" - if ! curl -fsSL "$base_url" -o "$tmp_script"; then - echo "Self-update: failed to download script from pinned revision." >&2 - return 1 + # Fetch the checksum first; if our on-disk copy already matches, we're current. + if ! curl -fsSL --connect-timeout 10 --max-time 30 "${base_url}.sha256" -o "$tmp_sum"; then + echo "Self-update: could not fetch checksum from ${SELF_UPDATE_BRANCH}; skipping update." >&2 + return 0 fi - if ! curl -fsSL "${base_url}.sha256" -o "$tmp_sum"; then - echo "Self-update: failed to download checksum; aborting (integrity unverifiable)." >&2 - return 1 + # Published sidecar is " "; take the first field. + expected_sum=$(awk '{print $1; exit}' "$tmp_sum") + if [[ -f "$target_path" ]]; then + local_sum=$(_self_update_sha256 "$target_path") + if [[ -n "$expected_sum" && "$local_sum" == "$expected_sum" ]]; then + return 0 + fi fi - if ! head -c2 "$tmp_script" | grep -q '^#!'; then - echo "Self-update: downloaded file is not a script; aborting." >&2 - return 1 + if ! curl -fsSL --connect-timeout 10 --max-time 30 "$base_url" -o "$tmp_script"; then + echo "Self-update: could not download latest script; skipping update." >&2 + return 0 fi - # Published sidecar is " "; take the first field. - expected_sum=$(awk '{print $1; exit}' "$tmp_sum") - actual_sum=$(shasum -a 256 "$tmp_script" | awk '{print $1}') - if [[ -z "$expected_sum" || "$expected_sum" != "$actual_sum" ]]; then + actual_sum=$(_self_update_sha256 "$tmp_script") + if [[ -z "$expected_sum" || -z "$actual_sum" || "$expected_sum" != "$actual_sum" ]]; then echo "Self-update: checksum mismatch; refusing to apply." >&2 echo " expected: ${expected_sum:-}" >&2 - echo " actual: ${actual_sum}" >&2 + echo " actual: ${actual_sum:-}" >&2 + return 1 + fi + + # Sanity check AFTER integrity: ensure the verified payload is a script. + if ! head -c2 "$tmp_script" | grep -q '^#!'; then + echo "Self-update: downloaded file is not a script; aborting." >&2 return 1 fi - chmod 0755 "$tmp_script" - # Atomic replace: never overwrite the running script in place. - if mv -f "$tmp_script" "$SCRIPT_PATH"; then - echo "Self-update: updated ${SCRIPT_PATH} to pinned revision ${SELF_UPDATE_REF}." + # Stage inside the target's directory so the rename is atomic (mv across + # filesystems would degrade to a non-atomic copy). + stage_file=$(mktemp "$(dirname "$target_path")/.bs-a11y-update.XXXXXX") || { + echo "Self-update: failed to stage update next to ${target_path}." >&2 + return 1 + } + if cp "$tmp_script" "$stage_file" && chmod 0755 "$stage_file" && mv -f "$stage_file" "$target_path"; then + echo "Self-update: updated ${target_path} to latest ${SELF_UPDATE_BRANCH}." else - echo "Self-update: failed to replace ${SCRIPT_PATH}." >&2 + rm -f -- "$stage_file" + echo "Self-update: failed to replace ${target_path}." >&2 return 1 fi } -if [[ $SUBCOMMAND == "--self-update" ]]; then - script_self_update - exit $? -fi +# Best-effort auto-update: always fetch the latest launcher from main before +# running. Failures (offline, integrity) are non-fatal -- the current script +# keeps working and any update applies on the next invocation. +script_self_update || true if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then register_git_hook diff --git a/scripts/zsh/spm.sh.sha256 b/scripts/zsh/spm.sh.sha256 new file mode 100644 index 0000000..8eeee6c --- /dev/null +++ b/scripts/zsh/spm.sh.sha256 @@ -0,0 +1 @@ +0ec0dc70730a10f0933ab680fe359ae163f46770cdcba72e0d5a4f727f2a67bd spm.sh