Skip to content

Refuse pull_request_target invocations in every reusable workflow#213

Open
TomHennen wants to merge 5 commits into
mainfrom
claude/202-refuse-pull-request-target
Open

Refuse pull_request_target invocations in every reusable workflow#213
TomHennen wants to merge 5 commits into
mainfrom
claude/202-refuse-pull-request-target

Conversation

@TomHennen
Copy link
Copy Markdown
Owner

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 a guard job at the head of jobs:. The guard fails fast when the calling workflow's trigger is pull_request_target (or workflow_run triggered by pull_request_target). Every other job in each workflow now lists guard in its needs: 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.yml
  • test/test_refuse_pull_request_target.bats (new)

Guard step design

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() { ... }
        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

Security review

  • permissions: {} — guard reads only env vars. No workspace, no API access, no secrets, no checkout. The least privileged step in the workflow.
  • No ${{ }} interpolation into the script body. github.event_name and github.event.workflow_run.event are passed via the step's env: block as EVENT_NAME and OUTER_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 validates event_name strictly — but the env-pass pattern is the wrangle convention everywhere else in the codebase, so let's match it).
  • First job in 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).
  • Workflow-startup blocking. GitHub validates a called reusable workflow's permissions at workflow startup regardless of 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 on gate.outputs.should-release AND needs: [guard, ...], so it can't run if guard fails.
  • Container workflow's docker push. The container build's docker push happens mid-composite in build/actions/container. The build: job now needs: [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

  • Compromised adopter who can edit wrangle. This is defense against adopter misconfiguration, not against an attacker who already has commit access to wrangle's main.
  • Caller workflows that do with: ref: ${{ github.event.pull_request.head.sha }} from a non-pull_request_target trigger. zizmor's domain (the "untrusted checkout" finding). Wrangle's source-scan composite already runs zizmor.
  • Indirect-via-workflow_dispatch. If an attacker triggers an adopter's workflow_dispatch from a workflow that was itself triggered by pull_request_target, GitHub flattens the event chain to workflow_dispatch and the guard sees only that. Out of scope; the workflow_run check is the most-common indirect vector.

Test plan

  • bats: test/test_refuse_pull_request_target.bats runs and passes. 7 structural assertions × 5 workflows = 35 checks. I validated the awk/grep logic locally before pushing.
  • zizmor / actionlint: should be clean — no new actions, no new permissions widening, no expression injection.
  • Reviewer: confirm the env-pass-instead-of-interpolate pattern matches wrangle's existing convention. I matched the python id: names step's pattern.
  • Reviewer: gut-check the "First job is guard" invariant. If you'd prefer a pwn_request_guard action under actions/ (mirroring release_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

https://claude.ai/code/session_01AqkojrFQsx5CgHGndN96E2


Generated by Claude Code

@TomHennen TomHennen temporarily deployed to integration-test May 13, 2026 02:20 — with GitHub Actions Inactive
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

TomHennen and others added 5 commits May 13, 2026 21:35
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>
@TomHennen TomHennen force-pushed the claude/202-refuse-pull-request-target branch from d68fe3f to e3153c9 Compare May 14, 2026 01:46
@TomHennen TomHennen deployed to integration-test May 14, 2026 01:46 — with GitHub Actions Active
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refuse pull_request_target invocations in every reusable workflow

1 participant