Sixty-three criticals on running images have no fix to take. Writing them into a trivy ignore file would have been the obvious move and the wrong one: trivy drops ignored findings from its output, so afterwards 'zero because fixed' and 'zero because we looked away' render identically. Every finding stays in trivy_vuln_info. The decisions sit beside them in entscheidungen.json and are counted, not subtracted. Each entry names its CVEs one by one. A blanket entry per image would also swallow the next finding that shows up there, which is the finding you would most want to see. The loader rejects an entry without ids, and rejects a review date it cannot parse — rejecting the whole file, because a half-read decision list is worse than none. An unreadable file leaves everything counted as open. Getting that direction backwards would mean a typo reads as 'all decided', and nobody would notice. The end-to-end run caught the same mistake in the other half: when the derived target set is empty the set is unknown, not empty, so the open count now falls back to every report rather than to zero. Three python services move off the debian base while we are here — 3.13-slim carried four criticals with no fix, 3.13-alpine none. All three run on the stdlib alone and TLS was checked inside the image before the switch.
172 lines
7.6 KiB
Python
172 lines
7.6 KiB
Python
#!/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)
|