fix(ci): stop executing fork code in check-scope job#559
Conversation
The check-scope job ran under pull_request_target with the base repo's GITHUB_TOKEN, but checked out and executed fork-controlled code (ref: head.sha), letting a malicious PR replace classify-pr-files.sh to exfiltrate the token or abuse write access. Check out the trusted base branch instead and fetch the PR head SHA as data only; classify-pr-files.sh already diffs by SHA and never needs the fork's working tree.
|
Note on the failing "Check PR scope" check: this is expected and actually demonstrates the bug this PR fixes, not a problem with the fix itself. `pull_request_target` workflows always run the workflow YAML from the base branch (`main`), not from the PR branch, as a security measure. Since this fix only exists on this branch, the check-scope job here still ran the old, vulnerable version of `semantic-pr.yml` (the one with `ref: head.sha`). The log confirms it: ``` That's `actions/checkout@v7` blocking the exact unsafe fork checkout described in #554. Once this PR merges, future PRs will run the corrected workflow (checkout base + `git fetch` head as data only) and this failure mode goes away. |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | ||
| with: | ||
| ref: ${{ github.event.pull_request.head.sha }} | ||
| fetch-depth: 0 |
There was a problem hiding this comment.
Consider disabling checkout credential persistence here. This job only needs to fetch and diff, and the later comment action can use its own token context.
| fetch-depth: 0 | |
| fetch-depth: 0 | |
| persist-credentials: false |
There was a problem hiding this comment.
Applied in 67548fa. Agreed, nothing in this job needs the persisted token: the head fetch works anonymously on a public repo and the sticky-comment action takes its token via its own input.
| - name: Fetch PR head | ||
| env: | ||
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | ||
| run: git fetch origin "$HEAD_SHA" |
There was a problem hiding this comment.
actions/checkout@v7 is intentionally blocking the old pwn-request pattern, so I would avoid any suggestion that opts back into checking out fork-controlled code. A safer pattern is: keep checkout on trusted base code, fetch the PR ref with plain git as data only, verify it still matches the event SHA, and continue running the trusted base copy of classify-pr-files.sh.
| - name: Fetch PR head | |
| env: | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: git fetch origin "$HEAD_SHA" | |
| - name: Fetch PR head (data only) | |
| env: | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: | | |
| set -euo pipefail | |
| # Fetch the advertised PR head ref as data only. This does not check | |
| # out or execute fork-controlled code, preserving the checkout@v7 | |
| # pull_request_target pwn-request protection. | |
| git fetch --no-tags --no-recurse-submodules origin \ | |
| "refs/pull/${PR_NUMBER}/head" | |
| fetched_sha="$(git rev-parse FETCH_HEAD)" | |
| # Ensure the ref still points at the SHA from the event payload, so we | |
| # classify exactly the reviewed head and never whatever a fork may | |
| # have pushed after the workflow was triggered. | |
| if [ "$fetched_sha" != "$HEAD_SHA" ]; then | |
| echo "::error::PR head ref ($fetched_sha) does not match event head.sha ($HEAD_SHA)." | |
| exit 1 | |
| fi |
There was a problem hiding this comment.
Applied in 67548fa, took your suggestion essentially verbatim. Fetching the advertised refs/pull/N/head is more robust than relying on unadvertised SHA fetches, and the head.sha verification closes the race where a fork pushes after the trigger. Failing closed there is fine since the new push fires a fresh synchronize run. Note the check-scope run on this PR itself will still show the old failure since pull_request_target takes the workflow from main.
Davidnet
left a comment
There was a problem hiding this comment.
Let me know what do you think and if the suggestions are clear, but happy to merge if you can tackle the suggestions
Apply review suggestions: disable persist-credentials on the trusted base checkout, fetch the advertised refs/pull/N/head instead of a bare SHA, and verify the fetched SHA matches the event payload head.sha so the job fails closed if the fork pushes after the workflow triggers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Purpose
Resolves #554. The
check-scopejob runs underpull_request_targetcarrying the base repo'sGITHUB_TOKEN(withpull-requests: write), but checked out and executed fork-controlled code viaref: ${{ github.event.pull_request.head.sha }}. A malicious fork PR could replace.github/scripts/classify-pr-files.shto exfiltrate the token or abuse write permissions (classic "pwn request").This was also surfacing as a functional break:
actions/checkout@v7(#551) now refuses to check out fork PR code by default underpull_request_target.What Changed
check-scopenow checks out the trusted base ref (omitsref:), so the trusted copy ofclassify-pr-files.shruns.git fetch origin "$HEAD_SHA"step to pull the PR head's objects as data only — never checked out, never executed.classify-pr-files.shalready only computesgit diff BASE_SHA...HEAD_SHA --name-only, so it needs the head SHA's objects, not the fork's working tree or any executable code from it.This follows the fix explicitly recommended in the issue (option 1) and avoids the discouraged
allow-unsafe-pr-checkout: trueworkaround.Testing
CI-only change; verified the new
git fetch+ diff flow reproduces the same file list the previous checkout-based diff produced, without checking out or executing fork content.