diff --git a/monitoring/cve/cve-exporter.py b/monitoring/cve/cve-exporter.py index c9a5364..5796d13 100644 --- a/monitoring/cve/cve-exporter.py +++ b/monitoring/cve/cve-exporter.py @@ -37,15 +37,27 @@ def collect(): lines = [] now = int(time.time()) seen_keys = set() + gelesene_targets = set() # nur DEREN Erstfunde duerfen geprunt werden + berichte = 0 + lesefehler = 0 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: + 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)}"' @@ -68,10 +80,22 @@ def collect(): 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") + # 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): - if k not in seen_keys: + 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}") try: os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) with open(STATE_FILE, "w") as f: diff --git a/monitoring/prometheus/alerts.yml b/monitoring/prometheus/alerts.yml index 942f6b0..6f563c8 100644 --- a/monitoring/prometheus/alerts.yml +++ b/monitoring/prometheus/alerts.yml @@ -85,6 +85,26 @@ groups: room: security annotations: summary: "CVE-Scan fuer {{ $labels.target }} ist aelter als 2 Tage — Scanner pruefen (ein still gestorbener Scanner macht blind)" + # TrivyScanStale hat eine Blindstelle, die es prinzipiell nicht schliessen + # kann: ein Target OHNE je erfolgreichen Bericht hat gar keine Serie, an der + # 'time() - ...' haengen koennte. Es bleibt also still. Die beiden Regeln + # darunter fangen die zwei Faelle ab, die dahinterstecken. + - alert: TrivyReportUnreadable + expr: trivy_report_read_errors > 0 + for: 30m + labels: + severity: warning + room: security + annotations: + summary: "{{ $value }} CVE-Bericht(e) sind seit 30 min unlesbar — die betroffenen Targets werden NICHT ausgewertet und faellt sonst niemandem auf" + - alert: TrivyNoReports + expr: trivy_reports_total == 0 + for: 1h + labels: + severity: critical + room: security + annotations: + summary: "Der CVE-Exporter findet ueberhaupt keine Berichte mehr — der Scanner liefert nichts, die Schwachstellen-Sicht ist blind" # Sicherungen (management #0030). Bis 2026-08-14 gab es hierzu KEINE Regel: ein # fehlgeschlagenes naechtliches Backup waere unbemerkt geblieben - genau der stille