diff --git a/monitoring/cve/cve-exporter.py b/monitoring/cve/cve-exporter.py index 46cfb2c..1e28cfe 100644 --- a/monitoring/cve/cve-exporter.py +++ b/monitoring/cve/cve-exporter.py @@ -261,7 +261,19 @@ def collect(): # ⚠️ 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: + # + # ⚠️ KEIN "und ziel in soll" mehr. Genau diese Bedingung stand hier bis + # zum 2026-08-22 und machte den Zaehler blind fuer den haeufigsten Fall: + # Ist das Image ganz aus dem Bestand gefallen (Fassungssprung), faellt + # auch seine Entscheidung aus der Pruefung - und bleibt fuer immer + # unbemerkt in der Datei stehen. Belegt am selben Tag: die Eintraege zu + # grafana 12.0.0 und 12.4.9 ueberlebten den Sprung auf 13.2.0, und der + # Zaehler meldete 0. + # + # Der Preis ist bekannt und tragbar: Wer eine Entscheidung trifft, BEVOR + # der Scanner das Image erreicht, sieht sie bis zur naechsten Runde hier + # auftauchen. Die Alarmregel wartet 24h und deckt das ab. + if (ziel, cve) not in laufend: ohne_befund += 1 lines.append(f"cve_entscheidungen_abgelaufen {abgelaufen}") lines.append(f"cve_entscheidungen_ohne_befund {ohne_befund}") diff --git a/monitoring/cve/entscheidungen.json b/monitoring/cve/entscheidungen.json index b2e7a01..11dfa35 100644 --- a/monitoring/cve/entscheidungen.json +++ b/monitoring/cve/entscheidungen.json @@ -90,29 +90,6 @@ "CVE-2026-8376" ] }, - { - "ziel": "grafana/grafana:12.0.0", - "art": "geplant", - "grund": "12.4.9 liegt im Repo (516641b) und ist GEMESSEN: 7 CRITICAL -> 1, 70 HIGH -> 3. Der Major 13.2.0 wurde nach Messung verworfen (HIGH 70 -> 162). Wirkt mit dem Ausrollen auf dem Betriebs-Host.", - "pruefen_am": "2026-08-28", - "cves": [ - "CVE-2025-68121", - "CVE-2026-31789", - "CVE-2026-33186", - "CVE-2026-33815", - "CVE-2026-33816", - "GHSA-r277-6w6q-xmqw" - ] - }, - { - "ziel": "grafana/grafana:12.4.9", - "art": "hingenommen", - "grund": "Die Fassung, auf die wir heute gehoben haben (7 CRITICAL -> 1). Der eine Rest ist GHSA-r277-6w6q-xmqw in github.com/getkin/kin-openapi v0.133.0, einer mitgelieferten Go-Abhaengigkeit - behoben in 0.144.0, aber Grafana muss sie ziehen, wir koennen es nicht. Der Major 13.2.0 wurde gemessen verworfen (HIGH 70 -> 162). Beim naechsten 12er-Patch nachsehen.", - "pruefen_am": "2026-09-21", - "cves": [ - "GHSA-r277-6w6q-xmqw" - ] - }, { "ziel": "oci.element.io/synapse:v1.158.0", "art": "hingenommen", diff --git a/monitoring/cve/test_entscheidungen.py b/monitoring/cve/test_entscheidungen.py index 1a1b2d9..028213b 100644 --- a/monitoring/cve/test_entscheidungen.py +++ b/monitoring/cve/test_entscheidungen.py @@ -138,6 +138,71 @@ class RechnungTest(unittest.TestCase): self.assertEqual(offen, 1) +class CollectTest(unittest.TestCase): + """⚠️ Diese Klasse prueft die ECHTE collect(), nicht die nachgebaute Rechnung + in RechnungTest. Der Unterschied ist an diesem Projekt schon einmal teuer + geworden: Am 2026-08-22 stand in collect() ein `and ziel in soll`, das den + Zaehler fuer verwaiste Entscheidungen blind machte, sobald ein Image ganz aus + dem Bestand fiel - und RechnungTest konnte das nicht sehen, weil dort die + Bedingung gar nicht stand. + """ + + def laufe(self, berichte, entscheidungen, soll): + res = tempfile.mkdtemp() + for i, (ziel, cves) in enumerate(berichte): + json.dump({"ArtifactName": ziel, "ArtifactType": "container_image", + "Results": [{"Vulnerabilities": [ + {"VulnerabilityID": c, "Severity": "CRITICAL", "PkgName": "p", + "InstalledVersion": "1", "FixedVersion": ""} for c in cves]}]}, + open(os.path.join(res, f"b{i}.json"), "w")) + pfad = schreibe({"eintraege": entscheidungen}) + alt_res, alt_ent = exporter.RESULTS, exporter.ENTSCHEIDUNGEN_FILE + exporter.RESULTS, exporter.ENTSCHEIDUNGEN_FILE = res, pfad + exporter.ziel_cache._letzte = exporter.ziel_herleitung.Herleitung( + ziele=set(soll), je_quelle={"cluster": set(soll)}, fehler={}, + stand={"cluster": exporter.time.time()}) + exporter.ziel_cache._geholt_um = exporter.time.time() + try: + zeilen = exporter.collect().splitlines() + finally: + exporter.RESULTS, exporter.ENTSCHEIDUNGEN_FILE = alt_res, alt_ent + self.zeilen = zeilen + return {z.split(" ")[0]: z.split(" ")[1] for z in zeilen if " " in z and "{" not in z} + + E = [{"ziel": "a:1", "art": "hingenommen", "grund": "x" * 70, + "pruefen_am": "2099-01-01", "cves": ["CVE-1"]}] + + def test_entschieden_und_offen_werden_getrennt_gezaehlt(self): + m = self.laufe([("a:1", ["CVE-1", "CVE-2"])], self.E, ["a:1"]) + self.assertEqual(m["cve_critical_entschieden"], "1") + self.assertEqual(m["cve_critical_offen"], "1") + self.assertEqual(m["cve_entscheidungen_ohne_befund"], "0") + + def test_entscheidung_zu_einem_VERSCHWUNDENEN_image_faellt_auf(self): + # Der Fall vom 2026-08-22: das Image ist ersetzt, das Ziel steht nicht + # mehr im Soll, der Bericht ist weg. Die Entscheidung ist Altpapier und + # muss sich melden - vorher meldete sie sich NICHT. + m = self.laufe([("b:1", [])], self.E, ["b:1"]) + self.assertEqual(m["cve_entscheidungen_ohne_befund"], "1") + + def test_gegenprobe_eine_gebrauchte_entscheidung_meldet_sich_nicht(self): + m = self.laufe([("a:1", ["CVE-1"])], self.E, ["a:1"]) + self.assertEqual(m["cve_entscheidungen_ohne_befund"], "0") + self.assertEqual(m["cve_critical_offen"], "0") + + def test_der_entschiedene_befund_bleibt_SICHTBAR(self): + # Der Kern der Entscheidung gegen eine Trivy-Ignore-Datei: entschieden + # heisst gezaehlt, nicht versteckt. Waere er unterdrueckt, fehlte diese + # Serie - und "0, weil behoben" saehe aus wie "0, weil weggeschaut". + m = self.laufe([("a:1", ["CVE-1"])], self.E, ["a:1"]) + self.assertEqual(m["cve_critical_entschieden"], "1") + treffer = [z for z in self.zeilen if z.startswith("trivy_vuln_info{") and "CVE-1" in z] + self.assertEqual(len(treffer), 1, "der entschiedene Befund fehlt in trivy_vuln_info") + # Gegenprobe: ein Ziel ohne Befund erzeugt auch keine Serie. + self.laufe([("a:1", [])], self.E, ["a:1"]) + self.assertFalse([z for z in self.zeilen if z.startswith("trivy_vuln_info{")]) + + class EchteDateiTest(unittest.TestCase): def setUp(self): self.getroffen, self.fehler = exporter.lade_entscheidungen(ECHT)