Files
cacti/.github/scripts/security_review.py
e24e78d77e ci: consolidate security and performance workflows (#7200)
* ci: add AI security review workflow for PRs

Adds a GitHub Actions workflow that runs on every PR targeting develop.
The job collects the PR diff, grep hits on changed PHP files (SQL sinks,
superglobal access, command execution, dynamic includes, file writes,
archive extraction), and 120-line excerpts from historically vulnerable
hotspot files. It feeds all of this to claude-opus-4-6 using a
structured security review prompt and posts the model's findings as a
PR comment.

The job fails (blocking merge) when the model returns "BLOCK PR". API
or infrastructure errors are non-fatal so they do not block unrelated
PRs.

Requires the ANTHROPIC_API_KEY repository secret. Fork PRs skip
gracefully when the secret is absent.

Closes #6936-related CI gap — continuous variant-class hunting on every
PR without manual triage overhead.

Signed-off-by: Thomas Vincent <thomasvincent@somethingwithproof.com>
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* ci: remove AI branding from security review workflow

Signed-off-by: Thomas Vincent <thomasvincent@somethingwithproof.com>
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* ci: add security posture and perf regression workflows

Add PHP-oriented parity guardrails similar to Spine modernization:
- new security posture workflow (TruffleHog, Semgrep SARIF, Scorecard,
  Composer audit, workflow policy checks)
- new performance regression workflow with hyperfine + RSS thresholds
- workflow policy checker script for pinned-action/strict-shell validation
- versioned CLI performance baseline file

Fixes #6822

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* ci: address copilot feedback on security and perf workflows

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* ci: fix scorecard install, sarif upload pin, and perf baseline bootstrap

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* ci: avoid github context interpolation in security review run steps

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* ci: gate secret-dependent steps and fix composer audit, sarif, artifact pins

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* ci: generate composer lock without --lock for audit

The --lock flag needs an existing composer.lock; Cacti ships none, so the audit step exited 3.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* ci: report composer audit advisories as non-blocking

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* ci: use a valid model id for the security review and cover security-review.yml in policy lint

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* ci: address review feedback on security and perf workflows

- grant issues:write so the security review can post PR comments
- harden i18n run step by passing github context via env vars
- fail the security gate when the API returns no usable text
- fetch origin/<base> before reading the trusted perf baseline

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* ci: harden review gate, workflow policy, and perf job

- security_review.py: gate on the model's parsed final-decision marker
  instead of a "BLOCK PR" substring match, which misfired on prose
  ("I would not BLOCK PR") and on the attacker-controlled diff echoing the
  instruction text back into the review body
- check-workflow-policy.py: also validate job-level reusable-workflow `uses:`
  and require docker:// references to be pinned by an @sha256 digest
- perf-regression.yml: skip the benchmark job on fork PRs so an untrusted
  fork cannot run baseline-defined commands during the bootstrap window

Co-Authored-By: Oz <oz-agent@warp.dev>

* ci: scope security review write permissions to the job

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

---------

Signed-off-by: Thomas Vincent <thomasvincent@somethingwithproof.com>
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Thomas Vincent <thomas@vincent.dev>
Co-authored-by: Oz <oz-agent@warp.dev>
Co-authored-by: TheWitness <thewitness@cacti.net>
2026-07-13 14:34:49 -04:00

236 lines
8.1 KiB
Python

#!/usr/bin/env python3
# Copyright (C) 2004-2026 The Cacti Group
# GPL-2.0-or-later
"""
Security review runner.
Reads the PR diff, grep hits, and hotspot excerpts from disk, calls the
review API with the security review prompt, then posts the result as a
PR issue comment via the GitHub REST API.
Exits 1 when the review recommends blocking the PR so the workflow step
fails and gates the merge. Exits 0 for all other outcomes (including API
errors) to avoid blocking PRs on infrastructure failures.
"""
import json
import os
import sys
import textwrap
import urllib.error
import urllib.request
# ---------------------------------------------------------------------------
# Configuration from environment
# ---------------------------------------------------------------------------
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
GITHUB_REPO = os.environ.get("GITHUB_REPOSITORY", "") # "owner/repo"
PR_NUMBER = os.environ.get("GITHUB_PR_NUMBER", "")
PROMPT_FILE = os.environ.get("SECURITY_PROMPT_FILE", ".github/prompts/security-review.md")
DIFF_FILE = os.environ.get("PR_DIFF_FILE", "/tmp/pr.diff")
GREP_FILE = os.environ.get("GREP_HITS_FILE", "/tmp/grep_hits.txt")
HOTSPOT_FILE = os.environ.get("HOTSPOT_FILE", "/tmp/hotspot_contents.txt")
MODEL = "claude-opus-4-8"
MAX_TOKENS = 4096
MAX_DIFF = 40_000
MAX_GREP = 20_000
MAX_HOTSPOT = 20_000
API_TIMEOUT = 300 # seconds
GITHUB_TIMEOUT = 30
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class EmptyReviewError(RuntimeError):
"""API responded but carried no usable review text; gate must fail closed."""
def read_truncated(path: str, max_chars: int) -> str:
"""Return up to max_chars of a file, appending a truncation notice."""
try:
with open(path, encoding="utf-8", errors="replace") as fh:
content = fh.read(max_chars)
if os.path.getsize(path) > max_chars:
content += "\n[... truncated ...]"
return content
except FileNotFoundError:
return "(not available)"
def call_claude(system_prompt: str, user_message: str) -> str:
"""Call the Anthropic Messages API and return the assistant text."""
if not ANTHROPIC_API_KEY:
raise RuntimeError("ANTHROPIC_API_KEY is not set")
payload = json.dumps({
"model": MODEL,
"max_tokens": MAX_TOKENS,
"system": system_prompt,
"messages": [{"role": "user", "content": user_message}],
}).encode()
req = urllib.request.Request(
"https://api.anthropic.com/v1/messages",
data=payload,
headers={
"x-api-key": ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
)
with urllib.request.urlopen(req, timeout=API_TIMEOUT) as resp:
body = json.loads(resp.read())
# The Messages API returns a list of content blocks; only some are text
# (e.g. tool_use blocks have no "text"). Concatenate every text block.
blocks = body.get("content", [])
text = "".join(
block.get("text", "")
for block in blocks
if isinstance(block, dict) and block.get("type") == "text"
)
if not text.strip():
raise EmptyReviewError(f"no text content in API response (stop_reason={body.get('stop_reason')})")
return text
def post_pr_comment(body: str) -> None:
"""Post a comment on the PR via the GitHub REST API."""
if not GITHUB_TOKEN or not GITHUB_REPO or not PR_NUMBER:
raise RuntimeError("Missing GITHUB_TOKEN / GITHUB_REPOSITORY / GITHUB_PR_NUMBER")
url = f"https://api.github.com/repos/{GITHUB_REPO}/issues/{PR_NUMBER}/comments"
payload = json.dumps({"body": body}).encode()
req = urllib.request.Request(
url,
data=payload,
headers={
"Authorization": f"Bearer {GITHUB_TOKEN}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req, timeout=GITHUB_TIMEOUT) as resp:
status = resp.status
if status not in (200, 201):
raise RuntimeError(f"GitHub API returned HTTP {status}")
# ---------------------------------------------------------------------------
# Decision parsing
# ---------------------------------------------------------------------------
# The review prompt requires the model to END with exactly one of these markers.
DECISION_BLOCK = "BLOCK PR"
DECISION_REVIEW = "REVIEW REQUIRED"
DECISION_PASS = "NO BLOCKING SECURITY ISSUES FOUND"
DECISIONS = (DECISION_BLOCK, DECISION_REVIEW, DECISION_PASS)
def final_decision(review: str) -> "str | None":
"""Return the review's final decision marker, or None when absent.
We gate on the decision marker rather than substring-matching "BLOCK PR":
a substring match misfires on prose ("I would not BLOCK PR") and, worse, on
the attacker-controlled diff echoing the instruction text back into the
review body. Scan from the bottom and return the first line that, once
stripped of markdown decoration, equals one of the markers.
"""
for raw in reversed(review.splitlines()):
line = raw.strip().strip("`*_# ").strip().rstrip(".:").strip().upper()
if line in DECISIONS:
return line
return None
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
# Load system prompt
try:
with open(PROMPT_FILE, encoding="utf-8") as fh:
system_prompt = fh.read()
except FileNotFoundError:
print(f"::error::Prompt file not found: {PROMPT_FILE}", file=sys.stderr)
sys.exit(0) # infrastructure failure; do not block PR
diff = read_truncated(DIFF_FILE, MAX_DIFF)
grep_out = read_truncated(GREP_FILE, MAX_GREP)
hotspots = read_truncated(HOTSPOT_FILE, MAX_HOTSPOT)
user_message = textwrap.dedent(f"""\
## PR Diff
```diff
{diff}
```
## grep / static-analysis hits on changed files
```
{grep_out}
```
## Hotspot file excerpts (prior-advisory targets)
```
{hotspots}
```
Perform your review now. Follow the output structure in your instructions exactly.
""")
try:
review = call_claude(system_prompt, user_message)
except urllib.error.HTTPError as exc:
body = exc.read().decode(errors="replace")
print(f"::warning::Anthropic API error {exc.code}: {body[:500]}", file=sys.stderr)
sys.exit(0)
except EmptyReviewError as exc:
# A malformed/empty response means the gate did not actually run; fail
# closed rather than letting the PR pass without a review.
print(f"::error::Security review failed: {exc}", file=sys.stderr)
sys.exit(1)
except Exception as exc: # noqa: BLE001
print(f"::warning::Security review skipped: {exc}", file=sys.stderr)
sys.exit(0)
comment_body = (
"## Security Review\n\n"
+ review
+ "\n\n---\n*[security-review.yml](.github/workflows/security-review.yml)*"
)
try:
post_pr_comment(comment_body)
print("Security review comment posted.")
except Exception as exc: # noqa: BLE001
print(f"::warning::Failed to post review comment: {exc}", file=sys.stderr)
# Print to stdout so the review is visible in the workflow log regardless
print("\n--- REVIEW OUTPUT ---\n")
print(review)
# Gate the merge on the model's *final decision*, not a substring match.
decision = final_decision(review)
if decision is None:
print("::warning::Security review produced no recognizable final decision; not gating.", file=sys.stderr)
elif decision == DECISION_BLOCK:
print("::error::Security review recommends blocking this PR.", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()