mirror of
https://github.com/Cacti/cacti.git
synced 2026-11-03 10:37:46 +09:00
* 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>
236 lines
8.1 KiB
Python
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()
|