#!/usr/bin/env python3 """Draft CVE exception entries from a vulnerability scan summary. Produces one exception entry per (package, CVE) pair for every CRITICAL/HIGH finding in --scan-summary (the format scripts that produce evidence/*/vulnerability-scan-summary.json already write). Every drafted entry has ``approvalStatus: "pending"`` and empty ``riskOwner``/``approver`` fields -- this script only assembles the factual, machine-derivable parts (CVE id, package, severity, affected image digests, scan date). It deliberately cannot grant approval on anyone's behalf; a human risk owner must review each entry and set approvalStatus to "approved" (or "rejected") with their name before scripts/check_cve_exceptions.py will treat it as satisfying the release gate for that finding. """ from __future__ import annotations import argparse import json import sys from datetime import date, timedelta from pathlib import Path _ROOT_DIR = Path(__file__).resolve().parents[1] if str(_ROOT_DIR) not in sys.path: sys.path.insert(0, str(_ROOT_DIR)) from scripts.packlib import brussels_now, load_json # noqa: E402 def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--scan-summary", required=True, type=Path) parser.add_argument( "--image-digests", type=Path, help="evidence//image-digests.json to populate affectedImageDigests from.", ) parser.add_argument("--evidence-reference", required=True) parser.add_argument("--output", required=True, type=Path) parser.add_argument("--expiry-days", type=int, default=90) parser.add_argument("--rescan-days", type=int, default=30) args = parser.parse_args() scan = load_json(args.scan_summary) scan_date = scan.get("scannedAt", brussels_now())[:10] scanner = f"trivy {scan.get('trivyVersion', 'unknown')}" affected_digests: dict[str, str] = {} if args.image_digests: digest_doc = load_json(args.image_digests) for service, entry in digest_doc.get("images", {}).items(): digest = entry.get("digest") or entry.get("imageId") if digest: affected_digests[service] = digest today = date.fromisoformat(scan_date) if len(scan_date) == 10 else date.today() expiry = (today + timedelta(days=args.expiry_days)).isoformat() rescan = (today + timedelta(days=args.rescan_days)).isoformat() exceptions = [] for row in scan.get("unresolvedCriticalOrHigh", []): package = row["package"] for cve in row["cves"]: exceptions.append( { "id": f"{package}-{cve}", "cve": cve, "package": package, "installedVersion": row.get("installedVersion"), "severity": row.get("severity"), "scanner": scanner, "scanDate": scan_date, "affectedImageDigests": dict(affected_digests), "reachabilityAnalysis": row.get("reachability", ""), "compensatingControls": "", "riskOwner": "", "approver": "", "approvalStatus": "pending", "expiryDate": expiry, "mandatoryRescanDate": rescan, "remediationTrigger": ( f"an upstream fixed version becomes available for {package}" if row.get("fixedVersion") is None else f"upgrade to {row.get('fixedVersion')} or later" ), "evidenceReference": args.evidence_reference, } ) document = { "schemaVersion": "1.0", "generatedAt": brussels_now(), "exceptions": exceptions, } args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8", newline="") print(f"Drafted {len(exceptions)} exception(s) (all pending review) to {args.output}") return 0 if __name__ == "__main__": raise SystemExit(main())