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:
@@ -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.
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: []
|
||||
|
||||
Reference in New Issue
Block a user