Skip to content
Open
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
69 changes: 69 additions & 0 deletions .github/workflows/verify-selfupdate-checksums.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Verify self-update checksums

# Self-update fetches each launcher script from `main` and verifies it against a
# committed `<script>.sha256` sidecar. If a script is edited without regenerating
# its sidecar, self-update silently breaks for every user (checksum mismatch →
# update refused). This workflow fails the PR/push when a sidecar is missing or
# out of sync, keeping the two in lockstep. (DEVA11Y-475 review follow-up.)

on:
pull_request:
paths:
- 'scripts/**'
- '.github/workflows/verify-selfupdate-checksums.yml'
push:
branches: [main]
paths:
- 'scripts/**'
- '.github/workflows/verify-selfupdate-checksums.yml'

permissions:
contents: read

jobs:
verify-sidecars:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Pin actions/checkout to a full SHA (repo convention)

The existing Semgrep.yml SHA-pins all actions (actions/checkout@c85c95e3… # v3.5.3). This @v4 floating tag breaks that convention and GitHub's supply-chain hardening guidance.

Suggestion:

Suggested change
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

Reviewer: stack:code-reviewer


- name: Verify scripts and .sha256 sidecars are in sync
run: |
set -uo pipefail
shopt -s globstar nullglob
status=0

# 1. Every self-updating script must have a sidecar.
for script in scripts/**/*.sh; do

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Add a zero-scripts guard (defensive)

With nullglob, this loop is a no-op if no *.sh are found and status stays 0. Today a true "no scripts" state is still caught by check-2's empty-sidecar guard (exit 1), but a symmetric guard here makes check-1 self-sufficient.

Suggestion: Before the loop, collect into an array and exit 1 if empty, mirroring the existing ${#sidecars[@]} -eq 0 guard.

Reviewer: stack:code-reviewer

if [ ! -f "${script}.sha256" ]; then
echo "::error file=${script}::Missing checksum sidecar ${script}.sha256. Generate it from the script's directory: shasum -a 256 <name>.sh | awk '{print \$1\" <name>.sh\"}' > <name>.sh.sha256"
status=1
fi
done

# 2. Every sidecar must match its script.
sidecars=(scripts/**/*.sha256)
if [ ${#sidecars[@]} -eq 0 ]; then
echo "::error::No .sha256 sidecars found under scripts/."
exit 1
fi
for sidecar in "${sidecars[@]}"; do
dir=$(dirname "$sidecar")
script="${sidecar%.sha256}"
if [ ! -f "$script" ]; then
echo "::error file=${sidecar}::Sidecar references missing script ${script}."
status=1
continue
fi
# Sidecars store "<sha256> <basename>", so verify from the script's dir.
if ( cd "$dir" && sha256sum -c "$(basename "$sidecar")" ); then

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Silence sha256sum -c stdout for cleaner logs

sha256sum -c prints <file>: OK/FAILED to stdout, doubling the success line and emitting an un-annotated FAILED. Failures are still surfaced via the else-branch ::error::, so this is log clarity only.

Suggestion: Redirect the check's stdout to /dev/null (sha256sum -c "$(basename "$sidecar")" > /dev/null) and rely on the annotation.

Reviewer: stack:code-reviewer

echo "OK: $sidecar"
else
echo "::error file=${script}::Checksum mismatch — regenerate ${sidecar} after editing ${script} (run from ${dir}): shasum -a 256 <name>.sh | awk '{print \$1\" <name>.sh\"}' > <name>.sh.sha256"
status=1
fi
done

if [ "$status" -ne 0 ]; then
echo "::error::Self-update checksum verification failed. Regenerate the affected .sha256 sidecar(s) and commit them."
fi
exit "$status"
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
96 changes: 91 additions & 5 deletions scripts/bash/cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,94 @@ a11y_scan() {
$BINARY_PATH a11y $EXTRA_ARGS
}

# 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
}

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/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

updated_script=$(curl -R -z "$SCRIPT_PATH" "$remote_url")
if [[ $updated_script =~ ^#! ]]; then
echo "$updated_script" > "$SCRIPT_PATH"
# 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
return 1
}
# shellcheck disable=SC2064
trap "rm -rf -- '${tmp_dir}'" RETURN
tmp_script="${tmp_dir}/cli.sh"
tmp_sum="${tmp_dir}/cli.sh.sha256"

# 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
# Published sidecar is "<sha256> <filename>"; 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 ! 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

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:-<empty>}" >&2
echo " actual: ${actual_sum:-<empty>}" >&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

# 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") || {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix: Stale temp files on SIGINT/SIGTERM.

stage_file created here in the target directory. trap ... RETURN cleans tmp_dir but stage_file only cleaned in the else branch of the cp/chmod/mv chain (line 166). If SIGINT between this mktemp and the mv, dotfile (.bs-a11y-update.XXXXXX) leaks in project directory.

Fix: Add stage_file to trap cleanup or set broader trap covering both temp artifacts.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: chmod 0755 here but download_binary (line 174) uses chmod 0775. Group-write on binary but not on script — intentional? Minor inconsistency.

echo "Self-update: updated ${target_path} to latest ${SELF_UPDATE_BRANCH}."
else
rm -f -- "$stage_file"
echo "Self-update: failed to replace ${target_path}." >&2
return 1
fi
}

Expand All @@ -92,7 +174,11 @@ download_binary() {
bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0775 "$BINARY_PATH"
}

script_self_update
# 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix: || true swallows checksum mismatch (potential MITM/corruption) same as network timeout.

User sees stderr message but script continues normally. Consider differentiating return codes:

  • Network unreachable → return 0 (skip silently)
  • Integrity violation → return 2 (warn harder or abort)

Caller decides based on code. Currently a corrupted download and a flaky network look identical to the caller.


if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then
register_git_hook
exit 0
Expand Down
1 change: 1 addition & 0 deletions scripts/bash/cli.sh.sha256
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
9af5ce77ada28741e91d2323e4664c47e7e7531e10b34168cfe6bc50a74f5d62 cli.sh
96 changes: 91 additions & 5 deletions scripts/bash/spm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -83,16 +83,102 @@ EOF
scan $EXTRA_ARGS
}

# 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
}

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/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
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"
# 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
# Published sidecar is "<sha256> <filename>"; 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 ! 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

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:-<empty>}" >&2
echo " actual: ${actual_sum:-<empty>}" >&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

# 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
rm -f -- "$stage_file"
echo "Self-update: failed to replace ${target_path}." >&2
return 1
fi
}

script_self_update
# 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
exit 0
Expand Down
1 change: 1 addition & 0 deletions scripts/bash/spm.sh.sha256
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dceb8b3a2f8b464bcd8e6c1894ee605b3fbc9714e2cbb874ccfdcacc19240232 spm.sh
Loading
Loading