"""Does a candidate image improve on the pinned one? (#1308) Trivy lists both images' packages; netOrk's matcher in kb-server judges both in one call, so against one state of its data (kb-server#155). The verdict: - **improved** when a critical or high CVE that has a fixed version is closed, Debian made a point release (``/etc/debian_version`` differs), or the candidate passes a CIS control the pin fails (``image_tools/cis.py``, both measured in the same run); - **same** otherwise, and whenever the matcher cannot be asked: no pin moves on a guess. New critical or high CVEs in the candidate never block; the report lists them. python -m image_tools.verify [--packages packages.allow] \ --json verdict.json --report report.md Standard library only; Trivy runs as a pinned container. """ from __future__ import annotations import argparse import json import os import subprocess import urllib.request from collections.abc import Callable, Iterable from dataclasses import asdict, dataclass, field from pathlib import Path #: Trivy, pinned by its multi-platform index digest. TRIVY_IMAGE = ( "aquasec/trivy:0.75.0@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa" ) SEVERE = frozenset({"critical", "high"}) KB_URL = os.environ.get("KB_URL", "https://kb.netork.io") TIMEOUT = 300 @dataclass class Verdict: improved: bool closed: list[dict] = field(default_factory=list) new: list[dict] = field(default_factory=list) point_release: tuple[str, str] | None = None cis_closed: list[str] = field(default_factory=list) counts: dict = field(default_factory=dict) error: str | None = None def to_json(self) -> str: return json.dumps(asdict(self), indent=2) def _patched(finding: dict) -> bool: return (finding.get("severity") or "").lower() in SEVERE and bool(finding.get("fixed")) def judge( answer: dict, current_debian: str, candidate_debian: str, cis_closed: Iterable[str] = (), ) -> Verdict: """The verdict on the matcher's *answer*, both images' Debian versions and the CIS controls the candidate passes and the pin fails.""" closed = [f for f in answer.get("closed") or [] if _patched(f)] new = [f for f in answer.get("new") or [] if (f.get("severity") or "").lower() in SEVERE] point = None if current_debian.strip() != candidate_debian.strip(): point = (current_debian.strip(), candidate_debian.strip()) counts = {"current": answer.get("current", {}), "candidate": answer.get("candidate", {})} cis = sorted(cis_closed) return Verdict(bool(closed) or point is not None or bool(cis), closed, new, point, cis, counts) def verify( current: str, candidate: str, *, sbom: Callable[[str], dict] | None = None, match: Callable[[dict, dict], dict] | None = None, debian_version: Callable[[str], str] | None = None, cis_failing: Callable[[str], set[str]] | None = None, ) -> Verdict: """Judge *candidate* against *current*; never raises.""" sbom = sbom or trivy_sbom match = match or kb_match debian_version = debian_version or debian_version_of cis_failing = cis_failing or cis_failing_of try: answer = match(sbom(current), sbom(candidate)) cis_closed = cis_failing(current) - cis_failing(candidate) return judge(answer, debian_version(current), debian_version(candidate), cis_closed) except (OSError, ValueError, subprocess.SubprocessError) as exc: return Verdict(False, error=f"{type(exc).__name__}: {exc}") def _row(f: dict) -> str: fixed = f.get("fixed") or "–" return f"| {f.get('cve')} | {f.get('package')} | {f.get('severity')} | {f.get('installed')} | {fixed} |" def render(verdict: Verdict, current: str, candidate: str) -> str: """The pull request body: what closed, what is new, against what.""" head = "**Improved**" if verdict.improved else "**Same** (the pin stays)" lines = [f"{head}: `{candidate}` against the pinned `{current}`.", ""] if verdict.error: lines += [f"The matcher could not be asked: {verdict.error}", ""] if verdict.point_release: old, new = verdict.point_release lines += [f"Debian point release: {old} → {new}", ""] if verdict.cis_closed: lines += [ f"CIS controls the pin fails and the candidate passes: {', '.join(verdict.cis_closed)}", "", ] table = ["| CVE | Package | Severity | Installed | Fixed in |", "|---|---|---|---|---|"] if verdict.closed: lines += ["### Closed critical/high", "", *table, *map(_row, verdict.closed), ""] if verdict.new: lines += ["### New critical/high in the candidate", "", *table, *map(_row, verdict.new), ""] if verdict.counts: lines += [ f"Counts: current {verdict.counts.get('current')}, candidate {verdict.counts.get('candidate')}." ] return "\n".join(lines).rstrip() + "\n" def trivy_sbom(ref: str) -> dict: # pragma: no cover - container seam """Trivy's package inventory of *ref* (no vulnerability DB).""" proc = subprocess.run( [ "docker", "run", "--rm", "-v", "/var/run/docker.sock:/var/run/docker.sock", "-e", "TRIVY_USERNAME", "-e", "TRIVY_PASSWORD", TRIVY_IMAGE, "image", "--quiet", "--disable-telemetry", "--skip-version-check", "--format", "json", "--list-all-pkgs", "--scanners", "license", ref, ], capture_output=True, text=True, timeout=TIMEOUT, check=True, ) return json.loads(proc.stdout) def kb_match(current: dict, candidate: dict) -> dict: # pragma: no cover - network seam """kb-server's verdict on both inventories, in one call (kb-server#155).""" body = json.dumps({"current": current, "candidate": candidate}).encode() request = urllib.request.Request( f"{KB_URL}/api/v1/match", data=body, method="POST", headers={ "Content-Type": "application/json", "Authorization": f"Bearer {os.environ['NETORK_LICENSE_KEY']}", }, ) with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # noqa: S310 - https return json.load(response) def debian_version_of(ref: str) -> str: # pragma: no cover - container seam proc = subprocess.run( ["docker", "run", "--rm", "--entrypoint", "cat", ref, "/etc/debian_version"], capture_output=True, text=True, timeout=120, check=True, ) return proc.stdout.strip() def cis_failing_of(ref: str, packages: str = "packages.allow") -> set[str]: # pragma: no cover """The CIS controls *ref* fails, measured as image_tools/cis.py measures a build; *packages* is the image repository's allow list.""" from image_tools import cis allowed = cis.load_packages(Path(packages).read_text()) return {c.id for c in cis.measure(ref, allowed) if c.result == cis.FAIL} def main() -> int: # pragma: no cover - CLI seam parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("current") parser.add_argument("candidate") parser.add_argument("--json", required=True) parser.add_argument("--report", required=True) parser.add_argument("--packages", default="packages.allow") args = parser.parse_args() verdict = verify( args.current, args.candidate, cis_failing=lambda ref: cis_failing_of(ref, args.packages), ) with open(args.json, "w") as fh: fh.write(verdict.to_json()) with open(args.report, "w") as fh: fh.write(render(verdict, args.current, args.candidate)) print("improved" if verdict.improved else "same") return 0 if __name__ == "__main__": # pragma: no cover raise SystemExit(main())