Skip to content

Commit f761add

Browse files
igerberclaude
andcommitted
Skip fork PRs in AI review workflow (closes CodeQL #11, #12)
GitHub Code Scanning flagged two errors on `.github/workflows/ai_pr_review.yml`: - #11 actions/untrusted-checkout/high — "Checkout of untrusted code in trusted context" - #12 actions/untrusted-checkout-toctou/high — "Insufficient protection against execution of untrusted code on a privileged workflow (issue_comment)" CodeQL flags the structural pattern: the workflow runs `actions/checkout@v6` with the PR head's repo+sha in a context that has access to OPENAI_API_KEY and write permissions on PRs/issues. Today we don't execute the checked-out code (just read it via `git diff`), but the pattern is risky — a future edit adding "run tests" or "install deps" would turn it into an active RCE on fork PRs. The PR-author-vs-commenter mismatch (#12) is real: a maintainer commenting `/ai-review` on a fork PR passes the author_association check, but the code being checked out is the fork's, which the maintainer didn't author. Fix: skip fork PRs entirely. Two-layer gate (different mechanics required for the three event types): 1. Workflow `if:` for `pull_request` events: require `github.event.pull_request.head.repo.full_name == github.repository`. Fork PRs opened against this repo no longer start a workflow run. 2. For `issue_comment` and `pull_request_review_comment` events (event payloads don't include head-repo info — must API-fetch), the resolve-pr step now sets a new `is_fork` output. All 7 post-resolve steps are gated on `steps.pr.outputs.is_fork == 'false'`: - actions/checkout (closed PR path) - actions/checkout (open PR path) — the line CodeQL flags - Pre-fetch base SHA - Fetch previous AI review - Build review prompt - Run Codex - Post PR comment For comment-triggered events on fork PRs, the resolve-pr step prints a yellow `core.notice()` banner in the Actions UI explaining the skip; no checkout, no codex, no comment. Background: per a prior thread on the same workflow, fork PRs already fail today (secrets aren't passed to fork-PR `pull_request` events; comment triggers on fork PRs would crash because OPENAI_API_KEY is empty). So skipping cleanly just makes the failure honest — no real loss of behavior. Tests added (TestWorkflowForkSkip class, 3 contract tests): - test_workflow_pull_request_if_block_excludes_fork_prs - test_workflow_resolve_pr_step_sets_is_fork_output - test_workflow_post_resolve_steps_gated_on_is_fork (asserts ≥7 occurrences of `is_fork == 'false'` so a future workflow refactor adding a new PR-content-touching step must extend the gate) Out of scope: - Adding `pull_request_target` for fork PRs (would need much more hardening; not justified for our use case) - Posting a PR comment explaining the skip (Actions UI notice is enough) Verification: - `pytest tests/test_openai_review.py -q` → 214 passed (3 new). - `python3 -c "import yaml; yaml.safe_load(...)"` → no errors. - 7 step-level `is_fork == 'false'` gates confirmed. - CodeQL on this PR should NOT re-flag #11/#12 — early validation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ecd76b7 commit f761add

2 files changed

Lines changed: 105 additions & 4 deletions

File tree

.github/workflows/ai_pr_review.yml

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,18 @@ jobs:
2222
runs-on: ubuntu-latest
2323

2424
# Run if:
25-
# - PR opened, OR
25+
# - PR opened (same-repo only — fork PRs are skipped here at the workflow
26+
# level, since `head.repo.full_name` is available on `pull_request`
27+
# events). For comment-triggered events, the same-repo check happens at
28+
# the step level via `steps.pr.outputs.is_fork` (we have to API-fetch
29+
# the PR there because `issue_comment` event payloads don't include
30+
# head-repo info). Closes CodeQL alerts #11, #12 (untrusted checkout).
2631
# - Comment "/ai-review" on a PR by a collaborator/member/owner (issue or inline diff comment)
2732
if: |
28-
(github.event_name == 'pull_request') ||
33+
(
34+
github.event_name == 'pull_request' &&
35+
github.event.pull_request.head.repo.full_name == github.repository
36+
) ||
2937
(
3038
github.event_name == 'issue_comment' &&
3139
github.event.issue.pull_request != null &&
@@ -75,6 +83,27 @@ jobs:
7583
const headRepoFullName =
7684
pr.data.head.repo?.full_name || `${owner}/${repo}`;
7785
86+
// Fork detection for comment-triggered events. Workflow-level
87+
// `if:` already excludes fork PRs for `pull_request` events
88+
// (uses `github.event.pull_request.head.repo.full_name`), but
89+
// `issue_comment` / `pull_request_review_comment` payloads
90+
// don't include head-repo info — we have to API-fetch.
91+
// Subsequent steps gate on `steps.pr.outputs.is_fork == 'false'`.
92+
// Uses raw `pr.data.head.repo?.full_name` (NOT the fallback
93+
// `headRepoFullName`): a deleted fork should still skip the
94+
// workflow because the code IS untrusted from CodeQL's
95+
// perspective. `undefined !== "owner/repo"` -> is_fork = true.
96+
// Closes CodeQL alerts #11, #12 (untrusted checkout TOCTOU).
97+
const isFork =
98+
pr.data.head.repo?.full_name !== `${owner}/${repo}`;
99+
if (isFork) {
100+
core.notice(
101+
`AI review skipped: PR head is from fork ` +
102+
`${pr.data.head.repo?.full_name || "(deleted)"}. ` +
103+
`See CodeQL alerts #11, #12 for the security rationale.`
104+
);
105+
}
106+
78107
core.setOutput("number", prNumber);
79108
core.setOutput("title", pr.data.title || "");
80109
core.setOutput("body", pr.data.body || "");
@@ -84,14 +113,15 @@ jobs:
84113
core.setOutput("head_ref", pr.data.head.ref);
85114
core.setOutput("head_repo_full_name", headRepoFullName);
86115
core.setOutput("state", pr.data.state);
116+
core.setOutput("is_fork", isFork ? "true" : "false");
87117
88118
# Closed/merged PR (e.g. `/ai-review` rerun on a merged PR):
89119
# use the base-repo mirror of the PR head, which GitHub keeps
90120
# durably even after the fork is deleted or branches removed.
91121
# The previous workflow used `refs/pull/<N>/merge`, which is
92122
# garbage-collected on closed PRs — this path replaces that.
93123
- uses: actions/checkout@v6
94-
if: steps.pr.outputs.state != 'open'
124+
if: steps.pr.outputs.state != 'open' && steps.pr.outputs.is_fork == 'false'
95125
with:
96126
ref: refs/pull/${{ steps.pr.outputs.number }}/head
97127

@@ -102,12 +132,13 @@ jobs:
102132
# (see .claude/commands/submit-pr.md:327-345). head_sha is
103133
# guaranteed to exist on the head repo for an open PR.
104134
- uses: actions/checkout@v6
105-
if: steps.pr.outputs.state == 'open'
135+
if: steps.pr.outputs.state == 'open' && steps.pr.outputs.is_fork == 'false'
106136
with:
107137
repository: ${{ steps.pr.outputs.head_repo_full_name }}
108138
ref: ${{ steps.pr.outputs.head_sha }}
109139

110140
- name: Pre-fetch base SHA
141+
if: steps.pr.outputs.is_fork == 'false'
111142
run: |
112143
set -euo pipefail
113144
# base_sha lives on the base repo (github.repository), which differs
@@ -119,6 +150,7 @@ jobs:
119150
120151
- name: Fetch previous AI review (if any)
121152
id: prev_review
153+
if: steps.pr.outputs.is_fork == 'false'
122154
uses: actions/github-script@v9
123155
with:
124156
script: |
@@ -136,6 +168,7 @@ jobs:
136168
core.setOutput("found", last ? "true" : "false");
137169
138170
- name: Build review prompt with PR context + diff
171+
if: steps.pr.outputs.is_fork == 'false'
139172
env:
140173
PR_TITLE: ${{ steps.pr.outputs.title }}
141174
PR_BODY: ${{ steps.pr.outputs.body }}
@@ -415,6 +448,7 @@ jobs:
415448
416449
- name: Run Codex
417450
id: run_codex
451+
if: steps.pr.outputs.is_fork == 'false'
418452
uses: openai/codex-action@v1
419453
with:
420454
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
@@ -426,6 +460,7 @@ jobs:
426460
effort: xhigh
427461

428462
- name: Post PR comment (new on every event except initial open)
463+
if: steps.pr.outputs.is_fork == 'false'
429464
uses: actions/github-script@v9
430465
env:
431466
CODEX_FINAL_MESSAGE: ${{ steps.run_codex.outputs.final-message }}

tests/test_openai_review.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2557,6 +2557,72 @@ def test_notice_caps_output_at_10_files(
25572557
assert "and 15 more" in err
25582558

25592559

2560+
class TestWorkflowForkSkip:
2561+
"""The AI review workflow must skip PRs from forks to avoid the
2562+
untrusted-checkout pattern that CodeQL flagged as alerts #11 and #12.
2563+
Two-layer skip:
2564+
1. Workflow-level `if:` gates `pull_request` events on
2565+
`head.repo.full_name == github.repository`
2566+
2. The resolve-pr step sets `is_fork` output (via API fetch);
2567+
all 7 post-resolve steps gate on `is_fork == 'false'`.
2568+
2569+
These contract tests pin both layers — without them, a future workflow
2570+
refactor could drop the gate and re-introduce the CodeQL alerts."""
2571+
2572+
@pytest.fixture
2573+
def workflow_text(self):
2574+
assert _SCRIPT_PATH is not None
2575+
repo_root = _SCRIPT_PATH.parent.parent.parent
2576+
wf = repo_root / ".github" / "workflows" / "ai_pr_review.yml"
2577+
if not wf.exists():
2578+
pytest.skip("workflow not found")
2579+
return wf.read_text()
2580+
2581+
def test_workflow_pull_request_if_block_excludes_fork_prs(self, workflow_text):
2582+
"""Layer 1: the workflow `if:` block for `pull_request` events must
2583+
require head.repo.full_name == github.repository so fork PRs never
2584+
start a workflow run."""
2585+
assert (
2586+
"github.event.pull_request.head.repo.full_name == github.repository"
2587+
in workflow_text
2588+
), (
2589+
"workflow `if:` for pull_request events must check that the PR "
2590+
"head is from the same repo (not a fork) — required to clear "
2591+
"CodeQL alerts #11/#12 (untrusted checkout)."
2592+
)
2593+
2594+
def test_workflow_resolve_pr_step_sets_is_fork_output(self, workflow_text):
2595+
"""Layer 2: the resolve-pr github-script step must set the `is_fork`
2596+
output that subsequent steps gate on. Comment-triggered events
2597+
(`issue_comment`, `pull_request_review_comment`) can't be gated at
2598+
the workflow `if:` level (event payload doesn't include head-repo
2599+
info), so the gate happens at the step level via this output."""
2600+
assert 'core.setOutput("is_fork"' in workflow_text, (
2601+
"resolve-pr step must set `is_fork` output so post-resolve steps "
2602+
"can gate on `steps.pr.outputs.is_fork == 'false'`."
2603+
)
2604+
2605+
def test_workflow_post_resolve_steps_gated_on_is_fork(self, workflow_text):
2606+
"""All steps that run AFTER resolve-pr (and could touch untrusted
2607+
PR contents) must have `if: steps.pr.outputs.is_fork == 'false'`.
2608+
2609+
Count derivation: 7 gated steps total — 2 checkouts (closed PR
2610+
path, open PR path) + Pre-fetch base SHA + Fetch previous AI
2611+
review + Build review prompt + Run Codex + Post PR comment.
2612+
Adding a new step that handles PR contents must extend this
2613+
count.
2614+
2615+
Allow ≥7 to also count incidental references in code comments
2616+
(e.g. the JS doc comment in the resolve-pr step itself)."""
2617+
count = workflow_text.count("is_fork == 'false'")
2618+
assert count >= 7, (
2619+
f"Expected at least 7 occurrences of `is_fork == 'false'` (one "
2620+
f"per gated post-resolve step); found {count}. A new step that "
2621+
f"reads PR contents must also be gated to keep CodeQL alerts "
2622+
f"#11/#12 closed."
2623+
)
2624+
2625+
25602626
class TestExtractResponseText:
25612627
def test_prefers_output_text_field(self, review_mod):
25622628
result = {"output_text": "Direct text.", "output": []}

0 commit comments

Comments
 (0)