Skip to content

Commit cfa63de

Browse files
authored
Merge pull request #427 from igerber/fix-codeql-untrusted-checkout
Skip fork PRs in AI review workflow (closes CodeQL #11, #12)
2 parents 6425094 + 93863e5 commit cfa63de

2 files changed

Lines changed: 147 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: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2557,6 +2557,114 @@ 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+
"""Every step in the `review` job that runs AFTER the `resolve-pr`
2607+
step must include `steps.pr.outputs.is_fork == 'false'` in its
2608+
`if:` clause.
2609+
2610+
Per CodeQL alerts #11/#12, no step that could touch untrusted PR
2611+
contents (or run while OPENAI_API_KEY is in scope) may execute
2612+
on a fork PR. The resolve-pr step itself only API-fetches PR
2613+
metadata via GITHUB_TOKEN — safe to run before the gate is
2614+
computed. Every step after must be gated.
2615+
2616+
The earlier (PR #427 R0) version of this test counted the string
2617+
`is_fork == 'false'` globally with `>= 7`, which had two false-
2618+
negative modes:
2619+
(a) a real gate could be removed — string count drops 8→7,
2620+
still passes
2621+
(b) a new ungated post-resolve step could be added — gate
2622+
count stays at 7, total step count grows, passes
2623+
2624+
This rewrite (R1, addressing the reviewer's P3) anchors on:
2625+
- `^ if:` at 8-space indent (the step-property indent
2626+
level for the review job's nested `if:` keys), excluding
2627+
the JS doc comment inside the resolve-pr step's `script: |`
2628+
block which would not match this anchor
2629+
- `^ - (name|uses):` at 6-space indent (step-list-item
2630+
indent), counting every step in the job
2631+
2632+
Then asserts `gated_steps == total_steps - 1` (resolve-pr is the
2633+
only legitimately ungated step). Catches both failure modes
2634+
above."""
2635+
import re
2636+
2637+
# `if:` lines at step-property indent (8 spaces) containing the
2638+
# gate. Allows combined conditions like
2639+
# `if: steps.pr.outputs.state == 'open' && steps.pr.outputs.is_fork == 'false'`.
2640+
gate_re = re.compile(
2641+
r"^ if:.*is_fork == 'false'", re.MULTILINE
2642+
)
2643+
gates = gate_re.findall(workflow_text)
2644+
2645+
# All step starts in the review job (` - name:` or
2646+
# ` - uses:` at 6-space indent).
2647+
step_start_re = re.compile(
2648+
r"^ - (?:name|uses):", re.MULTILINE
2649+
)
2650+
steps = step_start_re.findall(workflow_text)
2651+
2652+
# The resolve-pr step is the only ungated step (it sets the
2653+
# output that all subsequent steps gate on).
2654+
expected_gates = len(steps) - 1
2655+
assert len(gates) == expected_gates, (
2656+
f"Fork-skip gate invariant violated: found {len(gates)} "
2657+
f"gated step(s) but {len(steps)} total step(s) in the "
2658+
f"`review` job — expected exactly {expected_gates} gates "
2659+
f"(every step except resolve-pr must include "
2660+
f"`is_fork == 'false'` in its `if:`). Either a gate was "
2661+
f"removed or a new post-resolve step was added without one. "
2662+
f"Per CodeQL alerts #11/#12, every post-resolve step must "
2663+
f"be gated to prevent untrusted-checkout execution on fork "
2664+
f"PRs."
2665+
)
2666+
2667+
25602668
class TestExtractResponseText:
25612669
def test_prefers_output_text_field(self, review_mod):
25622670
result = {"output_text": "Direct text.", "output": []}

0 commit comments

Comments
 (0)