Skip to content

Commit 462c07d

Browse files
committed
ci_scripts: audit_report: add
Create a new script for reviewing reports created by ci_scripts/audit.sh, and opening/commenting on issues in GitHub to track vulnerabilities identified by nightly pip-audit runs. The issues are opened on a per-vulnerability basis with affected versions, but only if an existing issue matching the same vulnerability and with the "pip-audit" label isn't found. Previously-closed issues are ignored when determining if a new one should be opened. Also use GHA warning notations so that the nightly CI pipeline can report a pass (instead of a failure) while still reporting a vulnerability in the issue list. AI-Generated: Uses Claude Code Opus 4.7 Signed-off-by: Trevor Gamblin <tgamblin@baylibre.com>
1 parent 22195cf commit 462c07d

1 file changed

Lines changed: 219 additions & 0 deletions

File tree

ci_scripts/audit_report.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
#!/usr/bin/env python3
2+
# SPDX-FileCopyrightText: 2026 The RISE Project
3+
# SPDX-License-Identifier: MIT
4+
"""
5+
Parse pip-audit JSON report, emit warning annotations, append a job summary,
6+
and open/update GitHub issues (one per (package, vulnerability id)).
7+
8+
Expects environment:
9+
GH_TOKEN authenticated gh token
10+
GITHUB_REPOSITORY owner/repo (auto-set on GitHub Actions)
11+
GITHUB_SERVER_URL e.g. https://github.com (auto-set)
12+
GITHUB_RUN_ID current workflow run id (auto-set)
13+
GITHUB_STEP_SUMMARY path to write step summary (auto-set)
14+
15+
Usage: audit_report.py <path-to-audit-report.json>
16+
"""
17+
18+
import json
19+
import os
20+
import subprocess
21+
import sys
22+
from pathlib import Path
23+
from typing import Dict, List, Optional
24+
25+
ISSUE_LABELS = ["pip-audit", "security"]
26+
27+
28+
def run_url() -> str:
29+
server = os.environ.get("GITHUB_SERVER_URL", "https://github.com")
30+
repo = os.environ.get("GITHUB_REPOSITORY", "")
31+
run_id = os.environ.get("GITHUB_RUN_ID", "")
32+
if not repo or not run_id:
33+
return "<run URL unavailable outside GitHub Actions>"
34+
return f"{server}/{repo}/actions/runs/{run_id}"
35+
36+
37+
def append_summary(text: str) -> None:
38+
path = os.environ.get("GITHUB_STEP_SUMMARY")
39+
if not path:
40+
return
41+
with open(path, "a") as f:
42+
f.write(text)
43+
44+
45+
def gh(*args: str, check: bool = True) -> subprocess.CompletedProcess:
46+
return subprocess.run(
47+
["gh", *args], capture_output=True, text=True, check=check
48+
)
49+
50+
51+
def find_existing_issue(title: str) -> Optional[int]:
52+
"""Return open issue number matching exact title with the pip-audit label, else None."""
53+
try:
54+
result = gh(
55+
"issue", "list",
56+
"--label", "pip-audit",
57+
"--state", "open",
58+
"--search", f'"{title}" in:title',
59+
"--json", "number,title",
60+
"--limit", "50",
61+
)
62+
except subprocess.CalledProcessError as e:
63+
print(f" [!] gh issue list failed: {e.stderr or e}", file=sys.stderr)
64+
return None
65+
66+
try:
67+
issues = json.loads(result.stdout)
68+
except json.JSONDecodeError:
69+
return None
70+
71+
for issue in issues:
72+
if issue.get("title") == title:
73+
return issue.get("number")
74+
return None
75+
76+
77+
def create_issue(title: str, body: str) -> Optional[int]:
78+
args = ["issue", "create", "--title", title, "--body", body]
79+
for label in ISSUE_LABELS:
80+
args += ["--label", label]
81+
try:
82+
result = gh(*args)
83+
except subprocess.CalledProcessError as e:
84+
print(f" [!] gh issue create failed: {e.stderr or e}", file=sys.stderr)
85+
return None
86+
87+
for line in result.stdout.splitlines():
88+
line = line.strip()
89+
if line.startswith("http") and "/issues/" in line:
90+
try:
91+
return int(line.rsplit("/", 1)[-1])
92+
except ValueError:
93+
pass
94+
return None
95+
96+
97+
def comment_issue(number: int, body: str) -> bool:
98+
try:
99+
gh("issue", "comment", str(number), "--body", body)
100+
return True
101+
except subprocess.CalledProcessError as e:
102+
print(f" [!] gh issue comment failed: {e.stderr or e}", file=sys.stderr)
103+
return False
104+
105+
106+
def format_body(pkg: str, version: str, vuln: Dict, url: str) -> str:
107+
fix_versions = vuln.get("fix_versions") or []
108+
aliases = vuln.get("aliases") or []
109+
description = (vuln.get("description") or "").strip()
110+
111+
lines = [
112+
f"`pip-audit` detected a vulnerability in **{pkg}** {version}.",
113+
"",
114+
f"- Vulnerability ID: `{vuln.get('id', 'unknown')}`",
115+
]
116+
if aliases:
117+
lines.append(f"- Aliases: {', '.join(f'`{a}`' for a in aliases)}")
118+
if fix_versions:
119+
lines.append(f"- Fix versions: {', '.join(f'`{v}`' for v in fix_versions)}")
120+
else:
121+
lines.append("- Fix versions: _none reported_")
122+
lines += [
123+
f"- First observed: {url}",
124+
"",
125+
"### Description",
126+
"",
127+
description or "_(no description provided)_",
128+
]
129+
return "\n".join(lines)
130+
131+
132+
def format_comment(pkg: str, version: str, url: str) -> str:
133+
return (
134+
f"Still detected in {url}\n"
135+
f"- Package: `{pkg}` {version}\n"
136+
)
137+
138+
139+
def process_report(report_path: Path) -> int:
140+
if not report_path.exists():
141+
print(f"[!] Report not found at {report_path}; nothing to process.")
142+
return 0
143+
144+
try:
145+
data = json.loads(report_path.read_text())
146+
except json.JSONDecodeError as e:
147+
print(f"[!] Failed to parse {report_path}: {e}")
148+
return 0
149+
150+
deps = data.get("dependencies", [])
151+
url = run_url()
152+
vuln_count = 0
153+
summary_rows = []
154+
155+
for dep in deps:
156+
pkg = dep.get("name")
157+
version = dep.get("version", "")
158+
vulns = dep.get("vulns") or []
159+
if not pkg or not vulns:
160+
continue
161+
162+
for vuln in vulns:
163+
vuln_id = vuln.get("id", "unknown")
164+
title = f"pip-audit: {pkg}{vuln_id}"
165+
fix_versions = vuln.get("fix_versions") or []
166+
fixes = ", ".join(fix_versions) if fix_versions else "none"
167+
168+
# GHA warning annotation (yellow bubble on job)
169+
print(
170+
f"::warning file=ci_scripts/packages.txt::"
171+
f"{pkg} {version}: {vuln_id} (fix: {fixes})"
172+
)
173+
174+
summary_rows.append(
175+
f"| `{pkg}` | {version} | `{vuln_id}` | {fixes} |"
176+
)
177+
178+
existing = find_existing_issue(title)
179+
if existing is not None:
180+
if comment_issue(existing, format_comment(pkg, version, url)):
181+
print(f" [+] Updated existing issue #{existing} for {pkg} {vuln_id}")
182+
else:
183+
print(f" [!] Failed to comment on issue #{existing} for {pkg} {vuln_id}")
184+
else:
185+
number = create_issue(title, format_body(pkg, version, vuln, url))
186+
if number is not None:
187+
print(f" [+] Created issue #{number} for {pkg} {vuln_id}")
188+
else:
189+
print(f" [!] Failed to create issue for {pkg} {vuln_id}")
190+
191+
vuln_count += 1
192+
193+
if summary_rows:
194+
summary = (
195+
"## pip-audit report\n\n"
196+
f"Detected **{vuln_count}** vulnerability(ies). "
197+
f"Run: {url}\n\n"
198+
"| Package | Version | Vulnerability | Fix versions |\n"
199+
"|---------|---------|---------------|--------------|\n"
200+
+ "\n".join(summary_rows)
201+
+ "\n"
202+
)
203+
else:
204+
summary = f"## pip-audit report\n\nNo vulnerabilities detected. Run: {url}\n"
205+
206+
append_summary(summary)
207+
print(f"[+] Processed {vuln_count} vulnerability(ies).")
208+
return 0
209+
210+
211+
def main() -> int:
212+
if len(sys.argv) != 2:
213+
print("Usage: audit_report.py <path-to-audit-report.json>", file=sys.stderr)
214+
return 2
215+
return process_report(Path(sys.argv[1]))
216+
217+
218+
if __name__ == "__main__":
219+
sys.exit(main())

0 commit comments

Comments
 (0)