Files
soc-collector/sources/cve.py
T
Steffen Skui 800bda12cb
Build and push soc-collector / build (push) Successful in 6s
cve: report only fixable CVEs, grouped by package with the version to bump
--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.
2026-06-24 18:37:15 +02:00

82 lines
3.0 KiB
Python

"""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
from soc import finding
from . import _docker
def _running_images():
conts = _docker.get("/containers/json") or []
seen = []
for c in conts:
img = c.get("Image", "")
if img and img not in seen:
seen.append(img)
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
images = _running_images()
if not images:
return None
findings, clean = [], 0
for img in images:
vulns = _scan(img)
if not vulns:
clean += 1
continue
worst = "crit" if any(v.get("Severity") == "CRITICAL" for v in vulns) else "high"
# 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)} 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) — fixable HIGH/CRITICAL only",
"findings": findings,
"good": good,
}