cve: a successful query can still be worthless, and it cost 42 reports

Restarting k3s took kube-state-metrics and alloy's log tailers down with it, so
kube_pod_container_info went empty in prometheus. The derivation asked, got a
clean response with zero rows, and counted it as success. Cluster targets went
from 39 to none, the total from 54 to 12, and the scan loop deleted every report
whose target had vanished. Coverage then read 1.0.

None of the four rules fired, and each for a defensible reason: the set was not
empty because the operating host still answered, and every timestamp was fresh
because an empty success updates it. The gap sat exactly between them.

A source that has delivered before and now delivers nothing is treated as a
failure: its previous targets are kept, its timestamp ages, and the stale rule
takes over. A fifth rule watches the total for a drop of more than 40 percent,
and it deliberately also fires on a deliberate shrink — losing 40 percent of the
checked estate is worth a line either way.

The six rancher decisions are gone too. They described versions that no longer
run: the k3s patch took all fifteen of their criticals with it.
This commit is contained in:
Thore Cimbal
2026-08-21 12:00:00 +00:00
parent c7aaf388f5
commit 9a3b1d1dd5
7 changed files with 171 additions and 64 deletions
+13
View File
@@ -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.
+3 -1
View File
@@ -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():
-59
View File
@@ -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",
+26 -4
View File
@@ -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"]))
+71
View File
@@ -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
+17
View File
@@ -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.
+41
View File
@@ -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: []