167 lines
6.6 KiB
Python
167 lines
6.6 KiB
Python
#!/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/<packet>/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())
|