From 800bda12cbe45de308fd6479acd5ad68903c18ed Mon Sep 17 00:00:00 2001 From: Steffen Skui Date: Wed, 24 Jun 2026 18:37:15 +0200 Subject: [PATCH] cve: report only fixable CVEs, grouped by package with the version to bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --ignore-unfixed so unpatchable base-OS CVEs don't drown the actionable ones. Each finding now lists packages (installed -> fixed) and how many CVEs each bump clears, criticals first, plus a 'N of M images clean' line — a to-do list, not a wall of CVE IDs. --- sources/cve.py | 67 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/sources/cve.py b/sources/cve.py index 58a5b19..465527b 100644 --- a/sources/cve.py +++ b/sources/cve.py @@ -1,4 +1,9 @@ -"""Container-CVE source: Trivy-scan the images of running containers (HIGH/CRITICAL).""" +"""Container-CVE source: Trivy-scan running images, reporting only FIXABLE HIGH/CRITICAL CVEs. + +`--ignore-unfixed` is the whole point: a CVE with no available patch isn't a to-do item, +it's noise. We report only vulns that have a fix, grouped by package with the version to +bump to — so each finding is a short, actionable list instead of a wall of CVE IDs. +""" import json import subprocess @@ -6,8 +11,6 @@ from soc import finding from . import _docker -SEV = {"CRITICAL": "crit", "HIGH": "high", "MEDIUM": "med", "LOW": "low"} - def _running_images(): conts = _docker.get("/containers/json") or [] @@ -19,6 +22,19 @@ def _running_images(): return seen +def _scan(img): + """Fixable HIGH/CRITICAL vulns for one image (empty list on error).""" + try: + proc = subprocess.run( + ["trivy", "image", "--quiet", "--scanners", "vuln", "--ignore-unfixed", + "--severity", "HIGH,CRITICAL", "--format", "json", img], + capture_output=True, text=True, timeout=900) + rep = json.loads(proc.stdout or "{}") + except Exception: + return [] + return [v for res in (rep.get("Results") or []) for v in (res.get("Vulnerabilities") or [])] + + def run(): if not _docker.available(): return None # socket not mounted -> skip @@ -26,29 +42,40 @@ def run(): if not images: return None - findings = [] + findings, clean = [], 0 for img in images: - try: - proc = subprocess.run( - ["trivy", "image", "--quiet", "--scanners", "vuln", - "--severity", "HIGH,CRITICAL", "--format", "json", img], - capture_output=True, text=True, timeout=900) - rep = json.loads(proc.stdout or "{}") - except Exception: - continue - vulns = [v for res in (rep.get("Results") or []) for v in (res.get("Vulnerabilities") or [])] + vulns = _scan(img) if not vulns: + clean += 1 continue worst = "crit" if any(v.get("Severity") == "CRITICAL" for v in vulns) else "high" - ids = sorted({v.get("VulnerabilityID", "") for v in vulns if v.get("VulnerabilityID")}) + # Group by package: which to bump, installed -> fixed, and how many CVEs it clears. + pkgs = {} + for v in vulns: + p = v.get("PkgName", "?") + d = pkgs.setdefault(p, {"inst": v.get("InstalledVersion", "?"), + "fix": v.get("FixedVersion", "?"), "n": 0}) + d["n"] += 1 + top = sorted(pkgs.items(), key=lambda kv: -kv[1]["n"])[:6] + evidence = "; ".join(f"{p} {d['inst']}→{d['fix']} ({d['n']})" for p, d in top) + if len(pkgs) > 6: + evidence += f"; +{len(pkgs) - 6} more package(s)" findings.append(finding( - f"cve-{img}", worst, f"{len(vulns)} HIGH/CRITICAL CVEs in {img}", host=img, - evidence=", ".join(ids[:10]) + (" …" if len(ids) > 10 else ""), - impact=["known-exploitable vulnerabilities in a running image"], - fix=[f"pull/rebuild a patched {img.split(':')[0]} image"])) + f"cve-{img}", worst, + f"{len(vulns)} fixable HIGH/CRITICAL CVE(s) in {img}", host=img, + evidence=evidence, + impact=[f"{len(vulns)} CVEs with an available patch across {len(pkgs)} package(s)"], + fix=[f"bump: {', '.join(p for p, _ in top)}" + (" …" if len(pkgs) > 6 else ""), + f"or pull/rebuild a newer {img.split(':')[0]} base image"])) + findings.sort(key=lambda f: (f["sev"] != "crit", f["title"])) # criticals first + good = [] + if clean: + good.append(f"{clean} of {len(images)} image(s) have no fixable HIGH/CRITICAL CVEs") + if not findings and not good: + good.append("No fixable HIGH/CRITICAL CVEs in any running image") return { - "scope": f"{len(images)} running image(s)", + "scope": f"{len(images)} running image(s) — fixable HIGH/CRITICAL only", "findings": findings, - "good": [] if findings else ["No HIGH/CRITICAL CVEs in running images"], + "good": good, }