Skip to content
74 changes: 74 additions & 0 deletions .github/workflows/verify-selfupdate-checksums.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- 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.
script_files=(scripts/**/*.sh)
if [ ${#script_files[@]} -eq 0 ]; then
echo "::error::No .sh scripts found under scripts/ — checkout or glob is wrong."
exit 1
fi
for script in "${script_files[@]}"; do
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")" > /dev/null ); then
echo "::notice file=${script}::Checksum OK"
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
112 changes: 107 additions & 5 deletions scripts/bash/cli.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
#!/usr/bin/env bash -il

GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
Expand Down Expand Up @@ -78,12 +78,103 @@
$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

# 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
}
# Clean the work dir and any half-written staged file so an interrupt between
# staging and the final mv can't leak a dotfile in the target directory. The
# RETURN trap also clears the signal traps so they don't linger past this
# function (which would otherwise swallow Ctrl-C during the main command).
# tmp_dir is expanded now; stage_file is expanded when the trap fires (escaped $).
# shellcheck disable=SC2064
trap "rm -rf -- '${tmp_dir}'; rm -f -- \"\${stage_file:-}\"; trap - INT TERM" RETURN
# shellcheck disable=SC2064
trap "rm -rf -- '${tmp_dir}'; rm -f -- \"\${stage_file:-}\"; exit 130" INT TERM
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
# Integrity violation — distinct exit code (2) so the caller can tell this
# apart from a benign network skip (0) or an operational error (1).
return 2
fi

updated_script=$(curl -R -z "$SCRIPT_PATH" "$remote_url")
if [[ $updated_script =~ ^#! ]]; then
echo "$updated_script" > "$SCRIPT_PATH"
# 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 2
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") || {
Comment thread
Crash0v3rrid3 marked this conversation as resolved.
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
Comment thread
Crash0v3rrid3 marked this conversation as resolved.

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] Clear stage_file after a successful mv (clarity)

After mv -f "$stage_file" "$target_path", stage_file still holds the moved-from path, so the RETURN trap runs rm -f -- "${stage_file:-}" against it. It's a harmless no-op (the inode was renamed; rm -f ignores ENOENT), but it reads as if it could delete the script just placed.

Suggestion: set stage_file="" right after the successful mv so the trap's rm -f is unambiguously a no-op. Optional — same pattern applies to all six scripts.

Reviewer: stack:code-reviewer

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 +183,18 @@
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. Network/offline failures are silent (rc 0) and operational errors
# (rc 1) are non-fatal -- the existing script keeps working. An integrity
# failure (rc 2: checksum mismatch or non-script payload) leaves the verified
# on-disk script untouched and is surfaced loudly below, but still does not
# block the tool (per the always-run-latest, never-block design).
_self_update_rc=0
script_self_update || _self_update_rc=$?
if [[ "$_self_update_rc" -eq 2 ]]; then
echo "Self-update: integrity verification FAILED; kept the existing verified script (possible corruption or tampering)." >&2
fi

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 @@
2340d95e6ce35ea8656acb02e0b524eeccca02c12a940d44e8cd0c0032b0efdd cli.sh
112 changes: 107 additions & 5 deletions scripts/bash/spm.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
#!/usr/bin/env bash -il

[ -f "${PWD}/Package.swift" ]
Expand Down Expand Up @@ -83,16 +83,118 @@
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
}
# Clean the work dir and any half-written staged file so an interrupt between
# staging and the final mv can't leak a dotfile in the target directory. The
# RETURN trap also clears the signal traps so they don't linger past this
# function (which would otherwise swallow Ctrl-C during the main command).
# tmp_dir is expanded now; stage_file is expanded when the trap fires (escaped $).
# shellcheck disable=SC2064
trap "rm -rf -- '${tmp_dir}'; rm -f -- \"\${stage_file:-}\"; trap - INT TERM" RETURN
# shellcheck disable=SC2064
trap "rm -rf -- '${tmp_dir}'; rm -f -- \"\${stage_file:-}\"; exit 130" INT TERM
tmp_script="${tmp_dir}/spm.sh"
tmp_sum="${tmp_dir}/spm.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
# Integrity violation — distinct exit code (2) so the caller can tell this
# apart from a benign network skip (0) or an operational error (1).
return 2
fi

updated_script=$(curl -R -z "$SCRIPT_PATH" "$remote_url")
if [[ $updated_script =~ ^#! ]]; then
echo "$updated_script" > "$SCRIPT_PATH"
# 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 2
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. Network/offline failures are silent (rc 0) and operational errors
# (rc 1) are non-fatal -- the existing script keeps working. An integrity
# failure (rc 2: checksum mismatch or non-script payload) leaves the verified
# on-disk script untouched and is surfaced loudly below, but still does not
# block the tool (per the always-run-latest, never-block design).
_self_update_rc=0
script_self_update || _self_update_rc=$?
if [[ "$_self_update_rc" -eq 2 ]]; then
echo "Self-update: integrity verification FAILED; kept the existing verified script (possible corruption or tampering)." >&2
fi

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 @@
2d627fbe06991da445f7fbcd8bc8c4f5c4dd126c04372f74dd0f4ab39222c57e spm.sh
Loading
Loading