#!/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 hashlib 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 _affected_image_digests(row: dict[str, object], images: dict[str, object]) -> dict[str, str]: """Select current image bindings by scan tag, with a legacy full-inventory fallback.""" if not images: raise ValueError("--image-digests must contain a non-empty images map") if "affectedImages" not in row: services = list(images) else: affected_images = row.get("affectedImages") if ( not isinstance(affected_images, list) or not affected_images or any(not isinstance(tag, str) or not tag.strip() for tag in affected_images) or len(set(affected_images)) != len(affected_images) ): raise ValueError("scan affectedImages must be a non-empty list of unique image tags") services_by_tag: dict[str, list[str]] = {} for service, entry in images.items(): if not isinstance(entry, dict): continue tag = entry.get("tag") if isinstance(tag, str) and tag: services_by_tag.setdefault(tag, []).append(service) services = [] for tag in affected_images: matches = services_by_tag.get(tag, []) if len(matches) != 1: raise ValueError( f"scan image {tag} must resolve to exactly one current image service" ) services.append(matches[0]) bindings: dict[str, str] = {} for service in services: entry = images.get(service) if not isinstance(entry, dict): raise ValueError(f"image evidence for {service} must be an object") digest = entry.get("digest") or entry.get("imageId") if not isinstance(digest, str) or not digest: raise ValueError(f"image evidence for {service} has no immutable digest or imageId") bindings[service] = digest return bindings 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')}" image_inventory: dict[str, object] = {} if args.image_digests: digest_doc = load_json(args.image_digests) raw_images = digest_doc.get("images", {}) if isinstance(raw_images, dict): image_inventory = raw_images 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() versions_by_pair: dict[tuple[str, str], set[str]] = {} for row in scan.get("unresolvedCriticalOrHigh", []): for cve in row["cves"]: versions_by_pair.setdefault((row["package"], cve), set()).add( str(row.get("installedVersion")) ) exceptions = [] for row in scan.get("unresolvedCriticalOrHigh", []): package = row["package"] try: affected_digests = ( _affected_image_digests(row, image_inventory) if args.image_digests else {} ) except ValueError as exc: parser.error(str(exc)) for cve in row["cves"]: version_suffix = "" if len(versions_by_pair[(package, cve)]) > 1: version_suffix = ( "-" + hashlib.sha256(str(row.get("installedVersion")).encode()).hexdigest()[:12] ) exceptions.append( { "id": f"{package}-{cve}{version_suffix}", "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())