Refuse pull_request_target invocations in every reusable workflow#213
Open
TomHennen wants to merge 5 commits into
Open
Refuse pull_request_target invocations in every reusable workflow#213TomHennen wants to merge 5 commits into
TomHennen wants to merge 5 commits into
Conversation
TomHennen
commented
May 14, 2026
Owner
Author
There was a problem hiding this comment.
I bet we do this same thing everywhere and want to use it everywhere, can it be put into some common thing (like an action all these workflows call)?
Maybe something with a somewhat more generic name there might be other things we want to check in the future?
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).
Continues #202 — the npm workflow and the bats test follow in subsequent commits on this branch.
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).
…odel 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
d68fe3f to
e3153c9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #202. Defense-in-depth against the "pwn request" pattern that compromised TanStack/router via Mini Shai-Hulud in May 2026.
Every reusable workflow in
.github/workflows/now declares aguardjob at the head ofjobs:. The guard fails fast when the calling workflow's trigger ispull_request_target(orworkflow_runtriggered bypull_request_target). Every other job in each workflow now listsguardin itsneeds:array, so a refused invocation skips the entire workflow — no OIDC tokens minted, no privileged actions executed, no docker push, no provenance generation.No legitimate wrangle adoption uses
pull_request_target. The guard costs nothing for correctly-configured callers (a sub-second job) 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.ymltest/test_refuse_pull_request_target.bats(new)Guard step design
Security review
permissions: {}— guard reads only env vars. No workspace, no API access, no secrets, no checkout. The least privileged step in the workflow.${{ }}interpolation into the script body.github.event_nameandgithub.event.workflow_run.eventare passed via the step'senv:block asEVENT_NAMEandOUTER_EVENT, then referenced as"$EVENT_NAME"/"$OUTER_EVENT". This protects against shell injection if either value ever contains quote characters (currently it can't — GitHub validatesevent_namestrictly — but the env-pass pattern is the wrangle convention everywhere else in the codebase, so let's match it).jobs:in every workflow. The bats test enforces this, so a future refactor can't accidentally move the guard below a job that side-effects (build:runs docker push mid-composite, for example).if:. The guard sits in the calling reusable workflow, not in a nested call, so its startup is unconditional. The provenance generator (the most sensitive nested call) gates ongate.outputs.should-releaseANDneeds: [guard, ...], so it can't run if guard fails.build/actions/container. Thebuild:job nowneeds: [guard], so on a refused invocation, the runner never reaches the push step. This closes the worst-case gap for the build type that has a non-rollback-able privileged side effect.What this doesn't catch
with: ref: ${{ github.event.pull_request.head.sha }}from a non-pull_request_targettrigger. zizmor's domain (the "untrusted checkout" finding). Wrangle's source-scan composite already runs zizmor.workflow_dispatchfrom a workflow that was itself triggered bypull_request_target, GitHub flattens the event chain toworkflow_dispatchand the guard sees only that. Out of scope; the workflow_run check is the most-common indirect vector.Test plan
test/test_refuse_pull_request_target.batsruns and passes. 7 structural assertions × 5 workflows = 35 checks. I validated the awk/grep logic locally before pushing.id: namesstep's pattern.pwn_request_guardaction underactions/(mirroringrelease_gate's shape) instead of inline bash, say so and I'll convert it — the issue's proposed implementation was inline, so I matched that.Related
docs/SPEC.md threat model.docs/SPEC.mdalready has a "Threat Model" section (tool-binary integrity scope); a follow-up doc PR could add a "Trigger model" subsection that the error directly cites. Not done here to keep the PR focused.https://claude.ai/code/session_01AqkojrFQsx5CgHGndN96E2
Generated by Claude Code