Sixty-three criticals on running images have no fix to take. Writing them into a trivy ignore file would have been the obvious move and the wrong one: trivy drops ignored findings from its output, so afterwards 'zero because fixed' and 'zero because we looked away' render identically. Every finding stays in trivy_vuln_info. The decisions sit beside them in entscheidungen.json and are counted, not subtracted. Each entry names its CVEs one by one. A blanket entry per image would also swallow the next finding that shows up there, which is the finding you would most want to see. The loader rejects an entry without ids, and rejects a review date it cannot parse — rejecting the whole file, because a half-read decision list is worse than none. An unreadable file leaves everything counted as open. Getting that direction backwards would mean a typo reads as 'all decided', and nobody would notice. The end-to-end run caught the same mistake in the other half: when the derived target set is empty the set is unknown, not empty, so the open count now falls back to every report rather than to zero. Three python services move off the debian base while we are here — 3.13-slim carried four criticals with no fix, 3.13-alpine none. All three run on the stdlib alone and TLS was checked inside the image before the switch.
298 lines
14 KiB
Python
298 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
# Trivy-JSON -> Prometheus-Metriken (gitops#47). Stdlib-only, Machart wie die
|
|
# uebrigen Monitoring-Helfer. Liest die Reports des cve-scan-Sidecars und
|
|
# serviert /metrics; merkt sich je (CVE, Target) den Erstfund (first_seen),
|
|
# damit der geforderte Zeitstrahl (erstmals gesehen / geschlossen) abbildbar
|
|
# ist - "geschlossen" = Serie verschwindet, resolved kommt via Alertmanager.
|
|
#
|
|
# Schema (Pflichtfelder-Vorgabe sorb: CVE-ID, Mitigation, Zeitstrahl, Ort, Typ):
|
|
# trivy_vuln_info{cve,severity,target,target_type,host,pkg,installed,fixed_version} 1
|
|
# (nur HIGH/CRITICAL als Einzelserien - Kardinalitaet)
|
|
# trivy_vuln_count{target,target_type,host,severity} <n> (alle Severities)
|
|
# trivy_vuln_first_seen_timestamp{cve,target} <unix>
|
|
# trivy_last_scan_timestamp{target,target_type,host} <unix>
|
|
import json
|
|
import os
|
|
import threading
|
|
import time
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
import targets as ziel_herleitung
|
|
|
|
RESULTS = os.environ.get("RESULTS_DIR", "/results")
|
|
STATE_FILE = os.environ.get("STATE_FILE", "/state/first-seen.json")
|
|
# Soll-Menge (#0106): hierhin schreibt der Exporter, von hier liest scan-loop.sh.
|
|
TARGETS_FILE = os.environ.get("TARGETS_FILE", "/targets/targets.txt")
|
|
# ⚠️ Die Herleitung laeuft NICHT je Scrape. Bei 15 s Scrape-Intervall waeren das
|
|
# rund 21 000 Registry-Anfragen am Tag; eine Runde kostet ~90.
|
|
SOLL_INTERVALL = float(os.environ.get("SOLL_INTERVALL_SEKUNDEN", "3600"))
|
|
PROZESSSTART = time.time()
|
|
# Ort: Container-Images laufen (bis auf Weiteres) alle auf dem MATRIX-Host;
|
|
# Host-rootfs-Scans (Ausbaustufe) bringen ihren Hostnamen im Dateinamen mit.
|
|
DEFAULT_HOST = os.environ.get("DEFAULT_HOST", "matrix")
|
|
# Entschiedene Befunde (#0051): CRITICAL, zu dem es nichts mehr zu tun gibt oder
|
|
# zu dem die Arbeit noch aussteht - mit Begruendung und Pruefdatum.
|
|
ENTSCHEIDUNGEN_FILE = os.environ.get("ENTSCHEIDUNGEN_FILE", "/app/entscheidungen.json")
|
|
|
|
try:
|
|
with open(STATE_FILE) as f:
|
|
first_seen = json.load(f) # "cve|target" -> unix-ts
|
|
except Exception:
|
|
first_seen = {}
|
|
|
|
|
|
def esc(v):
|
|
return str(v).replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ")
|
|
|
|
|
|
def lade_entscheidungen(pfad=None):
|
|
"""Entscheidungen -> {(ziel, cve): (art, ablauf_unix)} plus Fehlertext.
|
|
|
|
⚠️ Bewusst KEINE Unterdrueckung. Trivy koennte die Befunde ueber eine
|
|
Ignore-Datei einfach weglassen - dann saehe "0 CRITICAL, weil behoben"
|
|
genauso aus wie "0 CRITICAL, weil weggeschaut". Hier bleibt jeder Befund in
|
|
trivy_vuln_info stehen; entschieden wird er nur zusaetzlich gezaehlt.
|
|
|
|
⚠️ Ein Lesefehler gibt eine LEERE Menge zurueck und meldet ihn. Damit zaehlt
|
|
im Zweifel alles als offen - die Richtung, die auffaellt. Umgekehrt saehe
|
|
eine kaputte Datei aus wie "alles entschieden".
|
|
"""
|
|
pfad = pfad or ENTSCHEIDUNGEN_FILE
|
|
getroffen = {}
|
|
try:
|
|
with open(pfad) as fh:
|
|
doc = json.load(fh)
|
|
except FileNotFoundError:
|
|
return getroffen, "keine Datei"
|
|
except Exception as e:
|
|
return getroffen, f"unlesbar: {e}"
|
|
for e in doc.get("eintraege") or []:
|
|
ziel = ziel_herleitung.normalisiere(e.get("ziel", ""))
|
|
art = e.get("art", "?")
|
|
datum = e.get("pruefen_am", "")
|
|
try:
|
|
ablauf = time.mktime(time.strptime(datum, "%Y-%m-%d"))
|
|
except Exception:
|
|
return {}, f"Pruefdatum unbrauchbar bei {e.get('ziel')}: {datum!r}"
|
|
if not ziel or not e.get("cves"):
|
|
return {}, f"Eintrag ohne Ziel oder ohne CVE-Kennung: {e.get('ziel')!r}"
|
|
for cve in e["cves"]:
|
|
getroffen[(ziel, cve)] = (art, ablauf)
|
|
return getroffen, None
|
|
|
|
|
|
class ZielCache:
|
|
"""Haelt die zuletzt gebildete Soll-Menge und erneuert sie im eigenen Takt.
|
|
|
|
⚠️ Der Stand je Quelle ueberlebt einen Ausfall absichtlich: Faellt eine
|
|
Quelle aus, bleibt ihr alter Zeitstempel stehen und ALTERT, statt zu
|
|
verschwinden. Eine verschwundene Serie kann keine Alarmregel ausloesen
|
|
(AAR 2026-08-01, Befund 3) - genau daran waere die Absicherung sonst
|
|
gescheitert.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._sperre = threading.Lock()
|
|
self._stand = {}
|
|
self._letzte = None
|
|
self._geholt_um = 0.0
|
|
|
|
def hole(self):
|
|
with self._sperre:
|
|
if self._letzte is not None and time.time() - self._geholt_um < SOLL_INTERVALL:
|
|
return self._letzte
|
|
cfg = ziel_herleitung.cfg_aus_umgebung()
|
|
h = ziel_herleitung.herleiten(cfg, vorheriger_stand=self._stand)
|
|
self._stand = dict(h.stand)
|
|
self._letzte, self._geholt_um = h, time.time()
|
|
for quelle, meldung in h.fehler.items():
|
|
print(f"soll-menge: quelle {quelle} ausgefallen: {meldung}", flush=True)
|
|
if h.brauchbar:
|
|
schreibe_targets(h.ziele)
|
|
else:
|
|
# NICHTS ueberschreiben. Sonst raeumt ein Neustart waehrend eines
|
|
# Prometheus-Ausfalls den gesamten Bestand ab.
|
|
print("soll-menge: alle Quellen aus - targets.txt bleibt unberuehrt", flush=True)
|
|
return h
|
|
|
|
|
|
def schreibe_targets(ziele):
|
|
try:
|
|
os.makedirs(os.path.dirname(TARGETS_FILE), exist_ok=True)
|
|
tmp = TARGETS_FILE + ".tmp"
|
|
with open(tmp, "w") as f:
|
|
f.write("# erzeugt von cve-exporter (#0106) - NICHT von Hand pflegen\n")
|
|
for z in sorted(ziele):
|
|
f.write(z + "\n")
|
|
os.replace(tmp, TARGETS_FILE) # atomar, der Scanner liest nebenlaeufig
|
|
except Exception as e:
|
|
print(f"targets.txt nicht schreibbar: {e}", flush=True)
|
|
|
|
|
|
ziel_cache = ZielCache()
|
|
|
|
|
|
def collect():
|
|
lines = []
|
|
now = int(time.time())
|
|
seen_keys = set()
|
|
gelesene_targets = set() # nur DEREN Erstfunde duerfen geprunt werden
|
|
berichte = 0
|
|
lesefehler = 0
|
|
kritisch = set() # (normalisiertes Ziel, CVE) - Grundlage fuer "offen"
|
|
for fn in sorted(os.listdir(RESULTS)):
|
|
if not fn.endswith(".json"):
|
|
continue
|
|
berichte += 1
|
|
path = os.path.join(RESULTS, fn)
|
|
try:
|
|
rep = json.load(open(path))
|
|
except Exception as e:
|
|
# Frueher: stilles 'continue'. Ein einmaliger Lesefehler (Datei wird
|
|
# gerade geschrieben, kurzer I/O-Fehler) liess die Findings dieses
|
|
# Targets aus seen_keys verschwinden - und der Prune unten loeschte
|
|
# ihre Erstfund-Zeitstempel DAUERHAFT. Der Zeitstrahl war damit weg,
|
|
# ohne dass irgendetwas gemeldet haette. Jetzt zaehlbar und sichtbar.
|
|
lesefehler += 1
|
|
print(f"report unlesbar: {fn}: {e}", flush=True)
|
|
continue
|
|
target = rep.get("ArtifactName", fn[:-5])
|
|
gelesene_targets.add(target)
|
|
ttype = "host" if rep.get("ArtifactType") in ("filesystem", "rootfs") else "image"
|
|
host = fn.split("__host__")[1].split(".json")[0] if "__host__" in fn else DEFAULT_HOST
|
|
base = f'target="{esc(target)}",target_type="{ttype}",host="{esc(host)}"'
|
|
lines.append(f'trivy_last_scan_timestamp{{{base}}} {int(os.path.getmtime(path))}')
|
|
counts = {}
|
|
for res in rep.get("Results") or []:
|
|
for v in res.get("Vulnerabilities") or []:
|
|
sev = v.get("Severity", "UNKNOWN")
|
|
counts[sev] = counts.get(sev, 0) + 1
|
|
if sev in ("HIGH", "CRITICAL"):
|
|
cve = v.get("VulnerabilityID", "?")
|
|
if sev == "CRITICAL":
|
|
kritisch.add((ziel_herleitung.normalisiere(target), cve))
|
|
key = f"{cve}|{target}"
|
|
if key not in first_seen:
|
|
first_seen[key] = now
|
|
seen_keys.add(key)
|
|
lines.append(
|
|
'trivy_vuln_info{cve="%s",severity="%s",%s,pkg="%s",installed="%s",fixed_version="%s"} 1'
|
|
% (esc(cve), sev, base, esc(v.get("PkgName", "?")),
|
|
esc(v.get("InstalledVersion", "?")), esc(v.get("FixedVersion", ""))))
|
|
lines.append(f'trivy_vuln_first_seen_timestamp{{cve="{esc(cve)}",target="{esc(target)}"}} {first_seen[key]}')
|
|
for sev, n in sorted(counts.items()):
|
|
lines.append(f'trivy_vuln_count{{{base},severity="{sev}"}} {n}')
|
|
# State kompakt halten: verschwundene Findings raus (= "geschlossen").
|
|
# ABER nur fuer Targets, deren Bericht in DIESEM Durchgang auch wirklich
|
|
# gelesen wurde. Sonst loescht ein einzelner Lesefehler den Zeitstrahl eines
|
|
# Targets, das es noch gibt - unwiederbringlich, weil "erstmals gesehen"
|
|
# danach auf 'jetzt' neu anfaengt.
|
|
for k in list(first_seen):
|
|
ziel = k.split("|", 1)[1] if "|" in k else ""
|
|
if ziel in gelesene_targets and k not in seen_keys:
|
|
del first_seen[k]
|
|
|
|
# Sichtbarkeit des Scanners selbst: ein still gestorbener Scanner macht blind,
|
|
# und TrivyScanStale kann ein Target, das NIE einen Bericht hatte, nicht melden
|
|
# (ohne Serie kein time()-Vergleich). Diese beiden Zahlen schliessen die Luecke
|
|
# so weit, wie sie ohne Soll-Liste zu schliessen ist.
|
|
lines.append(f"trivy_reports_total {berichte}")
|
|
lines.append(f"trivy_report_read_errors {lesefehler}")
|
|
|
|
# --- Deckung gegen die Soll-Menge (#0106) --------------------------------
|
|
# Bis hierher konnte der Exporter nur sagen, WAS er gescannt hat. Was er nie
|
|
# angesehen hat, war unsichtbar - am 2026-08-21 waren das 24 von 51 Images.
|
|
# Erst der Vergleich gegen eine Soll-Menge macht die Luecke zur Zahl.
|
|
h = ziel_cache.hole()
|
|
ist = {ziel_herleitung.normalisiere(t) for t in gelesene_targets}
|
|
ist.discard(None)
|
|
soll = h.ziele
|
|
|
|
for quelle in ziel_herleitung.QUELLEN:
|
|
for z in sorted(h.je_quelle.get(quelle, ())):
|
|
lines.append(f'cve_target_desired{{target="{esc(z)}",quelle="{quelle}"}} 1')
|
|
# ⚠️ IMMER ausgeben, auch wenn die Quelle noch nie geliefert hat: Eine
|
|
# fehlende Serie kann keine Regel ausloesen, und eine ausgefallene
|
|
# Herleitung sieht sonst aus wie vollstaendige Deckung. Ohne je einen
|
|
# Erfolg zaehlt die Zeit seit Prozessstart - der Wert waechst also und
|
|
# schlaegt irgendwann an, statt still zu fehlen.
|
|
seit = h.stand.get(quelle, PROZESSSTART)
|
|
lines.append(f'cve_target_source_stale{{quelle="{quelle}"}} {int(now - seit)}')
|
|
|
|
fehlend, verwaist = soll - ist, ist - soll
|
|
lines.append(f"cve_targets_missing {len(fehlend)}")
|
|
lines.append(f"cve_targets_orphaned {len(verwaist)}")
|
|
lines.append(f"cve_targets_desired_total {len(soll)}")
|
|
# Bei leerer Soll-Menge waere 0/0 rechnerisch 1,0 - also "alles gedeckt",
|
|
# ausgerechnet im Ausfall. Deshalb 0 statt einer Division.
|
|
lines.append(
|
|
f"cve_target_coverage_ratio {len(soll & ist) / len(soll) if soll else 0}"
|
|
)
|
|
|
|
# --- Entschieden oder offen (#0051, Kriterien 1 und 5) -------------------
|
|
# Kriterium 1 lautet "kein CRITICAL in einem laufenden Image OHNE
|
|
# Entscheidung". Das ist erst eine Zahl, wenn beide Seiten zaehlbar sind:
|
|
# die Befunde (oben) und die Entscheidungen (hier).
|
|
getroffen, fehler = lade_entscheidungen()
|
|
if fehler:
|
|
print(f"entscheidungen: {fehler}", flush=True)
|
|
# ⚠️ Nur Befunde auf Zielen, die WIRKLICH betrieben werden. Ein verwaister
|
|
# Bericht (Image abgeloest, Runde noch nicht gelaufen) darf die Zahl nicht
|
|
# hochhalten - sonst sieht ein erfolgreiches Update wie Stillstand aus.
|
|
#
|
|
# ⚠️ ABER: Ist die Soll-Menge leer, ist sie nicht "leer", sondern UNBEKANNT -
|
|
# die Herleitung ist ausgefallen. Dann darf hier nicht 0 herauskommen, denn
|
|
# 0 liest sich wie "nichts offen". In dem Fall zaehlen alle Berichte, auch
|
|
# verwaiste: lieber zu viel melden als im Ausfall Entwarnung geben.
|
|
laufend = {p for p in kritisch if p[0] in soll} if soll else set(kritisch)
|
|
offen = laufend - set(getroffen)
|
|
lines.append(f"cve_critical_offen {len(offen)}")
|
|
lines.append(f"cve_critical_entschieden {len(laufend & set(getroffen))}")
|
|
lines.append(f"cve_entscheidungen_lesefehler {1 if fehler else 0}")
|
|
|
|
abgelaufen = 0
|
|
ohne_befund = 0
|
|
for (ziel, cve), (art, ablauf) in sorted(getroffen.items()):
|
|
lines.append(
|
|
f'cve_entscheidung_gueltig_bis{{target="{esc(ziel)}",cve="{esc(cve)}",art="{esc(art)}"}} {int(ablauf)}'
|
|
)
|
|
if now > ablauf:
|
|
abgelaufen += 1
|
|
# ⚠️ Eine Entscheidung ohne Befund ist Altpapier: das Image ist ersetzt
|
|
# oder der Befund weg. Sie zaehlt hier, damit die Datei nicht zuwaechst
|
|
# und irgendwann Befunde deckt, die niemand mehr geprueft hat.
|
|
if (ziel, cve) not in laufend and ziel in soll:
|
|
ohne_befund += 1
|
|
lines.append(f"cve_entscheidungen_abgelaufen {abgelaufen}")
|
|
lines.append(f"cve_entscheidungen_ohne_befund {ohne_befund}")
|
|
lines.append(f"cve_entscheidungen_total {len(getroffen)}")
|
|
try:
|
|
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
|
|
with open(STATE_FILE, "w") as f:
|
|
json.dump(first_seen, f)
|
|
except Exception as e:
|
|
print(f"state save failed: {e}", flush=True)
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
if self.path != "/metrics":
|
|
self.send_response(404); self.end_headers(); return
|
|
body = collect().encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/plain; version=0.0.4")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def log_message(self, fmt, *args):
|
|
pass
|
|
|
|
|
|
# ⚠️ Unter __main__, damit test_entscheidungen.py die ECHTE Datei importieren
|
|
# kann statt eine zurechtgeschnittene Kopie. Ohne die Wache startet beim
|
|
# Import der Server, und der Test haette einen Nachbau geprueft - genau die
|
|
# Fehlerklasse, die scan-loop.sh am 2026-08-21 schon einmal gekostet hat.
|
|
if __name__ == "__main__":
|
|
ThreadingHTTPServer(("0.0.0.0", 9101), Handler).serve_forever()
|