Files
soc-collector/sources/cve.py
T
Steffen Skui 2ff4e703c4
Build and push soc-collector / build (push) Successful in 5s
cve: exclude the collector's own container from the scan (+ SOC_EXCLUDE_IMAGES)
2026-06-25 10:31:40 +02:00

167 lines
6.3 KiB
Python

"""Container-CVE source: Trivy-scan running images for FIXABLE HIGH/CRITICAL CVEs,
and — critically — label each with an UPDATE STATUS so the report distinguishes
what's yours to fix from what's the maintainer's:
[yours] git.skui.io/* image -> bump deps in its repo + rebuild (CI). Actionable.
[update] third-party, a NEWER image is published -> pull + recreate. Actionable.
[upstream] third-party, already on the latest published image -> clears only when
the maintainer rebuilds. NOT actionable here; re-pull periodically.
"Fixable" (Trivy --ignore-unfixed) only means a patched package version EXISTS — not
that you can apply it. For an image you don't build and already run :latest of, the
"bump X / rebuild base" advice is the maintainer's, not yours. The status makes that
explicit, and actionable findings sort above the can't-do-anything ones.
"""
import json
import os
import re
import socket
import subprocess
from soc import finding
from . import _docker
OWN_PREFIX = "git.skui.io/"
STATUS_RANK = {"yours": 0, "update": 1, "upstream": 2}
def _own_container_id():
"""The collector's own container id, so we don't scan ourselves.
/proc carries the full id regardless of `--hostname`; fall back to the
hostname (== the short container id by Docker default). Empty string when
not running in a container (then nothing is excluded)."""
try:
with open("/proc/self/mountinfo") as fh: # …/containers/<id>/{hostname,hosts}
m = re.search(r"/containers/([0-9a-f]{64})/", fh.read())
if m:
return m.group(1)
except OSError:
pass
try:
with open("/proc/self/cgroup") as fh: # cgroup v1: …/docker/<id>
m = re.search(r"docker[-/]([0-9a-f]{64})", fh.read())
if m:
return m.group(1)
except OSError:
pass
try:
return socket.gethostname().strip()
except Exception:
return ""
def _running_images():
conts = _docker.get("/containers/json") or []
self_id = _own_container_id()
# optional extra exclusions: SOC_EXCLUDE_IMAGES=substr1,substr2
extra = [s.strip() for s in os.environ.get("SOC_EXCLUDE_IMAGES", "").split(",") if s.strip()]
seen = []
for c in conts:
cid = c.get("Id", "")
if self_id and cid.startswith(self_id):
continue # don't scan the collector itself
img = c.get("Image", "")
if not img or img in seen or any(x in img for x in extra):
continue
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 _registry_digest(img):
"""The registry's current digest for img's tag, via the daemon (no pull). '' on error."""
try:
d = _docker.get(f"/distribution/{img}/json") or {}
return (d.get("Descriptor") or {}).get("digest", "")
except Exception:
return ""
def _local_digests(img):
try:
info = _docker.get(f"/images/{img}/json") or {}
return [r.split("@")[-1] for r in (info.get("RepoDigests") or [])]
except Exception:
return []
def _classify(img, pkgs):
"""Return (status, fix_lines) — is this image actionable, and how?"""
bump = ", ".join(pkgs[:6]) + ("" if len(pkgs) > 6 else "")
if img.startswith(OWN_PREFIX):
return "yours", [f"YOURS — bump {bump} in this repo, then rebuild (CI)"]
reg = _registry_digest(img)
if reg and reg in _local_digests(img):
return "upstream", [
"UPSTREAM — you already run the latest published image; this clears only when "
f"the maintainer rebuilds (with {bump}). Re-pull periodically; nothing to do here."]
if reg:
return "update", ["UPDATE AVAILABLE — a newer image is published; pull + recreate to get it"]
return "upstream", [
f"UPSTREAM — third-party image; the fix ({bump}) comes from the maintainer's rebuild"]
def run():
if not _docker.available():
return None
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"
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)"
status, fix = _classify(img, [p for p, _ in sorted(pkgs.items(), key=lambda kv: -kv[1]["n"])])
f = finding(
f"cve-{img}", worst,
f"[{status}] {len(vulns)} fixable HIGH/CRITICAL CVE(s) in {img}", host=img,
evidence=evidence,
impact=[f"{len(vulns)} CVEs with a published patch across {len(pkgs)} package(s)"],
fix=fix)
f["_status"] = status
findings.append(f)
# actionable first (yours, then update), then upstream; criticals first within each
sev = {"crit": 0, "high": 1, "med": 2, "low": 3}
findings.sort(key=lambda f: (STATUS_RANK.get(f.pop("_status", "upstream"), 2), sev.get(f["sev"], 9)))
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, tagged yours/upstream/update",
"findings": findings,
"good": good,
}