350 lines
14 KiB
Python
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())
|