Files

350 lines
14 KiB
Python

#!/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', '<no 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/<packet>/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())