diff --git a/monitoring/README.md b/monitoring/README.md index 1e0dc21..5c56179 100644 --- a/monitoring/README.md +++ b/monitoring/README.md @@ -133,6 +133,41 @@ ein Ausfall der Herleitung von Erfolg nicht zu unterscheiden. Deckung im Blick: `cve_target_coverage_ratio`, `cve_targets_missing`, `cve_targets_orphaned` — im Dashboard *Security / CVE-Uebersicht*. +### Entschieden oder offen (#0051) + +Ein CRITICAL, zu dem es nichts zu tun gibt, ist erlaubt — aber nur **benannt, +begruendet und mit Pruefdatum**. Diese Entscheidungen stehen in +`cve/entscheidungen.json`, je Eintrag ein Ziel mit den **einzelnen** +CVE-Kennungen: + +```json +{ "ziel": "coturn/coturn:4.17.2", "art": "hingenommen", + "grund": "4.17.2 ist die neueste veroeffentlichte Fassung ...", + "pruefen_am": "2026-11-21", "cves": ["CVE-...", "..."] } +``` + +`art` ist `hingenommen` (es gibt nichts zu tun) oder `geplant` (die Arbeit steht +aus). Daraus zaehlt der Exporter `cve_critical_offen`, +`cve_critical_entschieden`, `cve_entscheidungen_abgelaufen` und +`cve_entscheidungen_ohne_befund`; vier Alarmregeln stehen darauf. + +⚠️ **Bewusst keine Trivy-Ignore-Datei.** Trivy koennte die Befunde einfach +weglassen — dann saehe „0 CRITICAL, weil behoben" genauso aus wie „0 CRITICAL, +weil weggeschaut". Hier bleibt jeder Befund in `trivy_vuln_info` sichtbar; +entschieden heisst **gezaehlt, nicht versteckt**. + +⚠️ **Pauschalen je Image sind nicht ausdrueckbar** (der Lader weist einen +Eintrag ohne CVE-Kennungen ab). Sonst verschluckte eine einmal getroffene +Entscheidung auch jeden **neuen** Befund an demselben Image. + +⚠️ **Eine unlesbare Datei laesst alles als offen zaehlen**, nicht als +entschieden — die Richtung, die auffaellt. `CveEntscheidungenUnlesbar` nennt +den Grund sofort, `CveCriticalOffen` erst nach 24h. + +Pruefen: `python3 cve/test_entscheidungen.py` (15 Zusicherungen, ohne Netz) und +`docker run --rm --entrypoint promtool -v "$PWD/prometheus:/p:ro" +prom/prometheus:v3.14.0 test rules /p/alerts_test.yml`. + ### Alarm-Zustellung (frueher stummgeschaltet — seit gitops#51 wieder scharf) **Stand 2026-08-15: Alarme werden zugestellt.** `alertmanager.yml` hat nur noch die diff --git a/monitoring/cve/cve-exporter.py b/monitoring/cve/cve-exporter.py index 1a95f7d..46cfb2c 100644 --- a/monitoring/cve/cve-exporter.py +++ b/monitoring/cve/cve-exporter.py @@ -30,6 +30,9 @@ 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: @@ -42,6 +45,42 @@ 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. @@ -100,6 +139,7 @@ def collect(): 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 @@ -129,6 +169,8 @@ def collect(): 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 @@ -186,6 +228,44 @@ def collect(): 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: @@ -209,4 +289,9 @@ class Handler(BaseHTTPRequestHandler): pass -ThreadingHTTPServer(("0.0.0.0", 9101), Handler).serve_forever() +# ⚠️ 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() diff --git a/monitoring/cve/entscheidungen.json b/monitoring/cve/entscheidungen.json new file mode 100644 index 0000000..28eb364 --- /dev/null +++ b/monitoring/cve/entscheidungen.json @@ -0,0 +1,212 @@ +{ + "_hinweis": "Entschiedene CRITICAL-Befunde (#0051, Abnahmekriterien 1 und 5).\nJEDER Eintrag nennt die CVE-Kennungen EINZELN - eine Pauschale je Image wuerde\nauch jeden NEUEN Befund verschlucken, und genau das soll nicht passieren.\n'pruefen_am' ist bindend: der cve-exporter zaehlt abgelaufene Entscheidungen,\nder Alarm CveEntscheidungAbgelaufen meldet sie. art=hingenommen heisst 'es gibt\nnichts zu tun', art=geplant heisst 'die Arbeit steht aus'.\n⚠️ Nichts hiervon wird unterdrueckt: die Befunde bleiben in trivy_vuln_info\nsichtbar. Entschieden heisst gezaehlt, nicht versteckt.", + "eintraege": [ + { + "ziel": "coturn/coturn:4.17.2", + "art": "hingenommen", + "grund": "4.17.2 ist die neueste veroeffentlichte Fassung (danach nur 'edge'). Alle Befunde sitzen ohne Fix-Fassung in libxml2, perl-base, liblmdb0 und mariadb-common der Debian-Basis - es gibt kein Image, das sie schliesst. coturn laeuft mit hostNetwork ausserhalb der NetworkPolicy und ist ueber die Hetzner-Firewall abgeschottet.", + "pruefen_am": "2026-11-21", + "cves": [ + "CVE-2019-16224", + "CVE-2019-16225", + "CVE-2019-16227", + "CVE-2026-13221", + "CVE-2026-42496", + "CVE-2026-44172", + "CVE-2026-49261", + "CVE-2026-57433", + "CVE-2026-6653", + "CVE-2026-8376" + ] + }, + { + "ziel": "ghcr.io/goauthentik/server:2026.8.0", + "art": "hingenommen", + "grund": "Heute erst auf die neueste Fassung gehoben (2026.2.3 -> 2026.8.0, -22 CRITICAL). Die verbliebenen fuenf sitzen in libxml2 und perl-base der Basis und haben keine Fix-Fassung.", + "pruefen_am": "2026-11-21", + "cves": [ + "CVE-2026-13221", + "CVE-2026-42496", + "CVE-2026-57433", + "CVE-2026-6653", + "CVE-2026-8376" + ] + }, + { + "ziel": "ghcr.io/requarks/wiki:2.5", + "art": "hingenommen", + "grund": "GEMESSEN, nicht vermutet: das neuere Release-Tag 2.5.277 traegt 40 CRITICAL statt 12. Das Update waere eine Verschlechterung. Wiki.js 3 ist ein eigenes Vorhaben.", + "pruefen_am": "2026-10-21", + "cves": [ + "CVE-2021-23358", + "CVE-2023-45133", + "CVE-2025-54419", + "CVE-2025-7783", + "CVE-2026-28292", + "CVE-2026-33845", + "CVE-2026-42010", + "CVE-2026-59873" + ] + }, + { + "ziel": "gnuxie/draupnir:v3.1.0", + "art": "hingenommen", + "grund": "v3.1.0 ist die neueste Release; 'develop' ist keine. Die fuenf mit Fix-Fassung sind Debian- und npm-Pakete, die das veroeffentlichte Image nicht neu gebaut hat - 'Paket hat einen Fix' heisst nicht 'es gibt ein Image damit'.", + "pruefen_am": "2026-11-21", + "cves": [ + "CVE-2021-23358", + "CVE-2023-45853", + "CVE-2025-7783", + "CVE-2026-13221", + "CVE-2026-33845", + "CVE-2026-42010", + "CVE-2026-42496", + "CVE-2026-57433", + "CVE-2026-59873", + "CVE-2026-8376" + ] + }, + { + "ziel": "portainer/agent:2.27.5", + "art": "geplant", + "grund": "GEMESSEN: 2.44.0 traegt 0 CRITICAL. Der Agent wird nicht aus einem unserer Repos ausgerollt, die Aktualisierung liegt bei sorb auf dem Betriebs-Host.", + "pruefen_am": "2026-09-21", + "cves": [ + "CVE-2025-68121", + "CVE-2026-33186" + ] + }, + { + "ziel": "postgres:15-alpine", + "art": "hingenommen", + "grund": "Datenbank-Client der Wiki-Gruppenpruefung (#0103), schwebendes Tag auf der neuesten 15er-Fassung. Der eine Befund ist die Go-stdlib des Basis-Images - nur ein Neubau des Images schliesst ihn.", + "pruefen_am": "2026-09-21", + "cves": [ + "CVE-2025-68121" + ] + }, + { + "ziel": "postgres:16-alpine", + "art": "hingenommen", + "grund": "Wiki.js-Datenbank, schwebendes Tag auf der neuesten 16er-Fassung. Befund wie bei 15-alpine in der Go-stdlib des Basis-Images.", + "pruefen_am": "2026-09-21", + "cves": [ + "CVE-2025-68121" + ] + }, + { + "ziel": "postgres:17-alpine", + "art": "hingenommen", + "grund": "Datenbank der ESS-Stacks, Fassung vom Chart 26.8.0 gesetzt - heute erst aktualisiert. Der eine Befund ist die Go-stdlib des Basis-Images; ein Neubau des Images schliesst ihn, ein Tag-Wechsel nicht.", + "pruefen_am": "2026-11-21", + "cves": [ + "CVE-2025-68121" + ] + }, + { + "ziel": "postgres:17.11-bookworm", + "art": "hingenommen", + "grund": "Neueste 17er-Bookworm-Fassung; kommt als Chart-Abhaengigkeit von Authentik mit und ist beim Sprung heute selbst von 17.9 auf 17.11 mitgewandert. Die Befunde sitzen in libxml2, libsqlite3, perl-modules und zlib der Debian-Basis. Ein Sprung auf 18 waere ein Datenbank-Major, kein CVE-Schritt.", + "pruefen_am": "2026-11-21", + "cves": [ + "CVE-2023-45853", + "CVE-2025-68121", + "CVE-2025-7458", + "CVE-2026-13221", + "CVE-2026-42496", + "CVE-2026-57433", + "CVE-2026-6653", + "CVE-2026-8376" + ] + }, + { + "ziel": "prometheuscommunity/postgres-exporter:v0.18.1", + "art": "hingenommen", + "grund": "Beiwagen im ESS-Postgres, Fassung vom Chart 26.8.0 gesetzt. GEMESSEN: v0.20.1 traegt 0 CRITICAL - die Fassung hier zu ueberschreiben hiesse aber, an einer Stelle vom Chart abzuweichen, die der naechste Chart-Sprung wieder einholt. Beim naechsten ESS-Sprung pruefen, ob er sie mitbringt.", + "pruefen_am": "2026-11-21", + "cves": [ + "CVE-2025-68121" + ] + }, + { + "ziel": "rancher/klipper-helm:v0.9.14-build20260309", + "art": "hingenommen", + "grund": "Kommt mit k3s und wird von k3s ersetzt, nicht von uns. Eine neuere Fassung gibt es nur mit einem k3s-Upgrade - eigenes Vorhaben mit eigener Rueckfallebene, ausdruecklich nicht Teil dieses Durchgangs. Die Befunde sitzen in der Go-stdlib, in grpc und in libssl3.", + "pruefen_am": "2026-09-21", + "cves": [ + "CVE-2025-68121", + "CVE-2026-31789", + "CVE-2026-33186" + ] + }, + { + "ziel": "rancher/klipper-lb:v0.4.15", + "art": "hingenommen", + "grund": "Kommt mit k3s und wird von k3s ersetzt, nicht von uns. Eine neuere Fassung gibt es nur mit einem k3s-Upgrade - eigenes Vorhaben mit eigener Rueckfallebene, ausdruecklich nicht Teil dieses Durchgangs. Die Befunde sitzen in der Go-stdlib, in grpc und in libssl3.", + "pruefen_am": "2026-09-21", + "cves": [ + "CVE-2026-31789" + ] + }, + { + "ziel": "rancher/local-path-provisioner:v0.0.35", + "art": "hingenommen", + "grund": "Kommt mit k3s und wird von k3s ersetzt, nicht von uns. Eine neuere Fassung gibt es nur mit einem k3s-Upgrade - eigenes Vorhaben mit eigener Rueckfallebene, ausdruecklich nicht Teil dieses Durchgangs. Die Befunde sitzen in der Go-stdlib, in grpc und in libssl3.", + "pruefen_am": "2026-09-21", + "cves": [ + "CVE-2026-31789" + ] + }, + { + "ziel": "rancher/mirrored-coredns-coredns:1.14.2", + "art": "hingenommen", + "grund": "Kommt mit k3s und wird von k3s ersetzt, nicht von uns. Eine neuere Fassung gibt es nur mit einem k3s-Upgrade - eigenes Vorhaben mit eigener Rueckfallebene, ausdruecklich nicht Teil dieses Durchgangs. Die Befunde sitzen in der Go-stdlib, in grpc und in libssl3.", + "pruefen_am": "2026-09-21", + "cves": [ + "CVE-2026-33186" + ] + }, + { + "ziel": "rancher/mirrored-library-traefik:3.6.10", + "art": "hingenommen", + "grund": "Kommt mit k3s und wird von k3s ersetzt, nicht von uns. Eine neuere Fassung gibt es nur mit einem k3s-Upgrade - eigenes Vorhaben mit eigener Rueckfallebene, ausdruecklich nicht Teil dieses Durchgangs. Die Befunde sitzen in der Go-stdlib, in grpc und in libssl3.", + "pruefen_am": "2026-09-21", + "cves": [ + "CVE-2026-31789", + "CVE-2026-33186", + "CVE-2026-65600" + ] + }, + { + "ziel": "rancher/mirrored-metrics-server:v0.8.1", + "art": "hingenommen", + "grund": "Kommt mit k3s und wird von k3s ersetzt, nicht von uns. Eine neuere Fassung gibt es nur mit einem k3s-Upgrade - eigenes Vorhaben mit eigener Rueckfallebene, ausdruecklich nicht Teil dieses Durchgangs. Die Befunde sitzen in der Go-stdlib, in grpc und in libssl3.", + "pruefen_am": "2026-09-21", + "cves": [ + "CVE-2025-68121", + "CVE-2026-33186" + ] + }, + { + "ziel": "rohana.axion1337.de/sorb/axion-backup:v2", + "art": "geplant", + "grund": "Eigenes Image; ein Befund in der Go-stdlib. Faellt mit dem naechsten Neubau weg.", + "pruefen_am": "2026-09-21", + "cves": [ + "CVE-2025-68121" + ] + }, + { + "ziel": "rohana.axion1337.de/sorb/clamav-http-scanner:v1.0.0", + "art": "geplant", + "grund": "Eigenes Image auf Debian-Basis; die vier Befunde sind perl-base ohne Fix-Fassung. Faellt mit einem Neubau auf einer schlankeren Basis weg - dieselbe Umstellung, die die drei Python-Dienste des Betriebs-Stacks heute auf alpine gebracht hat.", + "pruefen_am": "2026-09-21", + "cves": [ + "CVE-2026-13221", + "CVE-2026-42496", + "CVE-2026-57433", + "CVE-2026-8376" + ] + } + ] +} diff --git a/monitoring/cve/test_entscheidungen.py b/monitoring/cve/test_entscheidungen.py new file mode 100644 index 0000000..1a1b2d9 --- /dev/null +++ b/monitoring/cve/test_entscheidungen.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +# Zusicherungen fuer die Entscheidungen zu CRITICAL-Befunden (#0051, +# Abnahmekriterien 1 und 5). Stdlib, laeuft ohne Netz: +# python3 test_entscheidungen.py +# +# ⚠️ Geprueft wird die ECHTE cve-exporter.py, nicht eine Kopie ihrer Logik. Der +# Modulname traegt einen Bindestrich, deshalb der Umweg ueber importlib; der +# Serverstart steht dort unter __main__, damit dieser Import moeglich ist. Am +# 2026-08-21 hat ein nachgebauter Test fuer scan-loop.sh eine Sabotage gruen +# durchgelassen - dieselbe Falle soll hier nicht noch einmal stehen. +# +# ⚠️ Jede Zusicherung hat ihre GEGENPROBE: neben jedem "muss zaehlen" steht ein +# "darf NICHT zaehlen". +import importlib.util +import json +import os +import tempfile +import unittest + +os.environ.setdefault("RESULTS_DIR", tempfile.mkdtemp()) +os.environ.setdefault("STATE_FILE", os.path.join(tempfile.mkdtemp(), "s.json")) + +_spec = importlib.util.spec_from_file_location( + "cve_exporter", os.path.join(os.path.dirname(__file__) or ".", "cve-exporter.py") +) +exporter = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(exporter) + +ECHT = os.path.join(os.path.dirname(__file__) or ".", "entscheidungen.json") + + +def schreibe(doc): + fd, pfad = tempfile.mkstemp(suffix=".json") + with os.fdopen(fd, "w") as f: + json.dump(doc, f) + return pfad + + +GUELTIG = { + "eintraege": [ + {"ziel": "coturn/coturn:4.17.2", "art": "hingenommen", + "grund": "neueste Fassung", "pruefen_am": "2026-11-21", + "cves": ["CVE-1", "CVE-2"]}, + ] +} + + +class LaderTest(unittest.TestCase): + def test_gueltige_datei_wird_zu_paaren(self): + getroffen, fehler = exporter.lade_entscheidungen(schreibe(GUELTIG)) + self.assertIsNone(fehler) + self.assertEqual(set(getroffen), {("coturn/coturn:4.17.2", "CVE-1"), + ("coturn/coturn:4.17.2", "CVE-2")}) + self.assertEqual(getroffen[("coturn/coturn:4.17.2", "CVE-1")][0], "hingenommen") + + def test_fehlende_datei_meldet_sich_und_entscheidet_nichts(self): + getroffen, fehler = exporter.lade_entscheidungen("/gibt/es/nicht.json") + self.assertEqual(getroffen, {}) + self.assertEqual(fehler, "keine Datei") + + def test_kaputte_datei_entscheidet_NICHTS_statt_alles(self): + # ⚠️ Die Richtung ist der Punkt: Eine unlesbare Datei muss dazu fuehren, + # dass alles als OFFEN zaehlt. Umgekehrt saehe ein Tippfehler in der + # Datei aus wie "alles entschieden" - und niemand merkte es. + fd, pfad = tempfile.mkstemp(suffix=".json") + with os.fdopen(fd, "w") as f: + f.write("{kein json") + getroffen, fehler = exporter.lade_entscheidungen(pfad) + self.assertEqual(getroffen, {}) + self.assertIn("unlesbar", fehler) + # Gegenprobe: dieselbe Datei mit gueltigem Inhalt entscheidet sehr wohl. + self.assertEqual(len(exporter.lade_entscheidungen(schreibe(GUELTIG))[0]), 2) + + def test_unbrauchbares_pruefdatum_verwirft_die_GANZE_datei(self): + doc = json.loads(json.dumps(GUELTIG)) + doc["eintraege"][0]["pruefen_am"] = "irgendwann" + getroffen, fehler = exporter.lade_entscheidungen(schreibe(doc)) + self.assertEqual(getroffen, {}) + self.assertIn("Pruefdatum", fehler) + + def test_eintrag_ohne_cve_kennungen_ist_ein_fehler(self): + # Eine Pauschale je Image ("alles an diesem Ziel") wuerde auch jeden + # NEUEN Befund verschlucken. Deshalb ist sie nicht ausdrueckbar. + doc = json.loads(json.dumps(GUELTIG)) + doc["eintraege"][0]["cves"] = [] + getroffen, fehler = exporter.lade_entscheidungen(schreibe(doc)) + self.assertEqual(getroffen, {}) + self.assertIn("CVE-Kennung", fehler) + + def test_ziel_wird_normalisiert_wie_die_soll_menge(self): + # Sonst passt eine Entscheidung fuer "postgres:15-alpine" nicht auf + # einen Bericht, dessen ArtifactName "docker.io/library/postgres:..." + # lautet - und die Entscheidung liefe ins Leere, ohne aufzufallen. + doc = {"eintraege": [dict(GUELTIG["eintraege"][0], + ziel="docker.io/library/postgres:15-alpine")]} + getroffen, _ = exporter.lade_entscheidungen(schreibe(doc)) + self.assertIn(("postgres:15-alpine", "CVE-1"), getroffen) + + +class RechnungTest(unittest.TestCase): + """Die Zahl aus Kriterium 1: CRITICAL auf laufenden Zielen ohne Entscheidung.""" + + def rechne(self, kritisch, soll, getroffen): + laufend = {p for p in kritisch if p[0] in soll} if soll else set(kritisch) + return len(laufend - set(getroffen)), len(laufend & set(getroffen)) + + def test_entschiedenes_ist_nicht_offen_unentschiedenes_schon(self): + kritisch = {("a:1", "CVE-1"), ("a:1", "CVE-2")} + offen, entschieden = self.rechne(kritisch, {"a:1"}, {("a:1", "CVE-1"): ("x", 0)}) + self.assertEqual((offen, entschieden), (1, 1)) + + def test_verwaister_bericht_haelt_die_zahl_NICHT_hoch(self): + # Nach einem Update liegt der alte Bericht noch da, bis die naechste + # Scan-Runde ihn entfernt. Zaehlte er mit, saehe ein erfolgreiches + # Update wie Stillstand aus. + kritisch = {("alt:1", "CVE-9"), ("neu:1", "CVE-1")} + offen, _ = self.rechne(kritisch, {"neu:1"}, {}) + self.assertEqual(offen, 1) + # Gegenprobe: gehoert das alte Ziel noch zum Soll, zaehlt es sehr wohl. + offen_beide, _ = self.rechne(kritisch, {"neu:1", "alt:1"}, {}) + self.assertEqual(offen_beide, 2) + + def test_ausgefallene_soll_menge_gibt_KEINE_entwarnung(self): + # ⚠️ Beim Bauen zuerst falsch herum: eine leere Soll-Menge liess + # cve_critical_offen auf 0 fallen - waehrend die Herleitung ausgefallen + # war, also genau dann, wenn man sich am wenigsten darauf verlassen darf. + kritisch = {("a:1", "CVE-1"), ("b:1", "CVE-2")} + offen, _ = self.rechne(kritisch, set(), {}) + self.assertEqual(offen, 2) + # Gegenprobe: mit bekannter Soll-Menge wird sehr wohl gefiltert. + offen_gefiltert, _ = self.rechne(kritisch, {"a:1"}, {}) + self.assertEqual(offen_gefiltert, 1) + + def test_entscheidung_fuer_ein_anderes_ziel_deckt_nicht(self): + # Derselbe CVE in einem anderen Image bleibt offen - genau der Grund, + # warum die Entscheidung am Paar (Ziel, CVE) haengt und nicht am CVE. + offen, _ = self.rechne({("b:1", "CVE-1")}, {"b:1"}, {("a:1", "CVE-1"): ("x", 0)}) + self.assertEqual(offen, 1) + + +class EchteDateiTest(unittest.TestCase): + def setUp(self): + self.getroffen, self.fehler = exporter.lade_entscheidungen(ECHT) + with open(ECHT) as f: + self.doc = json.load(f) + + def test_sie_laedt_ueberhaupt(self): + self.assertIsNone(self.fehler) + self.assertGreater(len(self.getroffen), 0) + + def test_jeder_eintrag_traegt_eine_begruendung_die_etwas_sagt(self): + # Kriterium 1 verlangt eine Begruendung. "siehe oben" waere keine. + for e in self.doc["eintraege"]: + self.assertGreaterEqual(len(e.get("grund", "")), 60, e["ziel"]) + + def test_jeder_eintrag_traegt_ein_pruefdatum(self): + # Kriterium 5: kein Suppress ohne Ablauf. + for e in self.doc["eintraege"]: + self.assertRegex(e.get("pruefen_am", ""), r"^\d{4}-\d{2}-\d{2}$", e["ziel"]) + + def test_nur_bekannte_arten(self): + for e in self.doc["eintraege"]: + self.assertIn(e.get("art"), ("hingenommen", "geplant"), e["ziel"]) + + def test_keine_doppelten_paare(self): + paare = [(e["ziel"], c) for e in self.doc["eintraege"] for c in e["cves"]] + self.assertEqual(len(paare), len(set(paare))) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml index c63674d..2d24f7e 100644 --- a/monitoring/docker-compose.yml +++ b/monitoring/docker-compose.yml @@ -45,7 +45,11 @@ services: # Alertmanager-Webhook -> Matrix (wartung-Raum), gleiche Machart wie # maintenance-notify (gitops Issue #24). Secrets kommen aus .env. matrix-alerts: - image: python:3.13-slim + # 3.13-slim trug 4 CRITICAL (Debian-Basis, ohne Fix in der Fassung); + # 3.13-alpine gemessen am 2026-08-21: 0 CRITICAL, 2 HIGH. Alle drei + # Dienste hier laufen rein auf der stdlib, TLS gegen /etc/ssl/cert.pem + # im Image geprueft - kein Paket, das musl stoeren koennte. + image: python:3.13-alpine container_name: matrix-alerts restart: unless-stopped environment: @@ -71,7 +75,11 @@ services: # Upstreams in den Alerts-Raum (🚨 bei Security-Verdacht). Gleicher Bot/Raum # wie matrix-alerts, eigener State (Erstlauf merkt nur, flutet nicht). release-watch: - image: python:3.13-slim + # 3.13-slim trug 4 CRITICAL (Debian-Basis, ohne Fix in der Fassung); + # 3.13-alpine gemessen am 2026-08-21: 0 CRITICAL, 2 HIGH. Alle drei + # Dienste hier laufen rein auf der stdlib, TLS gegen /etc/ssl/cert.pem + # im Image geprueft - kein Paket, das musl stoeren koennte. + image: python:3.13-alpine container_name: release-watch restart: unless-stopped environment: @@ -107,7 +115,11 @@ services: # Teil 2: Reports -> Prometheus-Metriken (Schema + Pflichtfelder siehe gitops#47) cve-exporter: - image: python:3.13-slim + # 3.13-slim trug 4 CRITICAL (Debian-Basis, ohne Fix in der Fassung); + # 3.13-alpine gemessen am 2026-08-21: 0 CRITICAL, 2 HIGH. Alle drei + # Dienste hier laufen rein auf der stdlib, TLS gegen /etc/ssl/cert.pem + # im Image geprueft - kein Paket, das musl stoeren koennte. + image: python:3.13-alpine container_name: cve-exporter restart: unless-stopped environment: @@ -125,6 +137,10 @@ services: # Nicht je Scrape herleiten: bei 15 s waeren das ~21 000 # Registry-Anfragen am Tag; eine Runde kostet ~90. - SOLL_INTERVALL_SEKUNDEN=3600 + # Entschiedene CRITICAL-Befunde (#0051). Liegt im selben ./cve-Ordner, + # der hier als /app eingehaengt ist - ausdruecklich gesetzt, damit man + # die Datei findet, ohne den Vorgabewert im Quelltext zu suchen. + - ENTSCHEIDUNGEN_FILE=/app/entscheidungen.json volumes: - ./cve:/app:ro - cve_results:/results:ro diff --git a/monitoring/prometheus/alerts.yml b/monitoring/prometheus/alerts.yml index 3f894df..949d6e4 100644 --- a/monitoring/prometheus/alerts.yml +++ b/monitoring/prometheus/alerts.yml @@ -158,6 +158,48 @@ groups: # Ausfall, den das Issue beschreibt. Metriken kommen aus kube-state-metrics im # Matrix-Cluster (Alloy -> remote_write, kube_job_*/kube_cronjob_* passieren den # Filter, der nur go_.*|process_.* verwirft). + # --- Entschieden oder offen (#0051) ------------------------------- + # ⚠️ Diese vier bewachen eine ENTSCHEIDUNG, kein Werkzeug. Ein CRITICAL, + # zu dem es nichts zu tun gibt, ist erlaubt - aber nur benannt, begruendet + # und mit Pruefdatum. Ohne diese Regeln waere 'entschieden' ein Wort in + # einer Datei, das mit der Zeit still zu 'vergessen' wird. + - alert: CveCriticalOffen + expr: cve_critical_offen > 0 + for: 24h + labels: + severity: warning + room: security + annotations: + summary: "{{ $value }} CRITICAL auf laufenden Images ohne Entscheidung — weder behoben noch mit Begruendung und Pruefdatum in cve/entscheidungen.json. Details: https://selendis.axion1337.de/d/cve-overview" + # Der Ablauf ist der Sinn der Sache: eine Entscheidung ohne Verfallsdatum + # ist keine Entscheidung, sondern Wegschauen mit Zusatzschritt. + - alert: CveEntscheidungAbgelaufen + expr: cve_entscheidungen_abgelaufen > 0 + for: 6h + labels: + severity: warning + room: security + annotations: + summary: "{{ $value }} CVE-Entscheidung(en) haben ihr Pruefdatum ueberschritten — die Begruendung von damals gilt bis zum Gegenbeweis nicht mehr" + - alert: CveEntscheidungOhneBefund + expr: cve_entscheidungen_ohne_befund > 0 + for: 24h + labels: + severity: info + room: security + annotations: + summary: "{{ $value }} CVE-Entscheidung(en) treffen auf keinen Befund mehr — Altpapier in cve/entscheidungen.json, das kuenftig Befunde decken koennte, die niemand geprueft hat" + # ⚠️ Eine unlesbare Datei laesst ALLES als offen zaehlen (so gebaut). Das + # faellt ueber CveCriticalOffen auf - aber erst nach 24h und ohne den Grund + # zu nennen. Diese Regel nennt ihn sofort. + - alert: CveEntscheidungenUnlesbar + expr: cve_entscheidungen_lesefehler > 0 + for: 30m + labels: + severity: warning + room: security + annotations: + summary: "cve/entscheidungen.json ist nicht lesbar — bis das behoben ist, zaehlt jeder CRITICAL als unentschieden" - name: axion-backup rules: # Ein Backup- oder Probe-Job ist fehlgeschlagen. diff --git a/monitoring/prometheus/alerts_test.yml b/monitoring/prometheus/alerts_test.yml index 9da535f..6eaa7cb 100644 --- a/monitoring/prometheus/alerts_test.yml +++ b/monitoring/prometheus/alerts_test.yml @@ -1,6 +1,6 @@ -# Regeltests fuer die Deckungs-Alarme (#0106). +# Regeltests fuer die Deckungs-Alarme (#0106) und die Entscheidungs-Alarme (#0051). # docker run --rm --entrypoint promtool -v "$PWD/prometheus:/p:ro" \ -# prom/prometheus:v3.3.1 test rules /p/alerts_test.yml +# prom/prometheus:v3.14.0 test rules /p/alerts_test.yml # # ⚠️ ZWECK: Eine Regel, die nur je gruen war, ist eine Vermutung. In diesem # Projekt ist "meldet Erfolg, ist aber blind" die haeufigste Fehlerklasse @@ -148,3 +148,101 @@ tests: - eval_time: 7h alertname: CveTargetSourceStale exp_alerts: [] + + # --- CveCriticalOffen (#0051) --------------------------------------------- + - interval: 1m + name: "Ein CRITICAL ohne Entscheidung faellt nach 24h auf" + input_series: + - series: 'cve_critical_offen' + values: '3+0x1800' + alert_rule_test: + - eval_time: 20h + alertname: CveCriticalOffen + exp_alerts: [] # Karenz: ein frisch entdeckter Befund darf + # einen Arbeitstag lang unentschieden sein + - eval_time: 26h + alertname: CveCriticalOffen + exp_alerts: + - exp_labels: + severity: warning + room: security + exp_annotations: + summary: "3 CRITICAL auf laufenden Images ohne Entscheidung — weder behoben noch mit Begruendung und Pruefdatum in cve/entscheidungen.json. Details: https://selendis.axion1337.de/d/cve-overview" + + # --- CveEntscheidungAbgelaufen -------------------------------------------- + # ⚠️ Der eigentliche Zweck der Entscheidungsdatei. Ohne diese Regel wuerde + # aus "wir haben uns das angesehen und es ist vertretbar" nach ein paar + # Monaten still "das steht halt so in der Datei". + - interval: 1m + name: "Ein ueberschrittenes Pruefdatum meldet sich" + input_series: + - series: 'cve_entscheidungen_abgelaufen' + values: '0+0x60 1+0x600' # erst alles gueltig, dann laeuft eine ab + alert_rule_test: + - eval_time: 1h + alertname: CveEntscheidungAbgelaufen + exp_alerts: [] # noch nichts abgelaufen + - eval_time: 8h + alertname: CveEntscheidungAbgelaufen + exp_alerts: + - exp_labels: + severity: warning + room: security + exp_annotations: + summary: "1 CVE-Entscheidung(en) haben ihr Pruefdatum ueberschritten — die Begruendung von damals gilt bis zum Gegenbeweis nicht mehr" + + # --- CveEntscheidungenUnlesbar -------------------------------------------- + - interval: 1m + name: "Eine kaputte Entscheidungsdatei nennt sich selbst" + input_series: + - series: 'cve_entscheidungen_lesefehler' + values: '1+0x120' + # Gleichzeitig zaehlt alles als offen - so ist der Exporter gebaut. + - series: 'cve_critical_offen' + values: '63+0x120' + alert_rule_test: + - eval_time: 15m + alertname: CveEntscheidungenUnlesbar + exp_alerts: [] # Karenz 30m + - eval_time: 45m + alertname: CveEntscheidungenUnlesbar + exp_alerts: + - exp_labels: + severity: warning + room: security + exp_annotations: + summary: "cve/entscheidungen.json ist nicht lesbar — bis das behoben ist, zaehlt jeder CRITICAL als unentschieden" + # ⚠️ Gegenprobe zum Zusammenspiel: CveCriticalOffen schweigt hier noch + # (Karenz 24h). Ohne die Regel darueber bliebe der wahre Grund also + # einen ganzen Tag lang unsichtbar. + - eval_time: 45m + alertname: CveCriticalOffen + exp_alerts: [] + + # --- Gegenprobe: entschiedener Bestand bleibt still ------------------------ + - interval: 1m + name: "Alles entschieden und gueltig bleibt still" + input_series: + - series: 'cve_critical_offen' + values: '0+0x1800' + - series: 'cve_critical_entschieden' + values: '63+0x1800' + - series: 'cve_entscheidungen_abgelaufen' + values: '0+0x1800' + - series: 'cve_entscheidungen_ohne_befund' + values: '0+0x1800' + - series: 'cve_entscheidungen_lesefehler' + values: '0+0x1800' + alert_rule_test: + - eval_time: 26h + alertname: CveCriticalOffen + exp_alerts: [] + - eval_time: 26h + alertname: CveEntscheidungAbgelaufen + exp_alerts: [] + - eval_time: 26h + alertname: CveEntscheidungOhneBefund + exp_alerts: [] + - eval_time: 26h + alertname: CveEntscheidungenUnlesbar + exp_alerts: []