#!/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)