diff --git a/monitoring/README.md b/monitoring/README.md index dfcce53..dff1c42 100644 --- a/monitoring/README.md +++ b/monitoring/README.md @@ -132,6 +132,19 @@ aus der Zielmenge gefallen. Der nackte Digest wird ausserdem ausdruecklich abgewiesen, sonst landete er als Repo `sha256` in `targets.txt` und zaehlte dauerhaft als Deckungsluecke. +⚠️ **Eine Antwort kann erfolgreich und trotzdem wertlos sein.** Am 2026-08-23 +legte ein k3s-Neustart kube-state-metrics und Alloy lahm; `kube_pod_container_info` +war daraufhin in Prometheus **leer**. Die Abfrage lief sauber durch, lieferte +null Zeilen — und galt als Erfolg. Die Cluster-Quelle fiel von 39 Zielen auf 0, +die Gesamtmenge von 54 auf 12, der Scanner loeschte **42 Berichte**, und das +Dashboard meldete Deckung **1,0**. Keine Regel schlug an: die Menge war nicht +leer, die Zeitstempel waren frisch. + +Seitdem gilt: **Eine Quelle, die schon einmal geliefert hat und jetzt nichts +liefert, ist ein Ausfall.** Der alte Stand bleibt, der Zeitstempel altert, +`CveTargetSourceStale` greift — und `CveZielmengeEingebrochen` meldet zusaetzlich +jeden Einbruch der Gesamtmenge um mehr als 40 %. + ⚠️ **Faellt eine Quelle aus, wird die Soll-Menge kleiner — und die Deckung sieht dadurch BESSER aus, nicht schlechter.** Deshalb traegt jede Quelle einen Frische-Zeitstempel (`cve_target_source_stale`), auf dem eine Alarmregel steht. diff --git a/monitoring/cve/cve-exporter.py b/monitoring/cve/cve-exporter.py index 1e28cfe..37939e0 100644 --- a/monitoring/cve/cve-exporter.py +++ b/monitoring/cve/cve-exporter.py @@ -102,7 +102,9 @@ class ZielCache: 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) + h = ziel_herleitung.herleiten( + cfg, vorheriger_stand=self._stand, + vorherige_ziele=(self._letzte.je_quelle if self._letzte else None)) self._stand = dict(h.stand) self._letzte, self._geholt_um = h, time.time() for quelle, meldung in h.fehler.items(): diff --git a/monitoring/cve/entscheidungen.json b/monitoring/cve/entscheidungen.json index 11dfa35..76d8f5e 100644 --- a/monitoring/cve/entscheidungen.json +++ b/monitoring/cve/entscheidungen.json @@ -206,65 +206,6 @@ "CVE-2026-8376" ] }, - { - "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:v1", "art": "hingenommen", diff --git a/monitoring/cve/targets.py b/monitoring/cve/targets.py index ca91786..20c2e8d 100644 --- a/monitoring/cve/targets.py +++ b/monitoring/cve/targets.py @@ -284,7 +284,8 @@ class Herleitung(NamedTuple): return bool(self.ziele) -def herleiten(cfg: dict, vorheriger_stand: Optional[dict] = None) -> Herleitung: +def herleiten(cfg: dict, vorheriger_stand: Optional[dict] = None, + vorherige_ziele: Optional[dict] = None) -> Herleitung: """Fragt alle drei Quellen. Ein Ausfall kostet nur die eigene Quelle. ⚠️ REIHENFOLGE IST BEDEUTSAM: Die Registry-Auswahl braucht den laufenden @@ -294,6 +295,7 @@ def herleiten(cfg: dict, vorheriger_stand: Optional[dict] = None) -> Herleitung: Rollback-Ziel ist und was ein Bau-Artefakt. """ stand = dict(vorheriger_stand or {}) + vorher = dict(vorherige_ziele or {}) je_quelle, fehler = {}, {} jetzt = time.time() @@ -302,11 +304,31 @@ def herleiten(cfg: dict, vorheriger_stand: Optional[dict] = None) -> Herleitung: je_quelle[name] = set() return try: - je_quelle[name] = hole() - stand[name] = jetzt + neu = hole() except Exception as e: # noqa: BLE001 - bewusst breit - je_quelle[name] = set() + je_quelle[name] = set(vorher.get(name, ())) fehler[name] = f"{type(e).__name__}: {e}" + return + # ⚠️ EINE ANTWORT KANN ERFOLGREICH UND TROTZDEM WERTLOS SEIN. + # Am 2026-08-23 hat der k3s-Neustart kube-state-metrics und Alloy + # lahmgelegt; `kube_pod_container_info` war in Prometheus daraufhin + # LEER. Die Abfrage lief sauber durch, lieferte null Zeilen - und + # galt als Erfolg. Damit fiel die Cluster-Quelle von 39 Zielen auf 0, + # die Gesamtmenge von 54 auf 12, der Scanner loeschte 42 Berichte, und + # das Dashboard meldete Deckung 1,0. Keine der drei Regeln schlug an: + # die Menge war nicht leer, und der Zeitstempel war frisch. + # + # Eine Quelle, die schon einmal geliefert hat und jetzt NICHTS liefert, + # gilt deshalb als Ausfall: alter Stand bleibt stehen, der Zeitstempel + # altert, CveTargetSourceStale greift. Lieber eine veraltete Menge als + # eine, die stillschweigend Ziele fallen laesst. + if not neu and vorher.get(name): + je_quelle[name] = set(vorher[name]) + fehler[name] = (f"leere Antwort trotz {len(vorher[name])} Zielen beim " + f"letzten Mal - alter Stand behalten") + return + je_quelle[name] = neu + stand[name] = jetzt versuche("cluster", lambda: ziele_cluster(cfg["prometheus"], cfg["timeout"])) versuche("operating", lambda: ziele_operating(cfg["prometheus"], cfg["timeout"])) diff --git a/monitoring/cve/test_targets.py b/monitoring/cve/test_targets.py index addb501..577a594 100644 --- a/monitoring/cve/test_targets.py +++ b/monitoring/cve/test_targets.py @@ -344,6 +344,77 @@ class Deckungsrechnung(unittest.TestCase): self.assertEqual(len(soll - ist_kaputt), 1) # merkt es +class LeereAntwortIstKeinErfolg(unittest.TestCase): + """⚠️ Nachgetragen am 2026-08-23, nachdem genau dieser Fall die Zielmenge + von 54 auf 12 fallen liess und der Scanner 42 Berichte loeschte. + + Der k3s-Neustart legte kube-state-metrics und Alloy lahm; die + Prometheus-Abfrage lief daraufhin sauber durch und lieferte NULL Zeilen. + Das galt als Erfolg: Zeitstempel frisch, Menge nicht leer (der Betriebs-Host + lieferte ja), also schlug keine der drei Regeln an. Deckung 1,0, waehrend + drei Viertel des Bestands unbeobachtet waren. + """ + + CFG = {"prometheus": "http://x", "registry": "r", "je_repo": 3, "timeout": 1} + + def herleite(self, antworten, vorher=None, stand=None): + echt = (targets.ziele_cluster, targets.ziele_operating, targets.ziele_registry) + targets.ziele_cluster = lambda *a, **k: antworten["cluster"] + targets.ziele_operating = lambda *a, **k: antworten["operating"] + targets.ziele_registry = lambda *a, **k: antworten["registry"] + try: + return targets.herleiten(self.CFG, vorheriger_stand=stand, vorherige_ziele=vorher) + finally: + targets.ziele_cluster, targets.ziele_operating, targets.ziele_registry = echt + + VORHER = {"cluster": {"a:1", "b:1"}, "operating": {"c:1"}, "registry": {"d:1"}} + + def test_leere_antwort_behaelt_den_alten_stand(self): + h = self.herleite({"cluster": set(), "operating": {"c:1"}, "registry": {"d:1"}}, + vorher=self.VORHER, stand={"cluster": 100.0}) + self.assertEqual(h.je_quelle["cluster"], {"a:1", "b:1"}) # NICHT verloren + self.assertIn("cluster", h.fehler) + self.assertEqual(h.stand["cluster"], 100.0) # Zeitstempel altert + + def test_gegenprobe_eine_ECHT_leere_erstmenge_ist_kein_fehler(self): + # Ohne Vorwissen ist eine leere Antwort schlicht eine leere Antwort - + # sonst koennte die Anlage nie sauber anlaufen. + h = self.herleite({"cluster": set(), "operating": {"c:1"}, "registry": {"d:1"}}, + vorher=None, stand=None) + self.assertEqual(h.je_quelle["cluster"], set()) + self.assertNotIn("cluster", h.fehler) + + def test_gegenprobe_eine_gefuellte_antwort_ersetzt_den_alten_stand(self): + h = self.herleite({"cluster": {"neu:1"}, "operating": {"c:1"}, "registry": {"d:1"}}, + vorher=self.VORHER, stand={"cluster": 100.0}) + self.assertEqual(h.je_quelle["cluster"], {"neu:1"}) + self.assertNotIn("cluster", h.fehler) + self.assertGreater(h.stand["cluster"], 100.0) + + def test_ein_ausfall_verliert_die_ziele_der_quelle_NICHT(self): + def kaputt(*a, **k): + raise RuntimeError("Prometheus weg") + echt = targets.ziele_cluster + targets.ziele_cluster = kaputt + try: + h = targets.herleiten(self.CFG, vorheriger_stand={"cluster": 100.0}, + vorherige_ziele=self.VORHER) + finally: + targets.ziele_cluster = echt + self.assertEqual(h.je_quelle["cluster"], {"a:1", "b:1"}) + self.assertIn("cluster", h.fehler) + + def test_die_gesamtmenge_bricht_dadurch_nicht_ein(self): + # Der eigentliche Schaden war die SUMME: 54 -> 12. Genau die darf nicht + # mehr passieren, solange die Quelle schon einmal geliefert hat. + h = self.herleite({"cluster": set(), "operating": {"c:1"}, "registry": set()}, + vorher=self.VORHER, stand=None) + self.assertEqual(len(h.ziele), 4) # a,b,c,d - nichts verloren + h_ohne = self.herleite({"cluster": set(), "operating": {"c:1"}, "registry": set()}, + vorher=None, stand=None) + self.assertEqual(len(h_ohne.ziele), 1) # Gegenprobe: ohne Vorwissen sehr wohl + + class DigestFestgenagelt(unittest.TestCase): """⚠️ Nachgetragen am 2026-08-21, nachdem ein Chart-Sprung einen per Digest festgenagelten Beiwagen hereingebracht hat (config-reloader im alloy-Chart diff --git a/monitoring/prometheus/alerts.yml b/monitoring/prometheus/alerts.yml index 949d6e4..d0594cf 100644 --- a/monitoring/prometheus/alerts.yml +++ b/monitoring/prometheus/alerts.yml @@ -200,6 +200,23 @@ groups: room: security annotations: summary: "cve/entscheidungen.json ist nicht lesbar — bis das behoben ist, zaehlt jeder CRITICAL als unentschieden" + # ⚠️ Die Regel, die am 2026-08-23 gefehlt hat. Ein k3s-Neustart legte + # kube-state-metrics und Alloy lahm, `kube_pod_container_info` war leer, + # die Herleitung nahm das als Erfolg - und die Zielmenge fiel von 54 auf + # 12. Der Scanner loeschte 42 Berichte, das Dashboard meldete Deckung 1,0. + # Keine der vier Regeln darueber schlug an: die Menge war nicht LEER, und + # die Zeitstempel waren FRISCH. Die Luecke sass genau dazwischen. + # + # ⚠️ Sie feuert auch bei einem gewollten Rueckbau. Das ist Absicht: ein + # Einbruch der Pruefmenge um 40 % ist immer eine Zeile wert, egal warum. + - alert: CveZielmengeEingebrochen + expr: cve_targets_desired_total < 0.6 * max_over_time(cve_targets_desired_total[24h]) + for: 15m + labels: + severity: warning + room: security + annotations: + summary: "Die CVE-Zielmenge ist auf {{ $value }} Ziele eingebrochen — ueber 40 % weniger als in den letzten 24 h. Faellt eine Quelle still aus, sieht das Dashboard danach BESSER aus, nicht schlechter." - 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 6eaa7cb..8146c3f 100644 --- a/monitoring/prometheus/alerts_test.yml +++ b/monitoring/prometheus/alerts_test.yml @@ -246,3 +246,44 @@ tests: - eval_time: 26h alertname: CveEntscheidungenUnlesbar exp_alerts: [] + + # --- CveZielmengeEingebrochen (#0051, Vorfall 2026-08-23) ------------------ + - interval: 1m + name: "Ein Einbruch der Zielmenge faellt auf" + input_series: + # Der echte Verlauf: 54 Ziele, dann faellt die Cluster-Quelle still aus. + - series: 'cve_targets_desired_total' + values: '54+0x120 12+0x120' + alert_rule_test: + - eval_time: 1h + alertname: CveZielmengeEingebrochen + exp_alerts: [] # noch bei 54 + - eval_time: 2h5m + alertname: CveZielmengeEingebrochen + exp_alerts: [] # eingebrochen, aber noch in der Karenz + - eval_time: 2h30m + alertname: CveZielmengeEingebrochen + exp_alerts: + - exp_labels: + severity: warning + room: security + exp_annotations: + summary: "Die CVE-Zielmenge ist auf 12 Ziele eingebrochen — ueber 40 % weniger als in den letzten 24 h. Faellt eine Quelle still aus, sieht das Dashboard danach BESSER aus, nicht schlechter." + # ⚠️ Gegenprobe zum Zusammenspiel: die vier aelteren Regeln schweigen in + # genau dieser Lage. Deshalb brauchte es eine fuenfte. + - eval_time: 2h30m + alertname: CveTargetsNoneDesired + exp_alerts: [] + - eval_time: 2h30m + alertname: CveTargetsMissing + exp_alerts: [] + + - interval: 1m + name: "Ein kleiner Rueckbau bleibt still" + input_series: + - series: 'cve_targets_desired_total' + values: '54+0x60 44+0x180' # -19 %, unter der Schwelle + alert_rule_test: + - eval_time: 3h + alertname: CveZielmengeEingebrochen + exp_alerts: []