#!/usr/bin/env python3 """Enforce the CVE exception policy against a vulnerability scan. Every CRITICAL/HIGH finding in --scan-summary must be covered by a current, explicit, fully-populated, *approved* exception in --exceptions, or the gate fails. This never suppresses scanner output -- it cross-references it. Specifically fails on: - a CRITICAL/HIGH finding (package, CVE) with no matching exception at all; - a matching exception whose approvalStatus is not exactly "approved" (covers "pending", "rejected", or any other value -- there is no default grant); - a matching exception that has expired (expiryDate in the past) or is past its mandatory rescan date; - a matching exception without a valid image binding for each affected service, or whose binding differs from the current image inventory; - an exception missing any required field. See docs/09-release/CVE_EXCEPTIONS.md for the full policy and contracts/schemas/ (release-record.schema.json's sibling documents) for context. This script has no "--force"/override flag by design. """ from __future__ import annotations import argparse import re import sys from datetime import date from pathlib import Path from typing import Any _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 load_json # noqa: E402 _SHA256_IMAGE_ID = re.compile(r"^sha256:[0-9a-f]{64}$") REQUIRED_FIELDS = ( "id", "cve", "package", "installedVersion", "severity", "scanner", "scanDate", "affectedImageDigests", "reachabilityAnalysis", "compensatingControls", "riskOwner", "approver", "approvalStatus", "expiryDate", "mandatoryRescanDate", "remediationTrigger", "evidenceReference", ) def _findings(scan: dict[str, Any]) -> list[tuple[dict[str, Any], str]]: """Return (scan row, CVE) pairs for every CRITICAL/HIGH finding.""" findings = [] for row in scan.get("unresolvedCriticalOrHigh", []): for cve in row["cves"]: findings.append((row, cve)) return findings def _current_image_inventory( image_digests: Any, ) -> tuple[dict[str, dict[str, str]], list[str]]: """Validate immutable current image bindings while retaining optional tags.""" errors: list[str] = [] if not isinstance(image_digests, dict) or not image_digests: return {}, ["IMAGE INVENTORY INVALID: --image-digests must contain a non-empty images map"] inventory: dict[str, dict[str, str]] = {} for service, entry in image_digests.items(): if not isinstance(service, str) or not service.strip(): errors.append("IMAGE INVENTORY INVALID: every image service needs a non-empty name") continue if not isinstance(entry, dict): errors.append(f"IMAGE INVENTORY INVALID: {service} must be an image evidence object") continue image_id = entry.get("imageId") if not isinstance(image_id, str) or not _SHA256_IMAGE_ID.fullmatch(image_id): errors.append(f"CURRENT IMAGE ID INVALID: {service} needs a sha256 imageId") continue if "digest" in entry: digest = entry.get("digest") if not isinstance(digest, str) or not _SHA256_IMAGE_ID.fullmatch(digest): errors.append(f"CURRENT IMAGE DIGEST INVALID: {service} has a malformed digest") continue binding = digest else: # Older v1 inventory evidence records the immutable local image ID # instead of a registry manifest digest. binding = image_id tag = entry.get("tag") if tag is not None and (not isinstance(tag, str) or not tag.strip()): errors.append(f"IMAGE INVENTORY INVALID: {service} has a malformed tag") continue inventory[service] = {"binding": binding, "imageId": image_id} if isinstance(tag, str): inventory[service]["tag"] = tag if not inventory: errors.append("IMAGE INVENTORY INVALID: no valid current image bindings were supplied") return inventory, errors def _affected_services( row: dict[str, Any], inventory: dict[str, dict[str, str]] ) -> tuple[list[str], list[str]]: """Resolve scan image tags to services, or use the legacy full inventory.""" if "affectedImages" not in row: # Legacy v1 scan rows had no affectedImages field. Requiring the # exception to cover the whole supplied inventory is the safe fallback. return list(inventory), [] affected_images = row.get("affectedImages") if ( not isinstance(affected_images, list) or not affected_images or any(not isinstance(image, str) or not image.strip() for image in affected_images) or len(set(affected_images)) != len(affected_images) ): return [], ["SCAN AFFECTED IMAGES INVALID: expected a non-empty list of unique image tags"] services_by_tag: dict[str, list[str]] = {} for service, evidence in inventory.items(): tag = evidence.get("tag") if tag: services_by_tag.setdefault(tag, []).append(service) services: list[str] = [] errors: list[str] = [] for image in affected_images: matches = services_by_tag.get(image, []) if not matches: errors.append( f"AFFECTED IMAGE UNBOUND: scan image {image} has no current service binding" ) elif len(matches) > 1: errors.append(f"AFFECTED IMAGE AMBIGUOUS: scan image {image} maps to multiple services") else: services.append(matches[0]) return services, errors def check( scan: dict[str, Any], exceptions_doc: dict[str, Any], image_digests: dict[str, Any] | None, today: date, ) -> list[str]: errors: list[str] = [] image_inventory: dict[str, dict[str, str]] = {} if image_digests is not None: image_inventory, inventory_errors = _current_image_inventory(image_digests) errors.extend(inventory_errors) by_key: dict[tuple[str, str, str], dict[str, Any]] = {} raw_by_key: dict[tuple[str, str, str], dict[str, Any]] = {} conflicting_keys: set[tuple[str, str, str]] = set() for exception in exceptions_doc.get("exceptions", []): if not isinstance(exception, dict): errors.append("exception entry must be an object") continue package = exception.get("package") cve = exception.get("cve") version = exception.get("installedVersion") key: tuple[str, str, str] | None = None if isinstance(package, str) and isinstance(cve, str) and isinstance(version, str): if package and cve and version: key = (package, cve, version) if key is not None and key in raw_by_key and raw_by_key[key] != exception: conflicting_keys.add(key) errors.append( f"CONFLICTING DUPLICATE: multiple exception entries disagree for {package}/{cve}" ) elif key is not None: raw_by_key.setdefault(key, exception) missing = [field for field in REQUIRED_FIELDS if not exception.get(field)] # riskOwner/approver/compensatingControls may be legitimately empty # only while approvalStatus is "pending" -- once "approved" they are # mandatory. Re-check those three explicitly below rather than # excluding them from REQUIRED_FIELDS entirely, so a pending draft # from scripts/draft_cve_exceptions.py isn't flagged as malformed. missing = [f for f in missing if f not in {"riskOwner", "approver", "compensatingControls"}] if missing: errors.append( f"exception {exception.get('id', '')} is missing required fields: {missing}" ) continue if key is not None: by_key.setdefault(key, exception) for key in conflicting_keys: by_key.pop(key, None) by_pair: dict[tuple[str, str], list[dict[str, Any]]] = {} for (package, cve, _version), exception in by_key.items(): by_pair.setdefault((package, cve), []).append(exception) for row, cve in _findings(scan): package = row["package"] severity = row.get("severity", "UNKNOWN") reviewed = by_pair.get((package, cve), []) if "installedVersion" in row: scanned_version = row.get("installedVersion") if not isinstance(scanned_version, str) or not scanned_version: errors.append( f"SCAN VERSION MISSING: {package}/{cve} has no valid installedVersion" ) continue exception = by_key.get((package, cve, scanned_version)) if exception is None and reviewed: errors.append( f"VERSION MISMATCH: {package}/{cve} scan found {scanned_version}, " "but no exception reviewed that exact version" ) continue else: # Existing v1 evidence may omit the installed version. It remains # usable only when one unambiguous reviewed version exists. if len(reviewed) > 1: errors.append( f"AMBIGUOUS VERSION: {package}/{cve} legacy scan omits installedVersion " "and multiple versions were reviewed" ) continue exception = reviewed[0] if reviewed else None if exception is None: errors.append( f"UNAPPROVED: {severity} finding {package}/{cve} has no exception recorded at all" ) continue status = exception.get("approvalStatus") if status != "approved": errors.append( f"UNAPPROVED: {package}/{cve} exception status is {status!r}, " "not 'approved' -- no default grant" ) continue for role in ("riskOwner", "approver"): if not exception.get(role): errors.append( f"UNAPPROVED: {package}/{cve} is marked approved but has no {role} recorded" ) if not exception.get("compensatingControls"): errors.append( f"UNAPPROVED: {package}/{cve} is marked approved but records no " "compensating controls" ) expiry = date.fromisoformat(exception["expiryDate"]) if expiry < today: errors.append(f"EXPIRED: {package}/{cve} exception expired on {expiry.isoformat()}") rescan_due = date.fromisoformat(exception["mandatoryRescanDate"]) if rescan_due < today: errors.append( f"RESCAN OVERDUE: {package}/{cve} exception's mandatory rescan date " f"{rescan_due.isoformat()} has passed" ) if image_digests is not None: legacy_full_inventory = "affectedImages" not in row services, scope_errors = _affected_services(row, image_inventory) errors.extend(scope_errors) recorded = exception.get("affectedImageDigests", {}) if not isinstance(recorded, dict): errors.append( f"IMAGE BINDING INVALID: {package}/{cve} affectedImageDigests must be an object" ) continue if legacy_full_inventory and set(recorded) != set(image_inventory): errors.append( f"IMAGE INVENTORY SCOPE CHANGED: {package}/{cve} uses a legacy scan " "without affectedImages and its reviewed service set does not exactly " "match the current full inventory" ) for service in services: current_digest = image_inventory[service]["binding"] recorded_digest = recorded.get(service) if recorded_digest is None or recorded_digest == "": errors.append( f"IMAGE BINDING MISSING: {package}/{cve} has no valid reviewed digest " f"for {service}" ) elif not isinstance(recorded_digest, str) or not _SHA256_IMAGE_ID.fullmatch( recorded_digest ): errors.append( f"IMAGE BINDING INVALID: {package}/{cve} has a malformed reviewed digest " f"for {service}" ) elif recorded_digest != current_digest: errors.append( f"DIGEST CHANGED: {package}/{cve} exception recorded " f"{service}={recorded_digest}, but the current build is " f"{current_digest} -- the exception was reviewed against a " "build that no longer exists and must be re-approved" ) return errors def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--scan-summary", required=True, type=Path) parser.add_argument("--exceptions", required=True, type=Path) parser.add_argument( "--image-digests", type=Path, help="evidence//image-digests.json; when given, exceptions whose " "current image IDs or reviewed bindings are missing, malformed, or changed fail.", ) args = parser.parse_args() scan = load_json(args.scan_summary) exceptions_doc = load_json(args.exceptions) image_digests: dict[str, Any] | None = None if args.image_digests: digest_doc = load_json(args.image_digests) image_digests = digest_doc.get("images", {}) errors = check(scan, exceptions_doc, image_digests, date.today()) if errors: print("CVE exception gate: FAIL") for error in errors: print(f" - {error}") return 1 print( "CVE exception gate: PASS (every CRITICAL/HIGH finding has a current, approved exception)" ) return 0 if __name__ == "__main__": raise SystemExit(main())