Skip to content

Commit e59953a

Browse files
Pigbibiclaude
andcommitted
feat: add static guard — scan for secrets/blocked files before App gate
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 699ba13 commit e59953a

1 file changed

Lines changed: 187 additions & 6 deletions

File tree

scripts/gate_codex_app_review.py

Lines changed: 187 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
#!/usr/bin/env python3
2-
"""Translate the Codex GitHub App's PR review into a check run for branch protection.
3-
4-
Two modes:
5-
WAIT — on PR opened/synchronize: create pending check, poll for Codex review,
6-
fall back to pass after timeout (Codex might be broken → don't block).
7-
REACT — on Codex bot review submitted: update check immediately.
2+
"""PR merge gate: static checks + Codex App review → check run for branch protection.
3+
4+
Two-phase guard (no API keys needed):
5+
1. STATIC — scan diff for secrets, blocked files, metadata issues (<30s).
6+
Fail immediately on hard violations.
7+
2. WAIT — poll for Codex GitHub App review up to N min.
8+
Fail on CHANGES_REQUESTED, pass on APPROVED/timeout.
9+
3. REACT — on Codex bot review submitted: update check instantly.
810
"""
911

1012
from __future__ import annotations
1113

1214
import json
1315
import os
16+
import re
1417
import sys
1518
import time
1619
import urllib.error
@@ -22,6 +25,7 @@
2225
BOT_LOGIN = "chatgpt-codex-connector[bot]"
2326
CHECK_NAME = "Codex Review Gate"
2427
DETAIL_URL = "https://github.com/apps/chatgpt-codex-connector"
28+
POLICY_PATH = Path(".github/codex_auto_merge_policy.json")
2529

2630
# ── helpers ──────────────────────────────────────────────────────────────────
2731

@@ -164,6 +168,160 @@ def review_decision(review: dict[str, Any] | None) -> tuple[str, str, str]:
164168
)
165169

166170

171+
# ── static guard ─────────────────────────────────────────────────────────────
172+
173+
174+
def load_policy() -> dict[str, Any]:
175+
if POLICY_PATH.exists():
176+
try:
177+
return json.loads(POLICY_PATH.read_text(encoding="utf-8"))
178+
except (OSError, json.JSONDecodeError):
179+
pass
180+
return {
181+
"version": 1,
182+
"blocked_path_patterns": [
183+
r"(^|/)(\.env|.*secret.*|.*credential.*|.*token.*|.*private.*|.*\.pem|.*\.key)$",
184+
],
185+
"max_changed_files": 50,
186+
"max_changed_lines": 5000,
187+
}
188+
189+
190+
def compile_blocked_patterns(policy: dict[str, Any]) -> list[re.Pattern[str]]:
191+
raw = policy.get("blocked_path_patterns", [])
192+
patterns: list[re.Pattern[str]] = []
193+
for p in raw:
194+
if isinstance(p, str) and p.strip():
195+
try:
196+
patterns.append(re.compile(p, re.IGNORECASE))
197+
except re.error:
198+
pass
199+
return patterns
200+
201+
202+
_SENSITIVE_ASSIGN = re.compile(
203+
r'(?:api[_\s]?key|secret|password|token|credential|private[_\s]?key)\s*[:=]\s*["\']'
204+
r'(?!\$\{\{|{{|example|placeholder|test|your[-_\s]|xxx|TODO|CHANGEME|replace)[^"\']{12,}["\']',
205+
re.IGNORECASE,
206+
)
207+
208+
209+
def scan_diff_for_secrets(diff_text: str, path_patterns: list[re.Pattern[str]]) -> list[str]:
210+
"""Scan PR diff for blocked files and hardcoded secret assignments."""
211+
violations: list[str] = []
212+
current_file = ""
213+
214+
for line in diff_text.splitlines():
215+
if line.startswith("diff --git "):
216+
parts = line.split(" ")
217+
current_file = parts[3][2:] if len(parts) >= 4 and parts[3].startswith("b/") else ""
218+
# Check path-level blocks once per file header
219+
if current_file:
220+
for pat in path_patterns:
221+
if pat.search(current_file):
222+
violations.append(
223+
f"**Blocked file**: `{current_file}` matches `{pat.pattern}`"
224+
)
225+
break
226+
continue
227+
228+
if line.startswith("+++ b/"):
229+
current_file = line[6:]
230+
continue
231+
232+
# Only inspect added lines
233+
if not line.startswith("+") or line.startswith("+++"):
234+
continue
235+
236+
content = line[1:]
237+
m = _SENSITIVE_ASSIGN.search(content)
238+
if m:
239+
violations.append(
240+
f"**Hardcoded secret** in `{current_file}`: `{m.group(0)[:100]}`"
241+
)
242+
243+
return list(dict.fromkeys(violations)) # dedup preserving order
244+
245+
246+
def check_pr_metadata(files: list[dict[str, Any]], policy: dict[str, Any]) -> list[str]:
247+
issues: list[str] = []
248+
max_files = policy.get("max_changed_files", 50)
249+
max_lines = policy.get("max_changed_lines", 5000)
250+
ta = sum(f.get("additions", 0) or 0 for f in files)
251+
td = sum(f.get("deletions", 0) or 0 for f in files)
252+
253+
for f in files:
254+
fn = f.get("filename", "?")
255+
st = (f.get("status") or "").lower().strip()
256+
if st == "removed":
257+
issues.append(f"**File deleted**: `{fn}` — verify intentional")
258+
elif st == "renamed":
259+
issues.append(f"**File renamed**: `{f.get('previous_filename', '?')}` → `{fn}`")
260+
261+
if len(files) > max_files:
262+
issues.append(f"**Too many files**: {len(files)} changed (limit {max_files})")
263+
if ta + td > max_lines:
264+
issues.append(f"**Too many lines**: {ta + td} changed (limit {max_lines})")
265+
266+
return issues
267+
268+
269+
def run_static_guard(
270+
token: str, repo: str, pr_number: int, head_sha: str
271+
) -> tuple[list[str], str, str] | None:
272+
"""Run static checks. Returns (issues, title, summary) or None if clean."""
273+
policy = load_policy()
274+
275+
# Fetch PR files
276+
all_files: list[dict[str, Any]] = []
277+
page = 1
278+
while True:
279+
try:
280+
batch = github_request(
281+
token, "GET", f"/repos/{repo}/pulls/{pr_number}/files?per_page=100&page={page}"
282+
)
283+
except RuntimeError:
284+
break
285+
if not isinstance(batch, list) or not batch:
286+
break
287+
all_files.extend(batch)
288+
if len(batch) < 100:
289+
break
290+
page += 1
291+
292+
# Fetch diff
293+
diff_text = ""
294+
try:
295+
req = urllib.request.Request(
296+
f"{API_BASE}/repos/{repo}/pulls/{pr_number}",
297+
method="GET",
298+
headers={
299+
"Authorization": f"Bearer {token}",
300+
"Accept": "application/vnd.github.v3.diff",
301+
"X-GitHub-Api-Version": "2022-11-28",
302+
"User-Agent": "codex-review-gate",
303+
},
304+
)
305+
with urllib.request.urlopen(req, timeout=30) as resp:
306+
diff_text = resp.read().decode("utf-8", errors="replace")
307+
except Exception:
308+
pass
309+
310+
# Scan
311+
issues: list[str] = []
312+
issues.extend(check_pr_metadata(all_files, policy))
313+
patterns = compile_blocked_patterns(policy)
314+
issues.extend(scan_diff_for_secrets(diff_text, patterns))
315+
316+
if not issues:
317+
return None
318+
319+
title = f"Merge blocked: {len(issues)} static issue(s)"
320+
summary = "\n".join(f"- {i}" for i in issues)
321+
summary += "\n\n---\nPush a fix commit to clear this block."
322+
return issues, title, summary
323+
324+
167325
# ── main logic ───────────────────────────────────────────────────────────────
168326

169327

@@ -197,6 +355,29 @@ def main() -> int:
197355

198356
print(f"PR #{pr_number} sha={head_sha[:12]} event={event_name}")
199357

358+
# ═══════════════════════════════════════════════════════════════════
359+
# PHASE 1: Static guard — runs on every PR event, <30s, no API keys
360+
# ═══════════════════════════════════════════════════════════════════
361+
if event_name != "pull_request_review": # skip on review-only events
362+
try:
363+
static_result = run_static_guard(token, repo, pr_number, head_sha)
364+
except RuntimeError as exc:
365+
print(f"::warning::Static guard failed: {exc}")
366+
static_result = None
367+
368+
if static_result is not None:
369+
issues, title, summary = static_result
370+
upsert_check_run(token, repo, head_sha, status="completed",
371+
conclusion="failure", title=title, summary=summary)
372+
print(f"STATIC → FAIL: {len(issues)} issue(s)")
373+
return 1
374+
else:
375+
print("STATIC → clean")
376+
377+
# ═══════════════════════════════════════════════════════════════════
378+
# PHASE 2: App review gate
379+
# ═══════════════════════════════════════════════════════════════════
380+
200381
# ── REACT mode: Codex just submitted a review ──────────────────────
201382
review_event = event.get("review") or {}
202383
review_user = (review_event.get("user") or {}).get("login", "")

0 commit comments

Comments
 (0)