diff --git a/.serena/memories/scripts/gh.md b/.serena/memories/scripts/gh.md index ed6adfa8f1..58f73e997d 100644 --- a/.serena/memories/scripts/gh.md +++ b/.serena/memories/scripts/gh.md @@ -9,6 +9,7 @@ repository via GraphQL and REST APIs through the authenticated `gh` CLI. - Finding issues with no milestone. - Fetching PR details by number or by milestone. - Comparing milestone issues against CHANGES.md to find missing entries. +- Listing or inspecting GitHub Security Advisories (GHSA). ## Prerequisites @@ -72,6 +73,30 @@ python3 scripts/gh.py prs --milestone "2.16.0" --state all **Output**: JSON array to stdout; progress to stderr. +### `advisories` + +List or inspect GitHub Security Advisories for the repository. + +```bash +# List all advisories (summary view) +python3 scripts/gh.py advisories + +# Filter by severity +python3 scripts/gh.py advisories --severity critical + +# Filter by state +python3 scripts/gh.py advisories --state triage + +# Get full detail for a single advisory +python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7 +``` + +**Summary output fields**: ghsa_id, cve_id, severity, cvss_score, state, summary, cwes, published_at, closed_at, url. + +**Detail output** (single advisory) adds: description, vulnerabilities (package, version ranges), credits, timestamps. + +**Output**: JSON to stdout; progress to stderr. + ## Key principles - All output is JSON — pipe into `jq` or other tools for further processing. diff --git a/scripts/gh.py b/scripts/gh.py index 7017389d52..c7e1f87aca 100755 --- a/scripts/gh.py +++ b/scripts/gh.py @@ -5,8 +5,9 @@ gh.py — Multi-purpose CLI helper for penpot/penpot GitHub operations. Uses GitHub GraphQL and REST APIs via the authenticated ``gh`` CLI. Subcommands: - issues List issues in a milestone (or unassigned with milestone=none) - prs Fetch details for one or more PRs (by number or milestone) + issues List issues in a milestone (or unassigned with milestone=none) + prs Fetch details for one or more PRs (by number or milestone) + advisories List or inspect GitHub security advisories Usage: python3 scripts/gh.py issues (default: state=closed) @@ -23,6 +24,9 @@ Usage: cat prs.txt | python3 scripts/gh.py prs --stdin python3 scripts/gh.py prs --milestone "2.16.0" (default: state=merged) python3 scripts/gh.py prs --milestone "2.16.0" --state all + python3 scripts/gh.py advisories (list all advisories) + python3 scripts/gh.py advisories --severity critical (filter by severity) + python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7 (single advisory detail) Prerequisites: - gh CLI authenticated (gh auth status) @@ -63,6 +67,16 @@ def run_gh_graphql(query: str, variables: dict) -> Any: return body["data"] +def run_gh_rest(path: str) -> Any: + """Run a REST API call via ``gh api``.""" + cmd = ["gh", "api", path] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"gh error: {result.stderr}", file=sys.stderr) + sys.exit(1) + return json.loads(result.stdout) + + # ───────────────────────────────────────────── # Shared: milestone lookup # ───────────────────────────────────────────── @@ -581,6 +595,93 @@ def cmd_prs(args: argparse.Namespace) -> None: print(json.dumps(all_results, indent=2)) +# ───────────────────────────────────────────── +# Subcommand: advisories +# ───────────────────────────────────────────── + + +def fetch_advisories() -> list[dict]: + """Fetch all security advisories for the repository via REST API.""" + return run_gh_rest(f"repos/{REPO}/security-advisories") + + +def fetch_advisory(ghsa_id: str) -> dict: + """Fetch a single security advisory by GHSA ID.""" + return run_gh_rest(f"repos/{REPO}/security-advisories/{ghsa_id}") + + +def format_advisory_summary(adv: dict) -> dict: + """Extract a summary view of an advisory.""" + return { + "ghsa_id": adv["ghsa_id"], + "cve_id": adv.get("cve_id"), + "severity": adv.get("severity"), + "cvss_score": (adv.get("cvss") or {}).get("score"), + "state": adv.get("state"), + "summary": adv.get("summary"), + "cwes": [c["cwe_id"] for c in adv.get("cwes", [])], + "published_at": adv.get("published_at"), + "closed_at": adv.get("closed_at"), + "url": adv.get("html_url"), + } + + +def format_advisory_detail(adv: dict) -> dict: + """Extract full detail view of an advisory.""" + summary = format_advisory_summary(adv) + summary["description"] = adv.get("description") + summary["vulnerabilities"] = [ + { + "package": v.get("package", {}).get("name"), + "vulnerable_version_range": v.get("vulnerable_version_range"), + "patched_versions": v.get("patched_versions"), + } + for v in adv.get("vulnerabilities", []) + ] + summary["credits"] = [ + {"login": c.get("user", {}).get("login"), "type": c.get("type")} + for c in adv.get("credits_detailed", []) + ] + summary["created_at"] = adv.get("created_at") + summary["updated_at"] = adv.get("updated_at") + summary["withdrawn_at"] = adv.get("withdrawn_at") + return summary + + +def cmd_advisories(args: argparse.Namespace) -> None: + """Handle the ``advisories`` subcommand.""" + + # ── Single advisory detail ────────────────────────────── + if args.ghsa_id: + ghsa_id = args.ghsa_id.upper() + if not ghsa_id.startswith("GHSA-"): + ghsa_id = f"GHSA-{ghsa_id}" + print(f"Fetching advisory {ghsa_id}...", file=sys.stderr) + adv = fetch_advisory(ghsa_id) + print(json.dumps(format_advisory_detail(adv), indent=2)) + return + + # ── List all advisories ───────────────────────────────── + print("Fetching security advisories...", file=sys.stderr) + advisories = fetch_advisories() + print(f"Fetched {len(advisories)} advisories", file=sys.stderr) + + results = [format_advisory_summary(adv) for adv in advisories] + + # Apply filters + if args.severity: + sev = args.severity.lower() + results = [r for r in results if (r.get("severity") or "").lower() == sev] + print(f"After severity filter ({sev}): {len(results)} advisories", file=sys.stderr) + + if args.state: + st = args.state.lower() + results = [r for r in results if (r.get("state") or "").lower() == st] + print(f"After state filter ({st}): {len(results)} advisories", file=sys.stderr) + + print(json.dumps(results, indent=2)) + + # ───────────────────────────────────────────── # CLI entrypoint # ───────────────────────────────────────────── @@ -645,6 +746,22 @@ def main() -> None: ) p_prs.set_defaults(func=cmd_prs) + # --- advisories --- + p_adv = sub.add_parser("advisories", help="List or inspect GitHub security advisories") + p_adv.add_argument( + "ghsa_id", nargs="?", + help="GHSA ID to fetch (e.g. 'GHSA-xvj6-fh9w-gjw7'); omit to list all" + ) + p_adv.add_argument( + "--severity", choices=["critical", "high", "medium", "low"], + help="Filter by severity level" + ) + p_adv.add_argument( + "--state", choices=["triage", "draft", "published", "closed", "withdrawn"], + help="Filter by advisory state" + ) + p_adv.set_defaults(func=cmd_advisories) + args = parser.parse_args() args.func(args)