From 9513636f5b6c8f37c6e65d7dc6a457bcf2212cb7 Mon Sep 17 00:00:00 2001 From: Tom Hennen Date: Tue, 12 May 2026 22:17:14 -0400 Subject: [PATCH 1/6] Refuse pull_request_target invocations in every reusable workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #202. Adds a `guard` job at the head of every reusable workflow that fails fast when the calling workflow's trigger is `pull_request_target` (or `workflow_run` triggered by `pull_request_target`). Every existing job now lists `guard` in its `needs:` so a refused invocation skips the entire workflow — no OIDC tokens minted, no privileged actions executed, no docker push. Defense-in-depth against the "pwn request" pattern that compromised TanStack/router via Mini Shai-Hulud in May 2026. No legitimate wrangle adoption uses pull_request_target; the guard costs nothing for correctly-configured callers and closes a class of adopter-side misconfigurations. Files touched: - .github/workflows/build_and_publish_npm.yml - .github/workflows/build_and_publish_python.yml - .github/workflows/build_and_publish_container.yml - .github/workflows/build_shell.yml - .github/workflows/check_source_change.yml - test/test_refuse_pull_request_target.bats (new — structural coverage) The guard step: - Has permissions: {} — reads only env, no workspace, no API, no secrets. - Uses env: to pass github.event_name / github.event.workflow_run.event rather than ${{ }}-interpolating them into the script body (avoids shell injection if those values ever contain quote characters). - Is the FIRST job in `jobs:` in every workflow; bats test asserts. - Uses bash printf (POSIX-portable, no echo -e ambiguity). --- .github/workflows/build_shell.yml | 33 +++++++++++++++++++++++ .github/workflows/check_source_change.yml | 33 +++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/.github/workflows/build_shell.yml b/.github/workflows/build_shell.yml index f514682..6614f63 100644 --- a/.github/workflows/build_shell.yml +++ b/.github/workflows/build_shell.yml @@ -19,7 +19,40 @@ on: default: "" jobs: + # Refuse pull_request_target invocations (defense-in-depth against the + # "pwn request" pattern that compromised TanStack/router via Mini + # Shai-Hulud in May 2026). pull_request_target runs in the base repo's + # privileged context, so a caller using that trigger could have + # poisoned the runner before invoking wrangle. No legitimate wrangle + # adoption uses this trigger. + # See: https://github.com/TomHennen/wrangle/issues/202 + guard: + runs-on: ubuntu-latest + permissions: {} # reads env only — no workspace, no API, no secrets + steps: + - name: Refuse pull_request_target invocations + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + OUTER_EVENT: ${{ github.event.workflow_run.event }} + run: | + set -euo pipefail + fail() { + printf '::error::wrangle refuses %s.\n' "$1" >&2 + printf "::error::This trigger pattern combined with a checkout of PR head SHA is the 'pwn request' vector that compromised TanStack/router via Mini Shai-Hulud in May 2026.\n" >&2 + printf '::error::Move build/publish to a push-triggered workflow (push to main or tags). See docs/SPEC.md threat model.\n' >&2 + exit 1 + } + if [[ "$EVENT_NAME" == "pull_request_target" ]]; then + fail "pull_request_target invocations" + fi + if [[ "$EVENT_NAME" == "workflow_run" && "$OUTER_EVENT" == "pull_request_target" ]]; then + fail "workflow_run invocations triggered by pull_request_target" + fi + printf 'Event "%s" allowed.\n' "$EVENT_NAME" + shell-build: + needs: [guard] runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/check_source_change.yml b/.github/workflows/check_source_change.yml index 929f63f..a38dc68 100644 --- a/.github/workflows/check_source_change.yml +++ b/.github/workflows/check_source_change.yml @@ -10,7 +10,40 @@ on: default: "osv zizmor scorecard:info" jobs: + # Refuse pull_request_target invocations (defense-in-depth against the + # "pwn request" pattern that compromised TanStack/router via Mini + # Shai-Hulud in May 2026). pull_request_target runs in the base repo's + # privileged context, so a caller using that trigger could have + # poisoned the runner before invoking wrangle. No legitimate wrangle + # adoption uses this trigger. + # See: https://github.com/TomHennen/wrangle/issues/202 + guard: + runs-on: ubuntu-latest + permissions: {} # reads env only — no workspace, no API, no secrets + steps: + - name: Refuse pull_request_target invocations + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + OUTER_EVENT: ${{ github.event.workflow_run.event }} + run: | + set -euo pipefail + fail() { + printf '::error::wrangle refuses %s.\n' "$1" >&2 + printf "::error::This trigger pattern combined with a checkout of PR head SHA is the 'pwn request' vector that compromised TanStack/router via Mini Shai-Hulud in May 2026.\n" >&2 + printf '::error::Move build/publish to a push-triggered workflow (push to main or tags). See docs/SPEC.md threat model.\n' >&2 + exit 1 + } + if [[ "$EVENT_NAME" == "pull_request_target" ]]; then + fail "pull_request_target invocations" + fi + if [[ "$EVENT_NAME" == "workflow_run" && "$OUTER_EVENT" == "pull_request_target" ]]; then + fail "workflow_run invocations triggered by pull_request_target" + fi + printf 'Event "%s" allowed.\n' "$EVENT_NAME" + check-change: + needs: [guard] permissions: actions: read contents: read From b7289c5d2f795f029ade6220eb768dead4fda4eb Mon Sep 17 00:00:00 2001 From: Tom Hennen Date: Tue, 12 May 2026 22:18:29 -0400 Subject: [PATCH 2/6] Add pull_request_target guard to python and container reusable workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continues #202 — the npm workflow and the bats test follow in subsequent commits on this branch. --- .../workflows/build_and_publish_container.yml | 40 ++++++++++++++++++- .../workflows/build_and_publish_python.yml | 40 ++++++++++++++++++- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build_and_publish_container.yml b/.github/workflows/build_and_publish_container.yml index 04541f0..e12f90f 100644 --- a/.github/workflows/build_and_publish_container.yml +++ b/.github/workflows/build_and_publish_container.yml @@ -68,7 +68,42 @@ on: value: ${{ jobs.gate.outputs.should-release }} jobs: + # Refuse pull_request_target invocations (defense-in-depth against the + # "pwn request" pattern that compromised TanStack/router via Mini + # Shai-Hulud in May 2026). pull_request_target runs in the base repo's + # privileged context, so a caller using that trigger could have + # poisoned the runner before invoking wrangle. No legitimate wrangle + # adoption uses this trigger. Refusal here also blocks the build job's + # docker push (which happens mid-composite, before any verify step + # could run), closing the worst-case gap for this build type. + # See: https://github.com/TomHennen/wrangle/issues/202 + guard: + runs-on: ubuntu-latest + permissions: {} # reads env only — no workspace, no API, no secrets + steps: + - name: Refuse pull_request_target invocations + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + OUTER_EVENT: ${{ github.event.workflow_run.event }} + run: | + set -euo pipefail + fail() { + printf '::error::wrangle refuses %s.\n' "$1" >&2 + printf "::error::This trigger pattern combined with a checkout of PR head SHA is the 'pwn request' vector that compromised TanStack/router via Mini Shai-Hulud in May 2026.\n" >&2 + printf '::error::Move build/publish to a push-triggered workflow (push to main or tags). See docs/SPEC.md threat model.\n' >&2 + exit 1 + } + if [[ "$EVENT_NAME" == "pull_request_target" ]]; then + fail "pull_request_target invocations" + fi + if [[ "$EVENT_NAME" == "workflow_run" && "$OUTER_EVENT" == "pull_request_target" ]]; then + fail "workflow_run invocations triggered by pull_request_target" + fi + printf 'Event "%s" allowed.\n' "$EVENT_NAME" + gate: + needs: [guard] runs-on: ubuntu-latest permissions: {} # release_gate reads only env; no workspace, no API outputs: @@ -82,6 +117,7 @@ jobs: events: ${{ inputs.release-events }} build: + needs: [guard] permissions: contents: read packages: write @@ -105,7 +141,7 @@ jobs: # Gated on release-events (default: non-pull-request) so adopters can # tighten when provenance is produced without forking the workflow. if: ${{ needs.gate.outputs.should-release == 'true' }} - needs: [gate, build] + needs: [guard, gate, build] permissions: actions: read # for detecting the Github Actions environment. id-token: write # for creating OIDC tokens for signing. @@ -138,7 +174,7 @@ jobs: # Tests assert the lockstep so a single-side bump fails CI loudly. verify: if: ${{ needs.gate.outputs.should-release == 'true' && inputs.verify-image }} - needs: [gate, build, provenance] + needs: [guard, gate, build, provenance] runs-on: ubuntu-latest # No registry credentials needed: cosign verify-attestation against # ghcr.io public images works anonymously, and the SLSA attestation diff --git a/.github/workflows/build_and_publish_python.yml b/.github/workflows/build_and_publish_python.yml index a0d53f9..7cbb549 100644 --- a/.github/workflows/build_and_publish_python.yml +++ b/.github/workflows/build_and_publish_python.yml @@ -94,11 +94,46 @@ on: value: ${{ jobs.gate.outputs.should-release }} jobs: + # Refuse pull_request_target invocations (defense-in-depth against the + # "pwn request" pattern that compromised TanStack/router via Mini + # Shai-Hulud in May 2026). pull_request_target runs in the base repo's + # privileged context, so a caller using that trigger could have + # poisoned the runner before invoking wrangle. No legitimate wrangle + # adoption uses this trigger. Every downstream job needs guard, so + # the gate, build, provenance, and verify jobs are all skipped on a + # refused invocation — no OIDC tokens minted, no privileged actions + # executed. See: https://github.com/TomHennen/wrangle/issues/202 + guard: + runs-on: ubuntu-latest + permissions: {} # reads env only — no workspace, no API, no secrets + steps: + - name: Refuse pull_request_target invocations + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + OUTER_EVENT: ${{ github.event.workflow_run.event }} + run: | + set -euo pipefail + fail() { + printf '::error::wrangle refuses %s.\n' "$1" >&2 + printf "::error::This trigger pattern combined with a checkout of PR head SHA is the 'pwn request' vector that compromised TanStack/router via Mini Shai-Hulud in May 2026.\n" >&2 + printf '::error::Move build/publish to a push-triggered workflow (push to main or tags). See docs/SPEC.md threat model.\n' >&2 + exit 1 + } + if [[ "$EVENT_NAME" == "pull_request_target" ]]; then + fail "pull_request_target invocations" + fi + if [[ "$EVENT_NAME" == "workflow_run" && "$OUTER_EVENT" == "pull_request_target" ]]; then + fail "workflow_run invocations triggered by pull_request_target" + fi + printf 'Event "%s" allowed.\n' "$EVENT_NAME" + # Decide whether release-time actions (provenance, publish) should run # for this event. Cheap separate job so the result is available to # both the provenance job below and the caller's publish job via # outputs.should-release. gate: + needs: [guard] runs-on: ubuntu-latest permissions: {} # release_gate reads only env; no workspace, no API outputs: @@ -112,6 +147,7 @@ jobs: events: ${{ inputs.release-events }} build: + needs: [guard] runs-on: ubuntu-latest permissions: contents: read # minimal — no OIDC, no publish @@ -173,7 +209,7 @@ jobs: # only uploads provenance as a workflow artifact (90-day retention). provenance: if: ${{ needs.gate.outputs.should-release == 'true' }} - needs: [gate, build] + needs: [guard, gate, build] permissions: actions: read # detect the GitHub Actions environment id-token: write # OIDC for Sigstore signing @@ -202,7 +238,7 @@ jobs: # `verify-provenance: false` and wire their own. verify: if: ${{ needs.gate.outputs.should-release == 'true' && inputs.verify-provenance }} - needs: [gate, build, provenance] + needs: [guard, gate, build, provenance] runs-on: ubuntu-latest permissions: {} # default token; download-artifact reads same-run artifacts steps: From b8bb280dde5ff88bbd228743beeab70ca49fa503 Mon Sep 17 00:00:00 2001 From: Tom Hennen Date: Tue, 12 May 2026 22:19:42 -0400 Subject: [PATCH 3/6] Add pull_request_target guard to npm reusable workflow + bats coverage Final commits for #202: the npm workflow's guard and the bats test that locks in the invariant across all 5 reusable workflows. The bats test asserts: 1. Every reusable workflow declares a `guard` job at the top of jobs:. 2. The guard step explicitly checks `pull_request_target` and `workflow_run` triggered by `pull_request_target`. 3. The pwn-request error fingerprint is present (catches no-op swaps of the guard for `exit 0`). 4. The guard job has `permissions: {}` (catches accidental widening). 5. Every non-guard job lists `guard` in its `needs:` array (catches a newly-added job missing the dependency). 6. The guard is the FIRST job in `jobs:` (catches accidental reordering that might side-effect before refusal). --- .github/workflows/build_and_publish_npm.yml | 38 +++++- test/test_refuse_pull_request_target.bats | 143 ++++++++++++++++++++ 2 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 test/test_refuse_pull_request_target.bats diff --git a/.github/workflows/build_and_publish_npm.yml b/.github/workflows/build_and_publish_npm.yml index 82da613..776a675 100644 --- a/.github/workflows/build_and_publish_npm.yml +++ b/.github/workflows/build_and_publish_npm.yml @@ -115,7 +115,40 @@ on: value: ${{ jobs.gate.outputs.should-release }} jobs: + # Refuse pull_request_target invocations (defense-in-depth against the + # "pwn request" pattern that compromised TanStack/router via Mini + # Shai-Hulud in May 2026). pull_request_target runs in the base repo's + # privileged context, so a caller using that trigger could have + # poisoned the runner before invoking wrangle. No legitimate wrangle + # adoption uses this trigger. + # See: https://github.com/TomHennen/wrangle/issues/202 + guard: + runs-on: ubuntu-latest + permissions: {} # reads env only — no workspace, no API, no secrets + steps: + - name: Refuse pull_request_target invocations + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + OUTER_EVENT: ${{ github.event.workflow_run.event }} + run: | + set -euo pipefail + fail() { + printf '::error::wrangle refuses %s.\n' "$1" >&2 + printf "::error::This trigger pattern combined with a checkout of PR head SHA is the 'pwn request' vector that compromised TanStack/router via Mini Shai-Hulud in May 2026.\n" >&2 + printf '::error::Move build/publish to a push-triggered workflow (push to main or tags). See docs/SPEC.md threat model.\n' >&2 + exit 1 + } + if [[ "$EVENT_NAME" == "pull_request_target" ]]; then + fail "pull_request_target invocations" + fi + if [[ "$EVENT_NAME" == "workflow_run" && "$OUTER_EVENT" == "pull_request_target" ]]; then + fail "workflow_run invocations triggered by pull_request_target" + fi + printf 'Event "%s" allowed.\n' "$EVENT_NAME" + gate: + needs: [guard] runs-on: ubuntu-latest permissions: {} # release_gate reads only env; no workspace, no API outputs: @@ -129,6 +162,7 @@ jobs: events: ${{ inputs.release-events }} build: + needs: [guard] runs-on: ubuntu-latest permissions: contents: read # minimal — no OIDC, no publish @@ -188,7 +222,7 @@ jobs: # write is mandatory even when upload-assets is false. provenance: if: ${{ needs.gate.outputs.should-release == 'true' }} - needs: [gate, build] + needs: [guard, gate, build] permissions: actions: read # detect the GitHub Actions environment id-token: write # OIDC for Sigstore signing @@ -210,7 +244,7 @@ jobs: # flows pass `verify-provenance: false` and wire their own. verify: if: ${{ needs.gate.outputs.should-release == 'true' && inputs.verify-provenance }} - needs: [gate, build, provenance] + needs: [guard, gate, build, provenance] runs-on: ubuntu-latest permissions: {} # default token; download-artifact reads same-run artifacts steps: diff --git a/test/test_refuse_pull_request_target.bats b/test/test_refuse_pull_request_target.bats new file mode 100644 index 0000000..9144a89 --- /dev/null +++ b/test/test_refuse_pull_request_target.bats @@ -0,0 +1,143 @@ +#!/usr/bin/env bats + +# Structural tests for the pull_request_target refusal guard added per +# issue #202. Every reusable workflow in .github/workflows/ that adopters +# can `uses:` must: +# +# 1. Declare a `guard` job at the head of `jobs:`. +# 2. Give that job `permissions: {}` (least privilege). +# 3. Run a step that fails on `pull_request_target` and +# `workflow_run`-triggered-by-`pull_request_target` events. +# 4. Have every other job include `guard` in its `needs:` list, so +# a refused invocation skips the entire workflow. +# +# These are grep-based — they don't parse YAML. The pwn-request comment, +# error string fingerprint, and exact `permissions: {}` literal are the +# load-bearing checks. Tests will break loudly if anyone refactors the +# guard out or accidentally loosens its permissions. + +setup() { + REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + WORKFLOWS_DIR="$REPO_ROOT/.github/workflows" + REUSABLE_WORKFLOWS=( + "build_and_publish_npm.yml" + "build_and_publish_python.yml" + "build_and_publish_container.yml" + "build_shell.yml" + "check_source_change.yml" + ) +} + +@test "guard: every reusable workflow has a guard job" { + for wf in "${REUSABLE_WORKFLOWS[@]}"; do + grep -qE '^ guard:$' "$WORKFLOWS_DIR/$wf" || { + printf 'Missing guard: job in %s\n' "$wf" >&2 + return 1 + } + done +} + +@test "guard: pull_request_target string is checked by name" { + for wf in "${REUSABLE_WORKFLOWS[@]}"; do + grep -qF '"$EVENT_NAME" == "pull_request_target"' "$WORKFLOWS_DIR/$wf" || { + printf 'Direct pull_request_target check missing in %s\n' "$wf" >&2 + return 1 + } + done +} + +@test "guard: workflow_run triggered by pull_request_target is also refused" { + for wf in "${REUSABLE_WORKFLOWS[@]}"; do + grep -qF '"$EVENT_NAME" == "workflow_run"' "$WORKFLOWS_DIR/$wf" || { + printf 'workflow_run check missing in %s\n' "$wf" >&2 + return 1 + } + grep -qF '"$OUTER_EVENT" == "pull_request_target"' "$WORKFLOWS_DIR/$wf" || { + printf 'workflow_run.event == pull_request_target check missing in %s\n' "$wf" >&2 + return 1 + } + done +} + +@test "guard: error message references the pwn-request vector" { + # Fingerprint that survives editorial polish but breaks if the guard + # is silently swapped for a no-op `exit 0` step. + for wf in "${REUSABLE_WORKFLOWS[@]}"; do + grep -qF "'pwn request' vector" "$WORKFLOWS_DIR/$wf" || { + printf 'pwn-request fingerprint missing in %s\n' "$wf" >&2 + return 1 + } + done +} + +@test "guard: job has permissions: {}" { + # Inspect only the guard job's block. The block starts at ` guard:` + # and ends at the next top-level job (two-space-indented `:`). + for wf in "${REUSABLE_WORKFLOWS[@]}"; do + guard_block=$(awk ' + /^ guard:$/ { in_block=1; print; next } + in_block && /^ [a-z]/ { in_block=0 } + in_block { print } + ' "$WORKFLOWS_DIR/$wf") + echo "$guard_block" | grep -qE 'permissions: \{\}' || { + printf 'guard job in %s missing permissions: {}\n' "$wf" >&2 + printf '%s\n' "$guard_block" >&2 + return 1 + } + done +} + +@test "guard: every non-guard job lists guard in its needs" { + # Failing the guard must skip every downstream job. The cheapest + # invariant to grep is "every non-guard job's needs: includes guard". + # Transitive gating via gate/build would also work, but the explicit + # form is easier to audit at a glance and breaks loudly if a later + # job is added without the guard dependency. + for wf in "${REUSABLE_WORKFLOWS[@]}"; do + # Extract top-level job names (two-space-indented `:`). + jobs=$(awk ' + /^jobs:$/ { in_jobs=1; next } + in_jobs && /^[a-zA-Z]/ { in_jobs=0 } + in_jobs && /^ [a-zA-Z][a-zA-Z0-9_-]*:$/ { + name=$1; sub(":", "", name); print name + } + ' "$WORKFLOWS_DIR/$wf") + [ -n "$jobs" ] || { + printf 'No jobs found in %s — awk extraction broken?\n' "$wf" >&2 + return 1 + } + for job in $jobs; do + [ "$job" = "guard" ] && continue + # Pull lines from ` :` until the next top-level job + # declaration. + block=$(awk -v j="$job" ' + $0 == " " j ":" { in_job=1; print; next } + in_job && /^ [a-zA-Z][a-zA-Z0-9_-]*:$/ { in_job=0 } + in_job { print } + ' "$WORKFLOWS_DIR/$wf") + echo "$block" | grep -qE '^[[:space:]]*needs:.*\[.*\bguard\b.*\]' || { + printf "Job '%s' in %s does not list 'guard' in its needs:\n" "$job" "$wf" >&2 + printf '%s\n' "$block" >&2 + return 1 + } + done + done +} + +@test "guard: no reusable workflow checks the event before guard runs" { + # Defense against accidentally reordering the guard so another job + # could side-effect before the refusal fires. The guard job MUST be + # the first top-level entry under `jobs:` in each reusable workflow. + for wf in "${REUSABLE_WORKFLOWS[@]}"; do + first_job=$(awk ' + /^jobs:$/ { in_jobs=1; next } + in_jobs && /^ [a-zA-Z][a-zA-Z0-9_-]*:$/ { + name=$1; sub(":", "", name); print name; exit + } + ' "$WORKFLOWS_DIR/$wf") + [ "$first_job" = "guard" ] || { + printf "First job in %s is '%s' not 'guard'\n" "$wf" "$first_job" >&2 + return 1 + } + done +} From 8f40e053e1423fb59008501f83126d7ff25fcae4 Mon Sep 17 00:00:00 2001 From: Tom Hennen Date: Wed, 13 May 2026 21:43:42 -0400 Subject: [PATCH 4/6] Extract guard to actions/preflight_guard + add docs/SPEC.md Trigger Model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback on #213: - Extract the 30-line inline guard (duplicated across 5 reusable workflows) into actions/preflight_guard, mirroring actions/release_gate's shape: composite action invoking a sibling shell script. Each workflow's guard job collapses to a single `uses:` line. - Generic name (preflight_guard, not pwn_request_guard) so future refusal categories — fork-from-untrusted, actor denylist, etc. — can be added without renaming. - New docs/SPEC.md "Trigger Model" subsection documenting: - Triggers wrangle's reusable workflows are designed for - Triggers preflight_guard refuses (and the supply-chain incidents motivating each refusal) - The guard-vs-gate distinction (table) so adopters/contributors learn it once: _guard = abort, _gate = signal - preflight_guard's error message anchors at docs/SPEC.md#trigger-model. - Bats tests split by concern: refusal-logic fingerprints live next to the action source (actions/preflight_guard/test.bats); workflow-level structural assertions stay in test/test_refuse_pull_request_target.bats (first job is guard, uses preflight_guard, permissions {}, every other job needs guard). The placeholder SHA in each workflow's `uses:` line gets bumped to this branch's HEAD in the following commit via tools/bump_action_pins.sh. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workflows/build_and_publish_container.yml | 38 ++----- .github/workflows/build_and_publish_npm.yml | 34 ++---- .../workflows/build_and_publish_python.yml | 36 ++---- .github/workflows/build_shell.yml | 34 ++---- .github/workflows/check_source_change.yml | 34 ++---- actions/preflight_guard/action.yml | 27 +++++ actions/preflight_guard/preflight_guard.sh | 32 ++++++ actions/preflight_guard/test.bats | 62 +++++++++++ docs/SPEC.md | 38 +++++++ test/test_refuse_pull_request_target.bats | 103 ++++++------------ 10 files changed, 231 insertions(+), 207 deletions(-) create mode 100644 actions/preflight_guard/action.yml create mode 100755 actions/preflight_guard/preflight_guard.sh create mode 100644 actions/preflight_guard/test.bats diff --git a/.github/workflows/build_and_publish_container.yml b/.github/workflows/build_and_publish_container.yml index e12f90f..5aa6368 100644 --- a/.github/workflows/build_and_publish_container.yml +++ b/.github/workflows/build_and_publish_container.yml @@ -68,39 +68,19 @@ on: value: ${{ jobs.gate.outputs.should-release }} jobs: - # Refuse pull_request_target invocations (defense-in-depth against the - # "pwn request" pattern that compromised TanStack/router via Mini - # Shai-Hulud in May 2026). pull_request_target runs in the base repo's - # privileged context, so a caller using that trigger could have - # poisoned the runner before invoking wrangle. No legitimate wrangle - # adoption uses this trigger. Refusal here also blocks the build job's - # docker push (which happens mid-composite, before any verify step - # could run), closing the worst-case gap for this build type. - # See: https://github.com/TomHennen/wrangle/issues/202 + # Refuse trigger patterns known to expose this workflow to supply-chain + # attack classes (pull_request_target, workflow_run via pull_request_target). + # The guard MUST stay first under `jobs:`; every other job lists `guard` + # in its `needs:` so a refused invocation skips the entire workflow — + # critical for the container build type, whose docker push happens + # mid-composite (no verify step could roll back a pushed image). + # Full trigger contract: docs/SPEC.md#trigger-model. guard: runs-on: ubuntu-latest permissions: {} # reads env only — no workspace, no API, no secrets steps: - - name: Refuse pull_request_target invocations - shell: bash - env: - EVENT_NAME: ${{ github.event_name }} - OUTER_EVENT: ${{ github.event.workflow_run.event }} - run: | - set -euo pipefail - fail() { - printf '::error::wrangle refuses %s.\n' "$1" >&2 - printf "::error::This trigger pattern combined with a checkout of PR head SHA is the 'pwn request' vector that compromised TanStack/router via Mini Shai-Hulud in May 2026.\n" >&2 - printf '::error::Move build/publish to a push-triggered workflow (push to main or tags). See docs/SPEC.md threat model.\n' >&2 - exit 1 - } - if [[ "$EVENT_NAME" == "pull_request_target" ]]; then - fail "pull_request_target invocations" - fi - if [[ "$EVENT_NAME" == "workflow_run" && "$OUTER_EVENT" == "pull_request_target" ]]; then - fail "workflow_run invocations triggered by pull_request_target" - fi - printf 'Event "%s" allowed.\n' "$EVENT_NAME" + # TODO: replace with $/actions/preflight_guard when GitHub ships $/ syntax (#136) + - uses: TomHennen/wrangle/actions/preflight_guard@b8bb280dde5ff88bbd228743beeab70ca49fa503 # claude/202-refuse-pull-request-target 2026-05-14 gate: needs: [guard] diff --git a/.github/workflows/build_and_publish_npm.yml b/.github/workflows/build_and_publish_npm.yml index 776a675..458d61a 100644 --- a/.github/workflows/build_and_publish_npm.yml +++ b/.github/workflows/build_and_publish_npm.yml @@ -115,37 +115,17 @@ on: value: ${{ jobs.gate.outputs.should-release }} jobs: - # Refuse pull_request_target invocations (defense-in-depth against the - # "pwn request" pattern that compromised TanStack/router via Mini - # Shai-Hulud in May 2026). pull_request_target runs in the base repo's - # privileged context, so a caller using that trigger could have - # poisoned the runner before invoking wrangle. No legitimate wrangle - # adoption uses this trigger. - # See: https://github.com/TomHennen/wrangle/issues/202 + # Refuse trigger patterns known to expose this workflow to supply-chain + # attack classes (pull_request_target, workflow_run via pull_request_target). + # The guard MUST stay first under `jobs:`; every other job lists `guard` + # in its `needs:` so a refused invocation skips the entire workflow. + # Full trigger contract: docs/SPEC.md#trigger-model. guard: runs-on: ubuntu-latest permissions: {} # reads env only — no workspace, no API, no secrets steps: - - name: Refuse pull_request_target invocations - shell: bash - env: - EVENT_NAME: ${{ github.event_name }} - OUTER_EVENT: ${{ github.event.workflow_run.event }} - run: | - set -euo pipefail - fail() { - printf '::error::wrangle refuses %s.\n' "$1" >&2 - printf "::error::This trigger pattern combined with a checkout of PR head SHA is the 'pwn request' vector that compromised TanStack/router via Mini Shai-Hulud in May 2026.\n" >&2 - printf '::error::Move build/publish to a push-triggered workflow (push to main or tags). See docs/SPEC.md threat model.\n' >&2 - exit 1 - } - if [[ "$EVENT_NAME" == "pull_request_target" ]]; then - fail "pull_request_target invocations" - fi - if [[ "$EVENT_NAME" == "workflow_run" && "$OUTER_EVENT" == "pull_request_target" ]]; then - fail "workflow_run invocations triggered by pull_request_target" - fi - printf 'Event "%s" allowed.\n' "$EVENT_NAME" + # TODO: replace with $/actions/preflight_guard when GitHub ships $/ syntax (#136) + - uses: TomHennen/wrangle/actions/preflight_guard@b8bb280dde5ff88bbd228743beeab70ca49fa503 # claude/202-refuse-pull-request-target 2026-05-14 gate: needs: [guard] diff --git a/.github/workflows/build_and_publish_python.yml b/.github/workflows/build_and_publish_python.yml index 7cbb549..80c3da7 100644 --- a/.github/workflows/build_and_publish_python.yml +++ b/.github/workflows/build_and_publish_python.yml @@ -94,39 +94,17 @@ on: value: ${{ jobs.gate.outputs.should-release }} jobs: - # Refuse pull_request_target invocations (defense-in-depth against the - # "pwn request" pattern that compromised TanStack/router via Mini - # Shai-Hulud in May 2026). pull_request_target runs in the base repo's - # privileged context, so a caller using that trigger could have - # poisoned the runner before invoking wrangle. No legitimate wrangle - # adoption uses this trigger. Every downstream job needs guard, so - # the gate, build, provenance, and verify jobs are all skipped on a - # refused invocation — no OIDC tokens minted, no privileged actions - # executed. See: https://github.com/TomHennen/wrangle/issues/202 + # Refuse trigger patterns known to expose this workflow to supply-chain + # attack classes (pull_request_target, workflow_run via pull_request_target). + # The guard MUST stay first under `jobs:`; every other job lists `guard` + # in its `needs:` so a refused invocation skips the entire workflow. + # Full trigger contract: docs/SPEC.md#trigger-model. guard: runs-on: ubuntu-latest permissions: {} # reads env only — no workspace, no API, no secrets steps: - - name: Refuse pull_request_target invocations - shell: bash - env: - EVENT_NAME: ${{ github.event_name }} - OUTER_EVENT: ${{ github.event.workflow_run.event }} - run: | - set -euo pipefail - fail() { - printf '::error::wrangle refuses %s.\n' "$1" >&2 - printf "::error::This trigger pattern combined with a checkout of PR head SHA is the 'pwn request' vector that compromised TanStack/router via Mini Shai-Hulud in May 2026.\n" >&2 - printf '::error::Move build/publish to a push-triggered workflow (push to main or tags). See docs/SPEC.md threat model.\n' >&2 - exit 1 - } - if [[ "$EVENT_NAME" == "pull_request_target" ]]; then - fail "pull_request_target invocations" - fi - if [[ "$EVENT_NAME" == "workflow_run" && "$OUTER_EVENT" == "pull_request_target" ]]; then - fail "workflow_run invocations triggered by pull_request_target" - fi - printf 'Event "%s" allowed.\n' "$EVENT_NAME" + # TODO: replace with $/actions/preflight_guard when GitHub ships $/ syntax (#136) + - uses: TomHennen/wrangle/actions/preflight_guard@b8bb280dde5ff88bbd228743beeab70ca49fa503 # claude/202-refuse-pull-request-target 2026-05-14 # Decide whether release-time actions (provenance, publish) should run # for this event. Cheap separate job so the result is available to diff --git a/.github/workflows/build_shell.yml b/.github/workflows/build_shell.yml index 6614f63..f8def1e 100644 --- a/.github/workflows/build_shell.yml +++ b/.github/workflows/build_shell.yml @@ -19,37 +19,17 @@ on: default: "" jobs: - # Refuse pull_request_target invocations (defense-in-depth against the - # "pwn request" pattern that compromised TanStack/router via Mini - # Shai-Hulud in May 2026). pull_request_target runs in the base repo's - # privileged context, so a caller using that trigger could have - # poisoned the runner before invoking wrangle. No legitimate wrangle - # adoption uses this trigger. - # See: https://github.com/TomHennen/wrangle/issues/202 + # Refuse trigger patterns known to expose this workflow to supply-chain + # attack classes (pull_request_target, workflow_run via pull_request_target). + # The guard MUST stay first under `jobs:`; every other job lists `guard` + # in its `needs:` so a refused invocation skips the entire workflow. + # Full trigger contract: docs/SPEC.md#trigger-model. guard: runs-on: ubuntu-latest permissions: {} # reads env only — no workspace, no API, no secrets steps: - - name: Refuse pull_request_target invocations - shell: bash - env: - EVENT_NAME: ${{ github.event_name }} - OUTER_EVENT: ${{ github.event.workflow_run.event }} - run: | - set -euo pipefail - fail() { - printf '::error::wrangle refuses %s.\n' "$1" >&2 - printf "::error::This trigger pattern combined with a checkout of PR head SHA is the 'pwn request' vector that compromised TanStack/router via Mini Shai-Hulud in May 2026.\n" >&2 - printf '::error::Move build/publish to a push-triggered workflow (push to main or tags). See docs/SPEC.md threat model.\n' >&2 - exit 1 - } - if [[ "$EVENT_NAME" == "pull_request_target" ]]; then - fail "pull_request_target invocations" - fi - if [[ "$EVENT_NAME" == "workflow_run" && "$OUTER_EVENT" == "pull_request_target" ]]; then - fail "workflow_run invocations triggered by pull_request_target" - fi - printf 'Event "%s" allowed.\n' "$EVENT_NAME" + # TODO: replace with $/actions/preflight_guard when GitHub ships $/ syntax (#136) + - uses: TomHennen/wrangle/actions/preflight_guard@b8bb280dde5ff88bbd228743beeab70ca49fa503 # claude/202-refuse-pull-request-target 2026-05-14 shell-build: needs: [guard] diff --git a/.github/workflows/check_source_change.yml b/.github/workflows/check_source_change.yml index a38dc68..a744970 100644 --- a/.github/workflows/check_source_change.yml +++ b/.github/workflows/check_source_change.yml @@ -10,37 +10,17 @@ on: default: "osv zizmor scorecard:info" jobs: - # Refuse pull_request_target invocations (defense-in-depth against the - # "pwn request" pattern that compromised TanStack/router via Mini - # Shai-Hulud in May 2026). pull_request_target runs in the base repo's - # privileged context, so a caller using that trigger could have - # poisoned the runner before invoking wrangle. No legitimate wrangle - # adoption uses this trigger. - # See: https://github.com/TomHennen/wrangle/issues/202 + # Refuse trigger patterns known to expose this workflow to supply-chain + # attack classes (pull_request_target, workflow_run via pull_request_target). + # The guard MUST stay first under `jobs:`; every other job lists `guard` + # in its `needs:` so a refused invocation skips the entire workflow. + # Full trigger contract: docs/SPEC.md#trigger-model. guard: runs-on: ubuntu-latest permissions: {} # reads env only — no workspace, no API, no secrets steps: - - name: Refuse pull_request_target invocations - shell: bash - env: - EVENT_NAME: ${{ github.event_name }} - OUTER_EVENT: ${{ github.event.workflow_run.event }} - run: | - set -euo pipefail - fail() { - printf '::error::wrangle refuses %s.\n' "$1" >&2 - printf "::error::This trigger pattern combined with a checkout of PR head SHA is the 'pwn request' vector that compromised TanStack/router via Mini Shai-Hulud in May 2026.\n" >&2 - printf '::error::Move build/publish to a push-triggered workflow (push to main or tags). See docs/SPEC.md threat model.\n' >&2 - exit 1 - } - if [[ "$EVENT_NAME" == "pull_request_target" ]]; then - fail "pull_request_target invocations" - fi - if [[ "$EVENT_NAME" == "workflow_run" && "$OUTER_EVENT" == "pull_request_target" ]]; then - fail "workflow_run invocations triggered by pull_request_target" - fi - printf 'Event "%s" allowed.\n' "$EVENT_NAME" + # TODO: replace with $/actions/preflight_guard when GitHub ships $/ syntax (#136) + - uses: TomHennen/wrangle/actions/preflight_guard@b8bb280dde5ff88bbd228743beeab70ca49fa503 # claude/202-refuse-pull-request-target 2026-05-14 check-change: needs: [guard] diff --git a/actions/preflight_guard/action.yml b/actions/preflight_guard/action.yml new file mode 100644 index 0000000..567f79d --- /dev/null +++ b/actions/preflight_guard/action.yml @@ -0,0 +1,27 @@ +name: Wrangle Preflight Guard +description: > + Refuse trigger patterns that would expose wrangle's reusable workflows + to known supply-chain attack classes. Used at the head of every wrangle + reusable workflow so a refused invocation skips the entire run + (`needs: [guard]` propagation). + + Currently refuses: + - pull_request_target invocations + - workflow_run invocations triggered by pull_request_target + These are the "pwn request" vector that compromised TanStack/router via + Mini Shai-Hulud in May 2026. No legitimate wrangle adoption uses these + triggers for build/publish workflows. + + No inputs. Wrangle owns the refuse-list; adopters add `needs: [guard]` + to their downstream jobs and the rest is automatic. + + See docs/SPEC.md#trigger-model for the full trigger contract. + +runs: + using: composite + steps: + - shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + OUTER_EVENT: ${{ github.event.workflow_run.event }} + run: "${{ github.action_path }}/preflight_guard.sh" diff --git a/actions/preflight_guard/preflight_guard.sh b/actions/preflight_guard/preflight_guard.sh new file mode 100755 index 0000000..8eba40c --- /dev/null +++ b/actions/preflight_guard/preflight_guard.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# preflight_guard.sh — refuse trigger patterns known to expose wrangle's +# reusable workflows to supply-chain attack classes. Fails the workflow +# fast; downstream jobs with `needs: [guard]` skip via standard +# `needs:` propagation. +# +# Required env (set by the composite action wrapper): +# EVENT_NAME — github.event_name +# OUTER_EVENT — github.event.workflow_run.event (empty unless event is +# workflow_run) +# +# Refusals are listed below. New refusal categories belong here, with a +# matching entry in docs/SPEC.md#trigger-model. + +set -euo pipefail + +fail() { + printf '::error::wrangle refuses %s.\n' "$1" >&2 + printf "::error::This trigger pattern combined with a checkout of PR head SHA is the 'pwn request' vector that compromised TanStack/router via Mini Shai-Hulud in May 2026.\n" >&2 + printf '::error::Move build/publish to a push-triggered workflow (push to main or tags). See docs/SPEC.md#trigger-model.\n' >&2 + exit 1 +} + +if [[ "$EVENT_NAME" == "pull_request_target" ]]; then + fail "pull_request_target invocations" +fi + +if [[ "$EVENT_NAME" == "workflow_run" && "$OUTER_EVENT" == "pull_request_target" ]]; then + fail "workflow_run invocations triggered by pull_request_target" +fi + +printf 'Event "%s" allowed.\n' "$EVENT_NAME" diff --git a/actions/preflight_guard/test.bats b/actions/preflight_guard/test.bats new file mode 100644 index 0000000..368d895 --- /dev/null +++ b/actions/preflight_guard/test.bats @@ -0,0 +1,62 @@ +#!/usr/bin/env bats + +# Structural tests for the preflight_guard composite action. Tests live +# next to the source like release_gate/ — they're grep-based fingerprints +# against the action script's refusal logic, not end-to-end runs. + +setup() { + ACTION_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" + ACTION="$ACTION_DIR/action.yml" + SCRIPT="$ACTION_DIR/preflight_guard.sh" +} + +@test "preflight_guard: action.yml exists" { + [[ -f "$ACTION" ]] +} + +@test "preflight_guard: script exists and is executable" { + [[ -x "$SCRIPT" ]] +} + +@test "preflight_guard: action.yml delegates to the script" { + run grep 'preflight_guard.sh' "$ACTION" + [[ "$status" -eq 0 ]] +} + +@test "preflight_guard: action.yml passes env vars (no expression interpolation into shell body)" { + # EVENT_NAME and OUTER_EVENT must be passed via env:, not interpolated + # into the script body. Matches wrangle's injection-safety convention. + run grep -F 'EVENT_NAME: ${{ github.event_name }}' "$ACTION" + [[ "$status" -eq 0 ]] + run grep -F 'OUTER_EVENT: ${{ github.event.workflow_run.event }}' "$ACTION" + [[ "$status" -eq 0 ]] +} + +@test "preflight_guard: refuses pull_request_target by name" { + run grep -qF '"$EVENT_NAME" == "pull_request_target"' "$SCRIPT" + [[ "$status" -eq 0 ]] +} + +@test "preflight_guard: refuses workflow_run triggered by pull_request_target" { + run grep -qF '"$EVENT_NAME" == "workflow_run"' "$SCRIPT" + [[ "$status" -eq 0 ]] + run grep -qF '"$OUTER_EVENT" == "pull_request_target"' "$SCRIPT" + [[ "$status" -eq 0 ]] +} + +@test "preflight_guard: error message references the pwn-request vector" { + # Fingerprint that survives editorial polish but breaks if the guard + # is silently swapped for a no-op step. + run grep -qF "'pwn request' vector" "$SCRIPT" + [[ "$status" -eq 0 ]] +} + +@test "preflight_guard: error message points adopters at docs/SPEC.md#trigger-model" { + run grep -qF 'docs/SPEC.md#trigger-model' "$SCRIPT" + [[ "$status" -eq 0 ]] +} + +@test "preflight_guard: script fails fast (exit 1, not exit 0 or signal-only)" { + run grep -qE '^[[:space:]]*exit 1' "$SCRIPT" + [[ "$status" -eq 0 ]] +} diff --git a/docs/SPEC.md b/docs/SPEC.md index 3235ed9..3d63896 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -759,6 +759,44 @@ Wrangle runs security tools on behalf of adopting repositories. This makes it a 2. **Compromised wrangle itself** — an attacker gains commit access to the wrangle repo 3. **Malicious adapter inputs** — attacker-controlled data flows into shell commands 4. **Tool misbehavior** — a tool writes outside its output directory, exfiltrates data, or produces malicious SARIF +5. **Adopter trigger misconfiguration** — an adopter wires wrangle's reusable workflows under a GitHub Actions trigger that runs attacker-influenced code in the base repo's privileged context. See "Trigger Model" below. + +### Trigger Model + +Every wrangle reusable workflow runs a `guard` job at the head of `jobs:` (the [`actions/preflight_guard`](../actions/preflight_guard/action.yml) composite action). Refusal fails the workflow; every other job declares `needs: [guard]` so a refused invocation skips the entire run — no OIDC tokens minted, no privileged actions executed, no docker push, no provenance generation. + +**Triggers wrangle's reusable workflows are designed for:** + +- `push` to `main`, release branches, or tags. +- `push` to integration test branches (e.g., `integration/**` used by `test/integration/dispatch.sh`). +- `workflow_dispatch` (manual). +- `workflow_call` (when one wrangle reusable workflow wraps another internally). + +**Triggers `preflight_guard` refuses:** + +- `pull_request_target` — runs in the **base** repo's privileged context with the base repo's secrets, while a checkout of `${{ github.event.pull_request.head.sha }}` brings in PR-author code. This is the "pwn request" vector: attacker code executes with secrets it shouldn't have. The TanStack/router Mini Shai-Hulud compromise (May 2026) is the most-cited recent exploitation. +- `workflow_run` triggered by `pull_request_target` — indirect form of the same vector. The outer event (`github.event.workflow_run.event`) is checked, not just `github.event_name`. + +**Triggers `preflight_guard` does NOT (currently) refuse but adopters should still be careful about:** + +- `pull_request` from a fork that the workflow then `checkout`s with `ref: ${{ github.event.pull_request.head.sha }}` — this is the same untrusted-checkout pattern but without base-repo privileges, so the blast radius is smaller. `actions/scan`'s `zizmor` runs in wrangle's source-scan path catches this finding for adopters. +- `workflow_dispatch` chains where an upstream workflow was itself `pull_request_target`-triggered — GitHub flattens the event chain to `workflow_dispatch` and the guard sees only that. Out of scope; the `workflow_run`-via-`pull_request_target` check covers the most common indirect vector. + +**Guard vs. gate — two preflight check shapes:** + +Wrangle's reusable workflows have two kinds of checks that sit at workflow start. Different mechanisms, different jobs to gate downstream on: + +| | `actions/preflight_guard` | `actions/release_gate` | +|---|---|---| +| **What it does** | Refuses the workflow run if the trigger is unsafe | Decides whether release-time actions should run this event/ref | +| **Mechanism** | Fails (`exit 1`) on a refused trigger | Outputs `should-release: true/false` | +| **Downstream uses it as** | `needs: [guard]` — fail propagation | `if: needs.gate.outputs.should-release == 'true'` — signal branching | +| **What happens on a "no"** | Whole workflow fails; everything skips | Workflow succeeds; non-release jobs (build, test) still run; release-time jobs (provenance, publish) skip | +| **Why this shape** | A green ✅ with everything skipped would hide the misconfiguration — fail-loud is the security-relevant property | Legit non-release events (PR builds) should still run build/test, just not provenance/publish | + +The `_guard` / `_gate` suffix is the name's contract: `_guard` = abort on fail, `_gate` = signal and let downstream branch. + +**Adding refusal categories:** add the check to `actions/preflight_guard/preflight_guard.sh`, add a matching row to the "refuses" list above, and add a structural assertion to `actions/preflight_guard/test.bats`. ### Integrity Verification diff --git a/test/test_refuse_pull_request_target.bats b/test/test_refuse_pull_request_target.bats index 9144a89..364bcd6 100644 --- a/test/test_refuse_pull_request_target.bats +++ b/test/test_refuse_pull_request_target.bats @@ -1,20 +1,20 @@ #!/usr/bin/env bats -# Structural tests for the pull_request_target refusal guard added per -# issue #202. Every reusable workflow in .github/workflows/ that adopters -# can `uses:` must: +# Structural tests asserting that every reusable workflow in +# .github/workflows/ wires the preflight guard correctly. The guard's +# own refusal-logic tests live next to the action source in +# actions/preflight_guard/test.bats — this file only checks the +# workflow-level wiring: # -# 1. Declare a `guard` job at the head of `jobs:`. -# 2. Give that job `permissions: {}` (least privilege). -# 3. Run a step that fails on `pull_request_target` and -# `workflow_run`-triggered-by-`pull_request_target` events. -# 4. Have every other job include `guard` in its `needs:` list, so -# a refused invocation skips the entire workflow. +# 1. The first job under `jobs:` is `guard:`. +# 2. The `guard:` job has `permissions: {}` (least privilege). +# 3. The `guard:` job invokes `actions/preflight_guard` via `uses:`. +# 4. Every other job lists `guard` in its `needs:` so a refused +# invocation skips the entire workflow. # -# These are grep-based — they don't parse YAML. The pwn-request comment, -# error string fingerprint, and exact `permissions: {}` literal are the -# load-bearing checks. Tests will break loudly if anyone refactors the -# guard out or accidentally loosens its permissions. +# Grep-based; doesn't parse YAML. Tests break loudly if anyone refactors +# the guard wiring out or accidentally adds a new job without the +# `needs: [guard]` dependency. setup() { REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" @@ -28,51 +28,39 @@ setup() { ) } -@test "guard: every reusable workflow has a guard job" { +@test "wiring: first job in each reusable workflow is guard" { + # Defense against accidentally reordering the guard below a job that + # could side-effect before the refusal fires. for wf in "${REUSABLE_WORKFLOWS[@]}"; do - grep -qE '^ guard:$' "$WORKFLOWS_DIR/$wf" || { - printf 'Missing guard: job in %s\n' "$wf" >&2 - return 1 - } - done -} - -@test "guard: pull_request_target string is checked by name" { - for wf in "${REUSABLE_WORKFLOWS[@]}"; do - grep -qF '"$EVENT_NAME" == "pull_request_target"' "$WORKFLOWS_DIR/$wf" || { - printf 'Direct pull_request_target check missing in %s\n' "$wf" >&2 - return 1 - } - done -} - -@test "guard: workflow_run triggered by pull_request_target is also refused" { - for wf in "${REUSABLE_WORKFLOWS[@]}"; do - grep -qF '"$EVENT_NAME" == "workflow_run"' "$WORKFLOWS_DIR/$wf" || { - printf 'workflow_run check missing in %s\n' "$wf" >&2 - return 1 - } - grep -qF '"$OUTER_EVENT" == "pull_request_target"' "$WORKFLOWS_DIR/$wf" || { - printf 'workflow_run.event == pull_request_target check missing in %s\n' "$wf" >&2 + first_job=$(awk ' + /^jobs:$/ { in_jobs=1; next } + in_jobs && /^ [a-zA-Z][a-zA-Z0-9_-]*:$/ { + name=$1; sub(":", "", name); print name; exit + } + ' "$WORKFLOWS_DIR/$wf") + [ "$first_job" = "guard" ] || { + printf "First job in %s is '%s' not 'guard'\n" "$wf" "$first_job" >&2 return 1 } done } -@test "guard: error message references the pwn-request vector" { - # Fingerprint that survives editorial polish but breaks if the guard - # is silently swapped for a no-op `exit 0` step. +@test "wiring: guard job invokes actions/preflight_guard via uses:" { for wf in "${REUSABLE_WORKFLOWS[@]}"; do - grep -qF "'pwn request' vector" "$WORKFLOWS_DIR/$wf" || { - printf 'pwn-request fingerprint missing in %s\n' "$wf" >&2 + guard_block=$(awk ' + /^ guard:$/ { in_block=1; print; next } + in_block && /^ [a-z]/ { in_block=0 } + in_block { print } + ' "$WORKFLOWS_DIR/$wf") + echo "$guard_block" | grep -qE 'uses:[[:space:]]*TomHennen/wrangle/actions/preflight_guard@' || { + printf 'guard job in %s does not use TomHennen/wrangle/actions/preflight_guard\n' "$wf" >&2 + printf '%s\n' "$guard_block" >&2 return 1 } done } -@test "guard: job has permissions: {}" { - # Inspect only the guard job's block. The block starts at ` guard:` - # and ends at the next top-level job (two-space-indented `:`). +@test "wiring: guard job has permissions: {}" { for wf in "${REUSABLE_WORKFLOWS[@]}"; do guard_block=$(awk ' /^ guard:$/ { in_block=1; print; next } @@ -87,14 +75,13 @@ setup() { done } -@test "guard: every non-guard job lists guard in its needs" { +@test "wiring: every non-guard job lists guard in its needs" { # Failing the guard must skip every downstream job. The cheapest # invariant to grep is "every non-guard job's needs: includes guard". # Transitive gating via gate/build would also work, but the explicit # form is easier to audit at a glance and breaks loudly if a later # job is added without the guard dependency. for wf in "${REUSABLE_WORKFLOWS[@]}"; do - # Extract top-level job names (two-space-indented `:`). jobs=$(awk ' /^jobs:$/ { in_jobs=1; next } in_jobs && /^[a-zA-Z]/ { in_jobs=0 } @@ -108,8 +95,6 @@ setup() { } for job in $jobs; do [ "$job" = "guard" ] && continue - # Pull lines from ` :` until the next top-level job - # declaration. block=$(awk -v j="$job" ' $0 == " " j ":" { in_job=1; print; next } in_job && /^ [a-zA-Z][a-zA-Z0-9_-]*:$/ { in_job=0 } @@ -123,21 +108,3 @@ setup() { done done } - -@test "guard: no reusable workflow checks the event before guard runs" { - # Defense against accidentally reordering the guard so another job - # could side-effect before the refusal fires. The guard job MUST be - # the first top-level entry under `jobs:` in each reusable workflow. - for wf in "${REUSABLE_WORKFLOWS[@]}"; do - first_job=$(awk ' - /^jobs:$/ { in_jobs=1; next } - in_jobs && /^ [a-zA-Z][a-zA-Z0-9_-]*:$/ { - name=$1; sub(":", "", name); print name; exit - } - ' "$WORKFLOWS_DIR/$wf") - [ "$first_job" = "guard" ] || { - printf "First job in %s is '%s' not 'guard'\n" "$wf" "$first_job" >&2 - return 1 - } - done -} From e3153c994cd9cad55627dbab392098b6a0113361 Mon Sep 17 00:00:00 2001 From: Tom Hennen Date: Wed, 13 May 2026 21:45:31 -0400 Subject: [PATCH 5/6] Bump action pins to include the new preflight_guard reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the placeholder SHA in each reusable workflow's guard `uses:` line with this branch's HEAD, the first commit containing actions/preflight_guard/. All other inner pins (release_gate, the per-build-type composite actions) bump along per tools/bump_action_pins.sh's all-or-nothing design — the SHA shift is behaviorally a no-op for those. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build_and_publish_container.yml | 6 +++--- .github/workflows/build_and_publish_npm.yml | 6 +++--- .github/workflows/build_and_publish_python.yml | 6 +++--- .github/workflows/build_shell.yml | 4 ++-- .github/workflows/check_source_change.yml | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build_and_publish_container.yml b/.github/workflows/build_and_publish_container.yml index 5aa6368..a98bab4 100644 --- a/.github/workflows/build_and_publish_container.yml +++ b/.github/workflows/build_and_publish_container.yml @@ -80,7 +80,7 @@ jobs: permissions: {} # reads env only — no workspace, no API, no secrets steps: # TODO: replace with $/actions/preflight_guard when GitHub ships $/ syntax (#136) - - uses: TomHennen/wrangle/actions/preflight_guard@b8bb280dde5ff88bbd228743beeab70ca49fa503 # claude/202-refuse-pull-request-target 2026-05-14 + - uses: TomHennen/wrangle/actions/preflight_guard@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 gate: needs: [guard] @@ -91,7 +91,7 @@ jobs: steps: # No checkout: release_gate resolves via ${{ github.action_path }}. # TODO: replace with $/actions/release_gate when GitHub ships $/ syntax (#136) - - uses: TomHennen/wrangle/actions/release_gate@dad131c0f4faf6348952ad21b037750cb568834a # claude/v0.2-pnpm-support 2026-05-14 + - uses: TomHennen/wrangle/actions/release_gate@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 id: gate with: events: ${{ inputs.release-events }} @@ -110,7 +110,7 @@ jobs: # TODO: replace with $/build/actions/container when GitHub ships $/ syntax (#136) - name: "build and publish" id: build - uses: TomHennen/wrangle/build/actions/container@dad131c0f4faf6348952ad21b037750cb568834a # claude/v0.2-pnpm-support 2026-05-14 + uses: TomHennen/wrangle/build/actions/container@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 with: path: ${{ inputs.path }} imagename: ${{ inputs.imagename }} diff --git a/.github/workflows/build_and_publish_npm.yml b/.github/workflows/build_and_publish_npm.yml index 458d61a..5f196bb 100644 --- a/.github/workflows/build_and_publish_npm.yml +++ b/.github/workflows/build_and_publish_npm.yml @@ -125,7 +125,7 @@ jobs: permissions: {} # reads env only — no workspace, no API, no secrets steps: # TODO: replace with $/actions/preflight_guard when GitHub ships $/ syntax (#136) - - uses: TomHennen/wrangle/actions/preflight_guard@b8bb280dde5ff88bbd228743beeab70ca49fa503 # claude/202-refuse-pull-request-target 2026-05-14 + - uses: TomHennen/wrangle/actions/preflight_guard@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 gate: needs: [guard] @@ -136,7 +136,7 @@ jobs: steps: # No checkout: release_gate resolves via ${{ github.action_path }}. # TODO: replace with $/actions/release_gate when GitHub ships $/ syntax (#136) - - uses: TomHennen/wrangle/actions/release_gate@dad131c0f4faf6348952ad21b037750cb568834a # claude/v0.2-pnpm-support 2026-05-14 + - uses: TomHennen/wrangle/actions/release_gate@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 id: gate with: events: ${{ inputs.release-events }} @@ -160,7 +160,7 @@ jobs: ref: ${{ inputs.ref || '' }} # TODO: replace with $/build/actions/npm when GitHub ships $/ syntax (#136) - - uses: TomHennen/wrangle/build/actions/npm@dad131c0f4faf6348952ad21b037750cb568834a # claude/v0.2-pnpm-support 2026-05-14 + - uses: TomHennen/wrangle/build/actions/npm@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 id: build with: path: ${{ inputs.path }} diff --git a/.github/workflows/build_and_publish_python.yml b/.github/workflows/build_and_publish_python.yml index 80c3da7..c87d657 100644 --- a/.github/workflows/build_and_publish_python.yml +++ b/.github/workflows/build_and_publish_python.yml @@ -104,7 +104,7 @@ jobs: permissions: {} # reads env only — no workspace, no API, no secrets steps: # TODO: replace with $/actions/preflight_guard when GitHub ships $/ syntax (#136) - - uses: TomHennen/wrangle/actions/preflight_guard@b8bb280dde5ff88bbd228743beeab70ca49fa503 # claude/202-refuse-pull-request-target 2026-05-14 + - uses: TomHennen/wrangle/actions/preflight_guard@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 # Decide whether release-time actions (provenance, publish) should run # for this event. Cheap separate job so the result is available to @@ -119,7 +119,7 @@ jobs: steps: # No checkout: release_gate resolves via ${{ github.action_path }}. # TODO: replace with $/actions/release_gate when GitHub ships $/ syntax (#136) - - uses: TomHennen/wrangle/actions/release_gate@dad131c0f4faf6348952ad21b037750cb568834a # claude/v0.2-pnpm-support 2026-05-14 + - uses: TomHennen/wrangle/actions/release_gate@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 id: gate with: events: ${{ inputs.release-events }} @@ -142,7 +142,7 @@ jobs: ref: ${{ inputs.ref || '' }} # TODO: replace with $/build/actions/python when GitHub ships $/ syntax (#136) - - uses: TomHennen/wrangle/build/actions/python@dad131c0f4faf6348952ad21b037750cb568834a # claude/v0.2-pnpm-support 2026-05-14 + - uses: TomHennen/wrangle/build/actions/python@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 id: build with: path: ${{ inputs.path }} diff --git a/.github/workflows/build_shell.yml b/.github/workflows/build_shell.yml index f8def1e..615cadb 100644 --- a/.github/workflows/build_shell.yml +++ b/.github/workflows/build_shell.yml @@ -29,7 +29,7 @@ jobs: permissions: {} # reads env only — no workspace, no API, no secrets steps: # TODO: replace with $/actions/preflight_guard when GitHub ships $/ syntax (#136) - - uses: TomHennen/wrangle/actions/preflight_guard@b8bb280dde5ff88bbd228743beeab70ca49fa503 # claude/202-refuse-pull-request-target 2026-05-14 + - uses: TomHennen/wrangle/actions/preflight_guard@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 shell-build: needs: [guard] @@ -43,7 +43,7 @@ jobs: - name: Run shell build # TODO: replace with $/build/actions/shell when GitHub ships $/ syntax (#136) - uses: TomHennen/wrangle/build/actions/shell@dad131c0f4faf6348952ad21b037750cb568834a # claude/v0.2-pnpm-support 2026-05-14 + uses: TomHennen/wrangle/build/actions/shell@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 with: scan-path: ${{ inputs.scan-path }} bats-path: ${{ inputs.bats-path }} diff --git a/.github/workflows/check_source_change.yml b/.github/workflows/check_source_change.yml index a744970..8828e60 100644 --- a/.github/workflows/check_source_change.yml +++ b/.github/workflows/check_source_change.yml @@ -20,7 +20,7 @@ jobs: permissions: {} # reads env only — no workspace, no API, no secrets steps: # TODO: replace with $/actions/preflight_guard when GitHub ships $/ syntax (#136) - - uses: TomHennen/wrangle/actions/preflight_guard@b8bb280dde5ff88bbd228743beeab70ca49fa503 # claude/202-refuse-pull-request-target 2026-05-14 + - uses: TomHennen/wrangle/actions/preflight_guard@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 check-change: needs: [guard] @@ -38,6 +38,6 @@ jobs: # action-pattern tools (zizmor, scorecard) via uses: internally. # TODO: replace with $/actions/scan when GitHub ships $/ syntax (#136) - name: Source scan - uses: TomHennen/wrangle/actions/scan@dad131c0f4faf6348952ad21b037750cb568834a # claude/v0.2-pnpm-support 2026-05-14 + uses: TomHennen/wrangle/actions/scan@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 with: tools: ${{ inputs.tools }} From 0173f0421373c32b085ee051d403b194f39b2faa Mon Sep 17 00:00:00 2001 From: Tom Hennen Date: Thu, 14 May 2026 21:25:48 -0400 Subject: [PATCH 6/6] Address review nits: behavioral tests + set -f + style cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small fixes from review on PR #213: 1. preflight_guard.sh: add `set -f` per CLAUDE.md (scripts processing external input must disable globbing defensively). EVENT_NAME and OUTER_EVENT come from the GitHub event context. 2. actions/preflight_guard/test.bats: add 6 behavioral tests that run the script directly with EVENT_NAME / OUTER_EVENT set and assert exit code + message. Previously all tests were grep-based — a drive-by edit could silently invert a refusal condition without breaking any test. Keep the structural fingerprints as belt-and- braces against no-op swaps. 3. actions/preflight_guard/test.bats: drop redundant `-q` flag from `run grep` invocations. `run` already captures stdout. 4. test/test_refuse_pull_request_target.bats: replace `[ ]` with `[[ ]]` to match CLAUDE.md and the companion test.bats style. Co-Authored-By: Claude Opus 4.7 (1M context) --- actions/preflight_guard/preflight_guard.sh | 1 + actions/preflight_guard/test.bats | 82 ++++++++++++++-------- test/test_refuse_pull_request_target.bats | 6 +- 3 files changed, 58 insertions(+), 31 deletions(-) diff --git a/actions/preflight_guard/preflight_guard.sh b/actions/preflight_guard/preflight_guard.sh index 8eba40c..f561a0c 100755 --- a/actions/preflight_guard/preflight_guard.sh +++ b/actions/preflight_guard/preflight_guard.sh @@ -13,6 +13,7 @@ # matching entry in docs/SPEC.md#trigger-model. set -euo pipefail +set -f # env vars come from GitHub event context — disable globbing defensively fail() { printf '::error::wrangle refuses %s.\n' "$1" >&2 diff --git a/actions/preflight_guard/test.bats b/actions/preflight_guard/test.bats index 368d895..ef62839 100644 --- a/actions/preflight_guard/test.bats +++ b/actions/preflight_guard/test.bats @@ -1,8 +1,14 @@ #!/usr/bin/env bats -# Structural tests for the preflight_guard composite action. Tests live -# next to the source like release_gate/ — they're grep-based fingerprints -# against the action script's refusal logic, not end-to-end runs. +# Tests for the preflight_guard composite action. Two flavors: +# +# - Behavioral: run preflight_guard.sh directly with EVENT_NAME / +# OUTER_EVENT set, assert exit code + emitted message. These cover +# the actual refusal logic. +# - Structural: grep-based fingerprints on action.yml / the script. +# These survive script-internal refactors and break loudly if a +# drive-by edit swaps the guard for a no-op step or strips the +# env-passthrough pattern. setup() { ACTION_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" @@ -10,53 +16,73 @@ setup() { SCRIPT="$ACTION_DIR/preflight_guard.sh" } -@test "preflight_guard: action.yml exists" { - [[ -f "$ACTION" ]] +# --- behavioral --- + +@test "behavior: refuses pull_request_target" { + EVENT_NAME=pull_request_target OUTER_EVENT="" run "$SCRIPT" + [[ "$status" -eq 1 ]] + [[ "$output" == *"pull_request_target invocations"* ]] } -@test "preflight_guard: script exists and is executable" { - [[ -x "$SCRIPT" ]] +@test "behavior: refuses workflow_run triggered by pull_request_target" { + EVENT_NAME=workflow_run OUTER_EVENT=pull_request_target run "$SCRIPT" + [[ "$status" -eq 1 ]] + [[ "$output" == *"workflow_run invocations triggered by pull_request_target"* ]] } -@test "preflight_guard: action.yml delegates to the script" { - run grep 'preflight_guard.sh' "$ACTION" +@test "behavior: allows push" { + EVENT_NAME=push OUTER_EVENT="" run "$SCRIPT" [[ "$status" -eq 0 ]] + [[ "$output" == *'Event "push" allowed.'* ]] } -@test "preflight_guard: action.yml passes env vars (no expression interpolation into shell body)" { - # EVENT_NAME and OUTER_EVENT must be passed via env:, not interpolated - # into the script body. Matches wrangle's injection-safety convention. - run grep -F 'EVENT_NAME: ${{ github.event_name }}' "$ACTION" - [[ "$status" -eq 0 ]] - run grep -F 'OUTER_EVENT: ${{ github.event.workflow_run.event }}' "$ACTION" +@test "behavior: allows workflow_dispatch" { + EVENT_NAME=workflow_dispatch OUTER_EVENT="" run "$SCRIPT" [[ "$status" -eq 0 ]] } -@test "preflight_guard: refuses pull_request_target by name" { - run grep -qF '"$EVENT_NAME" == "pull_request_target"' "$SCRIPT" +@test "behavior: allows workflow_call" { + EVENT_NAME=workflow_call OUTER_EVENT="" run "$SCRIPT" [[ "$status" -eq 0 ]] } -@test "preflight_guard: refuses workflow_run triggered by pull_request_target" { - run grep -qF '"$EVENT_NAME" == "workflow_run"' "$SCRIPT" +@test "behavior: allows workflow_run triggered by push (not pull_request_target)" { + EVENT_NAME=workflow_run OUTER_EVENT=push run "$SCRIPT" [[ "$status" -eq 0 ]] - run grep -qF '"$OUTER_EVENT" == "pull_request_target"' "$SCRIPT" +} + +# --- structural --- + +@test "structure: action.yml exists" { + [[ -f "$ACTION" ]] +} + +@test "structure: script exists and is executable" { + [[ -x "$SCRIPT" ]] +} + +@test "structure: action.yml delegates to the script" { + run grep 'preflight_guard.sh' "$ACTION" [[ "$status" -eq 0 ]] } -@test "preflight_guard: error message references the pwn-request vector" { - # Fingerprint that survives editorial polish but breaks if the guard - # is silently swapped for a no-op step. - run grep -qF "'pwn request' vector" "$SCRIPT" +@test "structure: action.yml passes env vars (no expression interpolation into shell body)" { + # EVENT_NAME and OUTER_EVENT must be passed via env:, not interpolated + # into the script body. Matches wrangle's injection-safety convention. + run grep -F 'EVENT_NAME: ${{ github.event_name }}' "$ACTION" + [[ "$status" -eq 0 ]] + run grep -F 'OUTER_EVENT: ${{ github.event.workflow_run.event }}' "$ACTION" [[ "$status" -eq 0 ]] } -@test "preflight_guard: error message points adopters at docs/SPEC.md#trigger-model" { - run grep -qF 'docs/SPEC.md#trigger-model' "$SCRIPT" +@test "structure: error message references the pwn-request vector" { + # Fingerprint that survives editorial polish but breaks if the guard + # is silently swapped for a no-op step. + run grep -F "'pwn request' vector" "$SCRIPT" [[ "$status" -eq 0 ]] } -@test "preflight_guard: script fails fast (exit 1, not exit 0 or signal-only)" { - run grep -qE '^[[:space:]]*exit 1' "$SCRIPT" +@test "structure: error message points adopters at docs/SPEC.md#trigger-model" { + run grep -F 'docs/SPEC.md#trigger-model' "$SCRIPT" [[ "$status" -eq 0 ]] } diff --git a/test/test_refuse_pull_request_target.bats b/test/test_refuse_pull_request_target.bats index 364bcd6..39d19c1 100644 --- a/test/test_refuse_pull_request_target.bats +++ b/test/test_refuse_pull_request_target.bats @@ -38,7 +38,7 @@ setup() { name=$1; sub(":", "", name); print name; exit } ' "$WORKFLOWS_DIR/$wf") - [ "$first_job" = "guard" ] || { + [[ "$first_job" == "guard" ]] || { printf "First job in %s is '%s' not 'guard'\n" "$wf" "$first_job" >&2 return 1 } @@ -89,12 +89,12 @@ setup() { name=$1; sub(":", "", name); print name } ' "$WORKFLOWS_DIR/$wf") - [ -n "$jobs" ] || { + [[ -n "$jobs" ]] || { printf 'No jobs found in %s — awk extraction broken?\n' "$wf" >&2 return 1 } for job in $jobs; do - [ "$job" = "guard" ] && continue + [[ "$job" == "guard" ]] && continue block=$(awk -v j="$job" ' $0 == " " j ":" { in_job=1; print; next } in_job && /^ [a-zA-Z][a-zA-Z0-9_-]*:$/ { in_job=0 }