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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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