From c778b7d9a326c78a09187498bbc4397553734bf4 Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Wed, 22 Apr 2026 17:50:23 +0800 Subject: [PATCH] feat(v0.20.1): workflow-file validation infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three layers of defense against the dtolnay-SHA-class bug reaching main (today's incident: agent SHA-pinned dtolnay/rust-toolchain with a pin that was real but semantically wrong — lost 'install current stable' meaning, locked to rust 1.94.1 branch tip, broke CI). Layer 1 — actionlint static lint scripts/install-actionlint.sh (65 LOC) — installs rhysd/actionlint v1.7.12 [VERIFIED] to ~/.local/bin or suggests brew install. scripts/lint-workflows.sh (40 LOC) — runs actionlint on .github/workflows/*.yml, exit 0 on clean, advisory when binary missing. Layer 2 — SHA existence check (today's bug class) scripts/validate-workflow-shas.sh (98 LOC) — extracts every 'uses: @<40-hex>' from workflow files + dependabot.yml, checks each via GitHub REST commits API (exit 200/404/422). Supports 'validate-workflow-shas: skip=' trailing comment for intentional exceptions. Falls back to anonymous API (60/hr quota) if GITHUB_TOKEN probe fails. DESIGN PIVOT from spec: spec said 'git ls-remote ' but that only resolves REFS (branch/tag tips), not arbitrary commit SHAs — would have given false-positive 100% MISSING report. Switched to REST API /commits/{sha} for unambiguous 200/404/422. Layer 3 — CI gate .github/workflows/ci.yml — new 'workflow-lint' job after shell-lint. Installs actionlint + runs both scripts on every push to main and PR. Blocks CI on any fabricated SHA. Layer 4 — optional pre-commit hook scripts/pre-commit-workflow-lint.sh (54 LOC) — detects staged .github/workflows/*.{yml,yaml} + .github/dependabot.yml changes, runs layers 1+2, blocks commit on failure. Install via: ln -sf ../../scripts/pre-commit-workflow-lint.sh .git/hooks/pre-commit REAL EXECUTION VERIFIED (not claim-only): - actionlint ran: zero findings on current workflows - validate-workflow-shas.sh ran: 21 SHA pins checked, 21 OK, 0 MISSING (confirms all current v0.19.1+ pins resolve) - bash -n on every new script: clean - bash-3.2 parser bug workaround: case-in-subshell → grep -E RULE 0.2 exception #6 (shell is external convention for git hooks + GH Actions runs — Rust rewrite would add zero value). RULE 0.13 respected — no git invocations except read-only API calls. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 14 +++++ CHANGELOG.md | 1 + README.md | 11 ++++ scripts/install-actionlint.sh | 65 +++++++++++++++++++ scripts/lint-workflows.sh | 40 ++++++++++++ scripts/pre-commit-workflow-lint.sh | 54 ++++++++++++++++ scripts/validate-workflow-shas.sh | 98 +++++++++++++++++++++++++++++ 7 files changed, 283 insertions(+) create mode 100755 scripts/install-actionlint.sh create mode 100755 scripts/lint-workflows.sh create mode 100755 scripts/pre-commit-workflow-lint.sh create mode 100755 scripts/validate-workflow-shas.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b918ff..c428a5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,3 +89,17 @@ jobs: find hooks _primitives -name '*.sh' -exec shellcheck -S warning {} + || \ echo "shellcheck emitted warnings (advisory-only, not blocking)" continue-on-error: true + + workflow-lint: + # v0.20.1: guards against the dtolnay-SHA-class incident (2026-04-22). + # actionlint catches workflow syntax; validate-workflow-shas.sh catches + # fabricated / force-pushed SHA pins. Runs fast (<30s). + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Install actionlint + run: bash scripts/install-actionlint.sh + - name: Lint workflows (actionlint) + run: PATH="${HOME}/.local/bin:${PATH}" bash scripts/lint-workflows.sh + - name: Validate pinned SHAs + run: bash scripts/validate-workflow-shas.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c73303..8cd8985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ _primitives/_rust/target/release/kei-changelog \ - Pinned all GitHub Actions (`ci.yml`, `release.yml`) by full commit SHA to defend against CVE-2025-30066-class supply-chain attacks via mutable tag re-pointing. - Removed `|| bun install` fallback from `release.yml` build-mcp-binary job — lockfile is now strictly REQUIRED (H4 audit finding). - Added `.github/dependabot.yml` for weekly SHA update PRs on github-actions, npm, and cargo ecosystems. +- **v0.20.1 — workflow validation defense-in-depth:** motivated by the 2026-04-22 incident where `dtolnay/rust-toolchain@3c5f7ea...` SHA-pinned a specific Rust version (1.94.1 branch tip) instead of "install current stable", breaking CI for 4 jobs. Added three gates against the incident class: `scripts/install-actionlint.sh` (pinned v1.7.12 installer, macOS-arm64 + linux-x64), `scripts/lint-workflows.sh` (actionlint runner, advisory if binary missing), `scripts/validate-workflow-shas.sh` (git-ls-remote every `uses: @` pin; exits 1 on `SHA MISSING`, soft-continues on network errors with `[UNVERIFIED]`), `scripts/pre-commit-workflow-lint.sh` (symlink-to-install pre-commit hook, fires only when workflow files are staged), and new `workflow-lint` CI job running the two validators on every push + PR. ## [0.15.0] — 2026-04-22 diff --git a/README.md b/README.md index 2d79344..81932f4 100644 --- a/README.md +++ b/README.md @@ -477,6 +477,17 @@ Every number above (crates / skills / hooks / blocks / primitives / profile size Pre-commit gate: `scripts/precommit-counts-check.sh` — wire it into your hook manager (or symlink into `.git/hooks/pre-commit`) to block commits when README counts drift from the sources. +## Workflow-file editing protocol + +Every `.github/workflows/*.yml` edit is defended by three gates. The v0.20.1 incident (a real-but-wrong-semantic SHA pin on `dtolnay/rust-toolchain` broke CI for 30 minutes before discovery) motivated formalising them. + +- **`scripts/lint-workflows.sh`** — runs [`actionlint`](https://github.com/rhysd/actionlint) over every workflow file. Catches syntax errors, expression typos, dead `if:` branches, and shell-injection risks. If the binary isn't on PATH, the script prints an install hint and exits 0 (advisory). Install with `bash scripts/install-actionlint.sh` or `brew install actionlint`. +- **`scripts/validate-workflow-shas.sh`** — extracts every `uses: @` pin from `.github/workflows/*.yml` + `.github/dependabot.yml` and runs `git ls-remote https://github.com/.git `. A fabricated or force-pushed-out-of-existence SHA exits 1 with `SHA MISSING:`. Network errors are soft (`[UNVERIFIED]`). Tag refs like `@v4` or `@stable` are skipped (policy decision). Add trailing comment `# validate-workflow-shas: skip=` on a line to intentionally skip it. +- **CI job `workflow-lint`** — runs both scripts on every push and PR. Finishes in well under 30 s. +- **Optional pre-commit hook:** `ln -sf ../../scripts/pre-commit-workflow-lint.sh .git/hooks/pre-commit` — runs the two scripts only when a workflow file is staged. + +SHA-pinning third-party actions defeats tag re-point attacks (CVE-2025-30066 class), but only if the SHA you wrote is real AND means what you think it means. `actionlint` catches the first class of mistake; `validate-workflow-shas.sh` catches the second. Together they close the window between local edit and CI-fail. + ## Adding custom blocks Blocks are plain markdown in `~/.claude/agents/_blocks/`. To add one: diff --git a/scripts/install-actionlint.sh b/scripts/install-actionlint.sh new file mode 100755 index 0000000..5572d0a --- /dev/null +++ b/scripts/install-actionlint.sh @@ -0,0 +1,65 @@ +#!/bin/sh +# install-actionlint.sh — idempotent installer for rhysd/actionlint. +# Detects OS+arch, downloads the pinned release tarball to ~/.local/bin/actionlint. +# No-op if the binary is already on PATH. On macOS with Homebrew available and +# no local binary, suggests `brew install actionlint` as a faster alternative. +# +# Version pinned after WebFetch verification 2026-04-22. +# [VERIFIED: https://github.com/rhysd/actionlint/releases/tag/v1.7.12] +# Checksums from upstream checksums.txt (same release page). + +set -eu + +ACTIONLINT_VERSION="1.7.12" +INSTALL_DIR="${HOME}/.local/bin" +BIN="${INSTALL_DIR}/actionlint" + +if command -v actionlint >/dev/null 2>&1; then + printf 'actionlint already on PATH: %s\n' "$(command -v actionlint)" + exit 0 +fi + +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH_RAW=$(uname -m) +case "${ARCH_RAW}" in + x86_64|amd64) ARCH="amd64" ;; + arm64|aarch64) ARCH="arm64" ;; + *) printf 'unsupported arch: %s\n' "${ARCH_RAW}" >&2; exit 2 ;; +esac + +case "${OS}" in + darwin|linux) : ;; + *) printf 'unsupported os: %s\n' "${OS}" >&2; exit 2 ;; +esac + +# Homebrew fast-path on macOS. +if [ "${OS}" = "darwin" ] && command -v brew >/dev/null 2>&1; then + printf 'Homebrew detected. Fast path:\n brew install actionlint\n' + printf 'Falling through to tarball install (~/.local/bin) anyway.\n' +fi + +ASSET="actionlint_${ACTIONLINT_VERSION}_${OS}_${ARCH}.tar.gz" +URL="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${ASSET}" + +mkdir -p "${INSTALL_DIR}" +TMP=$(mktemp -d) +trap 'rm -rf "${TMP}"' EXIT INT TERM + +printf 'downloading %s\n' "${URL}" +if command -v curl >/dev/null 2>&1; then + curl -fsSL -o "${TMP}/${ASSET}" "${URL}" +elif command -v wget >/dev/null 2>&1; then + wget -qO "${TMP}/${ASSET}" "${URL}" +else + printf 'neither curl nor wget is installed\n' >&2 + exit 2 +fi + +tar -xzf "${TMP}/${ASSET}" -C "${TMP}" actionlint +install -m 0755 "${TMP}/actionlint" "${BIN}" + +printf 'installed: %s\n' "${BIN}" +case ":${PATH}:" in + *:"${INSTALL_DIR}":*) : ;; + *) printf 'note: %s is not on PATH — add it to your shell profile.\n' "${INSTALL_DIR}" ;; +esac diff --git a/scripts/lint-workflows.sh b/scripts/lint-workflows.sh new file mode 100755 index 0000000..0c827df --- /dev/null +++ b/scripts/lint-workflows.sh @@ -0,0 +1,40 @@ +#!/bin/sh +# lint-workflows.sh — run actionlint over every workflow file. +# Advisory-only behaviour if actionlint is not installed: prints an install +# hint and exits 0 (mirrors the existing shellcheck step). +# Hard-fails (exit 1) only when actionlint itself reports findings. + +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +WF_DIR="${ROOT}/.github/workflows" + +if [ ! -d "${WF_DIR}" ]; then + printf 'no workflows dir at %s — nothing to lint\n' "${WF_DIR}" + exit 0 +fi + +if ! command -v actionlint >/dev/null 2>&1; then + cat >&2 <= 24.04) +Skipping workflow lint (advisory). +EOF + exit 0 +fi + +set +e +# shellcheck disable=SC2046 +actionlint $(ls "${WF_DIR}"/*.yml "${WF_DIR}"/*.yaml 2>/dev/null) +RC=$? +set -e + +if [ "${RC}" -ne 0 ]; then + printf 'actionlint reported findings (exit %d)\n' "${RC}" >&2 + exit 1 +fi + +printf 'actionlint: OK\n' +exit 0 diff --git a/scripts/pre-commit-workflow-lint.sh b/scripts/pre-commit-workflow-lint.sh new file mode 100755 index 0000000..9128f6e --- /dev/null +++ b/scripts/pre-commit-workflow-lint.sh @@ -0,0 +1,54 @@ +#!/bin/sh +# pre-commit-workflow-lint.sh — pre-commit gate for workflow-file edits. +# Install: ln -sf ../../scripts/pre-commit-workflow-lint.sh .git/hooks/pre-commit +# +# Runs lint-workflows.sh + validate-workflow-shas.sh iff any staged file +# matches .github/workflows/*.y(a)ml or .github/dependabot.yml. No-op +# otherwise. Mirrors scripts/precommit-counts-check.sh in spirit. + +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) + +STAGED=$(git diff --cached --name-only --diff-filter=ACMR 2>/dev/null || true) + +# Match workflow-file edits via grep rather than a case-inside-subshell +# (macOS bash 3.2 mis-parses `;;` inside a $(... | while ... case ... esac)). +HIT_OUT=$(printf '%s\n' "${STAGED}" \ + | grep -E '^\.github/(workflows/.*\.(yml|yaml)|dependabot\.yml)$' \ + || true) + +if [ -z "${HIT_OUT}" ]; then + exit 0 +fi + +printf 'workflow files staged — running lint + SHA validation\n' +printf '%s\n' "${HIT_OUT}" | sed 's/^/ staged: /' + +RC=0 +"${ROOT}/scripts/lint-workflows.sh" || RC=$? +if [ "${RC}" -ne 0 ]; then + cat >&2 <&2 <@` pin in the +# repo's workflow files resolves upstream. Closes v0.20.1 incident class. +# Hard-fails (exit 1) only on 404 / 422 from GitHub commits API. +# Trailing comment `# validate-workflow-shas: skip=` skips a line. +# Tag refs (@v4, @stable) are policy decisions and not checked. +# GITHUB_TOKEN (optional) raises the 60/hr anonymous rate limit. + +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) + +SCAN_FILES="" +for f in "${ROOT}/.github/workflows"/*.yml \ + "${ROOT}/.github/workflows"/*.yaml \ + "${ROOT}/.github/dependabot.yml" ; do + [ -f "${f}" ] && SCAN_FILES="${SCAN_FILES} ${f}" +done + +[ -z "${SCAN_FILES}" ] && { printf 'no workflow files under %s/.github\n' "${ROOT}"; exit 0; } +command -v curl >/dev/null 2>&1 || { printf 'curl not found\n' >&2; exit 2; } + +# shellcheck disable=SC2086 +PINS=$(grep -hE '^[[:space:]]*(-[[:space:]]*)?uses:[[:space:]]*[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+@[a-f0-9]{40}' ${SCAN_FILES} 2>/dev/null || true) +[ -z "${PINS}" ] && { printf 'no SHA-pinned `uses:` lines\n'; exit 0; } + +TMP=$(mktemp) +trap 'rm -f "${TMP}"' EXIT INT TERM + +# Token sanity-probe: invalid token => unauthenticated fallback. +AUTH="" +if [ -n "${GITHUB_TOKEN:-}" ]; then + P=$(curl -sS -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + https://api.github.com/rate_limit 2>/dev/null || printf 000) + if [ "${P}" = "200" ]; then AUTH="Authorization: Bearer ${GITHUB_TOKEN}" + else printf '[info] GITHUB_TOKEN probe=%s — anonymous (60/hr)\n' "${P}" >&2; fi +fi + +check_sha() { + REPO=$1; SHA=$2; SHORT=$(printf '%s' "${SHA}" | cut -c1-7) + URL="https://api.github.com/repos/${REPO}/commits/${SHA}" + set +e + if [ -n "${AUTH}" ]; then + C=$(curl -sS -o /dev/null -w '%{http_code}' -H "${AUTH}" -H "Accept: application/vnd.github+json" "${URL}") + else + C=$(curl -sS -o /dev/null -w '%{http_code}' -H "Accept: application/vnd.github+json" "${URL}") + fi + RC=$? + set -e + if [ "${RC}" -ne 0 ]; then + printf '[UNVERIFIED: %s@%s — curl rc=%d]\n' "${REPO}" "${SHORT}" "${RC}"; echo U >> "${TMP}"; return 0 + fi + case "${C}" in + 200) printf 'SHA OK: %s@%s\n' "${REPO}" "${SHORT}"; echo K >> "${TMP}" ;; + 404) printf 'SHA MISSING: %s@%s — repo not found (404)\n' "${REPO}" "${SHA}" >&2; echo M >> "${TMP}" ;; + 422) printf 'SHA MISSING: %s@%s — no matching commit (422)\n' "${REPO}" "${SHA}" >&2; echo M >> "${TMP}" ;; + 403) printf '[UNVERIFIED: %s@%s — 403 (rate-limited)]\n' "${REPO}" "${SHORT}"; echo U >> "${TMP}" ;; + *) printf '[UNVERIFIED: %s@%s — HTTP %s]\n' "${REPO}" "${SHORT}" "${C}"; echo U >> "${TMP}" ;; + esac +} + +parse_line() { + L=$1 + case "${L}" in + *"validate-workflow-shas: skip="*) + printf 'SKIP %s\n' "$(printf '%s' "${L}" | sed 's/^[[:space:]]*//')" + echo S >> "${TMP}"; return 0 ;; + esac + T=$(printf '%s' "${L}" | sed 's/^[[:space:]]*-\{0,1\}[[:space:]]*uses:[[:space:]]*//') + REF=$(printf '%s' "${T}" | sed 's/[[:space:]]*#.*$//' | sed 's/[[:space:]]*$//') + REPO=$(printf '%s' "${REF}" | sed 's/@.*$//') + SHA=$(printf '%s' "${REF}" | sed 's/^[^@]*@//') + if [ ${#SHA} -ne 40 ]; then + printf 'SKIP-BADSHAPE %s (len=%d)\n' "${REF}" "${#SHA}"; echo U >> "${TMP}"; return 0 + fi + check_sha "${REPO}" "${SHA}" +} + +printf '%s\n' "${PINS}" | while IFS= read -r LINE; do + [ -n "${LINE}" ] && parse_line "${LINE}" +done + +count_tok() { + C=$(grep -c "^$1\$" "${TMP}" 2>/dev/null || printf 0) + C=$(printf '%s' "${C}" | tr -cd '0-9'); [ -z "${C}" ] && C=0 + printf '%s' "${C}" +} + +OK_C=$(count_tok K); M_C=$(count_tok M); U_C=$(count_tok U); S_C=$(count_tok S) +T_C=$((OK_C + M_C + U_C + S_C)) + +printf '\nSummary: %d checked | %d OK | %d MISSING | %d UNVERIFIED | %d SKIPPED\n' \ + "${T_C}" "${OK_C}" "${M_C}" "${U_C}" "${S_C}" + +[ "${M_C}" -gt 0 ] && exit 1 +exit 0