Files
image-tools/image_tools/verify.py
T
Christian Manivong d139d66c3d
CI / Tests, lint (push) Successful in 18s
feat: judge, sign and verify netOrk's images in one place (NetOrk/netork#1390)
The CIS gate, the SBOM, the cosign signature and the matcher's verdict lived
in NetOrk/base-os, the head of the chain. Every link below it -- base-python,
engine, ui, satellite, the service images -- needs the same, so the tools
move here, and every image repository pins them by commit.

- image_tools: cis.py, cis_os.sh, sbom.py and verify.py from base-os. The
  image's own repository holds its cis.toml and packages.allow; both default
  to the working directory.
- bin/sign.sh signs whatever platforms a reference holds: base-os's index of
  amd64 and arm64 (EXPECT_PLATFORMS) or a dev image's single manifest
  (image_tools/platforms.py).
- bin/verify-base.sh checks every FROM image of a Dockerfile against
  cosign.pub before a build; one without a digest is refused
  (image_tools/dockerfile.py).
- bin/install-cosign.sh installs cosign v3.1.3 for amd64 and arm64 runners,
  checked against the release checksums.
- actions/verify-base, judge, sign wrap them for the workflows.
- cosign.pub is the public half of the COSIGN_PRIVATE_KEY_B64 org secret,
  the key rotated on 2026-10-22.
2026-10-22 16:23:00 +02:00

222 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 <current-ref> <candidate-ref> [--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())