cve: a critical with nothing left to do is allowed, but only on the record

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.
This commit is contained in:
Thore Cimbal
2026-08-21 12:00:00 +00:00
parent 516641bbba
commit 44cc030f0b
7 changed files with 665 additions and 6 deletions
+35
View File
@@ -133,6 +133,41 @@ ein Ausfall der Herleitung von Erfolg nicht zu unterscheiden.
Deckung im Blick: `cve_target_coverage_ratio`, `cve_targets_missing`,
`cve_targets_orphaned` — im Dashboard *Security / CVE-Uebersicht*.
### Entschieden oder offen (#0051)
Ein CRITICAL, zu dem es nichts zu tun gibt, ist erlaubt — aber nur **benannt,
begruendet und mit Pruefdatum**. Diese Entscheidungen stehen in
`cve/entscheidungen.json`, je Eintrag ein Ziel mit den **einzelnen**
CVE-Kennungen:
```json
{ "ziel": "coturn/coturn:4.17.2", "art": "hingenommen",
"grund": "4.17.2 ist die neueste veroeffentlichte Fassung ...",
"pruefen_am": "2026-11-21", "cves": ["CVE-...", "..."] }
```
`art` ist `hingenommen` (es gibt nichts zu tun) oder `geplant` (die Arbeit steht
aus). Daraus zaehlt der Exporter `cve_critical_offen`,
`cve_critical_entschieden`, `cve_entscheidungen_abgelaufen` und
`cve_entscheidungen_ohne_befund`; vier Alarmregeln stehen darauf.
⚠️ **Bewusst keine Trivy-Ignore-Datei.** Trivy koennte die Befunde einfach
weglassen — dann saehe „0 CRITICAL, weil behoben" genauso aus wie „0 CRITICAL,
weil weggeschaut". Hier bleibt jeder Befund in `trivy_vuln_info` sichtbar;
entschieden heisst **gezaehlt, nicht versteckt**.
⚠️ **Pauschalen je Image sind nicht ausdrueckbar** (der Lader weist einen
Eintrag ohne CVE-Kennungen ab). Sonst verschluckte eine einmal getroffene
Entscheidung auch jeden **neuen** Befund an demselben Image.
⚠️ **Eine unlesbare Datei laesst alles als offen zaehlen**, nicht als
entschieden — die Richtung, die auffaellt. `CveEntscheidungenUnlesbar` nennt
den Grund sofort, `CveCriticalOffen` erst nach 24h.
Pruefen: `python3 cve/test_entscheidungen.py` (15 Zusicherungen, ohne Netz) und
`docker run --rm --entrypoint promtool -v "$PWD/prometheus:/p:ro"
prom/prometheus:v3.14.0 test rules /p/alerts_test.yml`.
### Alarm-Zustellung (frueher stummgeschaltet — seit gitops#51 wieder scharf)
**Stand 2026-08-15: Alarme werden zugestellt.** `alertmanager.yml` hat nur noch die
+86 -1
View File
@@ -30,6 +30,9 @@ PROZESSSTART = time.time()
# Ort: Container-Images laufen (bis auf Weiteres) alle auf dem MATRIX-Host;
# Host-rootfs-Scans (Ausbaustufe) bringen ihren Hostnamen im Dateinamen mit.
DEFAULT_HOST = os.environ.get("DEFAULT_HOST", "matrix")
# Entschiedene Befunde (#0051): CRITICAL, zu dem es nichts mehr zu tun gibt oder
# zu dem die Arbeit noch aussteht - mit Begruendung und Pruefdatum.
ENTSCHEIDUNGEN_FILE = os.environ.get("ENTSCHEIDUNGEN_FILE", "/app/entscheidungen.json")
try:
with open(STATE_FILE) as f:
@@ -42,6 +45,42 @@ def esc(v):
return str(v).replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ")
def lade_entscheidungen(pfad=None):
"""Entscheidungen -> {(ziel, cve): (art, ablauf_unix)} plus Fehlertext.
⚠️ Bewusst KEINE Unterdrueckung. Trivy koennte die Befunde ueber eine
Ignore-Datei einfach weglassen - dann saehe "0 CRITICAL, weil behoben"
genauso aus wie "0 CRITICAL, weil weggeschaut". Hier bleibt jeder Befund in
trivy_vuln_info stehen; entschieden wird er nur zusaetzlich gezaehlt.
⚠️ Ein Lesefehler gibt eine LEERE Menge zurueck und meldet ihn. Damit zaehlt
im Zweifel alles als offen - die Richtung, die auffaellt. Umgekehrt saehe
eine kaputte Datei aus wie "alles entschieden".
"""
pfad = pfad or ENTSCHEIDUNGEN_FILE
getroffen = {}
try:
with open(pfad) as fh:
doc = json.load(fh)
except FileNotFoundError:
return getroffen, "keine Datei"
except Exception as e:
return getroffen, f"unlesbar: {e}"
for e in doc.get("eintraege") or []:
ziel = ziel_herleitung.normalisiere(e.get("ziel", ""))
art = e.get("art", "?")
datum = e.get("pruefen_am", "")
try:
ablauf = time.mktime(time.strptime(datum, "%Y-%m-%d"))
except Exception:
return {}, f"Pruefdatum unbrauchbar bei {e.get('ziel')}: {datum!r}"
if not ziel or not e.get("cves"):
return {}, f"Eintrag ohne Ziel oder ohne CVE-Kennung: {e.get('ziel')!r}"
for cve in e["cves"]:
getroffen[(ziel, cve)] = (art, ablauf)
return getroffen, None
class ZielCache:
"""Haelt die zuletzt gebildete Soll-Menge und erneuert sie im eigenen Takt.
@@ -100,6 +139,7 @@ def collect():
gelesene_targets = set() # nur DEREN Erstfunde duerfen geprunt werden
berichte = 0
lesefehler = 0
kritisch = set() # (normalisiertes Ziel, CVE) - Grundlage fuer "offen"
for fn in sorted(os.listdir(RESULTS)):
if not fn.endswith(".json"):
continue
@@ -129,6 +169,8 @@ def collect():
counts[sev] = counts.get(sev, 0) + 1
if sev in ("HIGH", "CRITICAL"):
cve = v.get("VulnerabilityID", "?")
if sev == "CRITICAL":
kritisch.add((ziel_herleitung.normalisiere(target), cve))
key = f"{cve}|{target}"
if key not in first_seen:
first_seen[key] = now
@@ -186,6 +228,44 @@ def collect():
lines.append(
f"cve_target_coverage_ratio {len(soll & ist) / len(soll) if soll else 0}"
)
# --- Entschieden oder offen (#0051, Kriterien 1 und 5) -------------------
# Kriterium 1 lautet "kein CRITICAL in einem laufenden Image OHNE
# Entscheidung". Das ist erst eine Zahl, wenn beide Seiten zaehlbar sind:
# die Befunde (oben) und die Entscheidungen (hier).
getroffen, fehler = lade_entscheidungen()
if fehler:
print(f"entscheidungen: {fehler}", flush=True)
# ⚠️ Nur Befunde auf Zielen, die WIRKLICH betrieben werden. Ein verwaister
# Bericht (Image abgeloest, Runde noch nicht gelaufen) darf die Zahl nicht
# hochhalten - sonst sieht ein erfolgreiches Update wie Stillstand aus.
#
# ⚠️ ABER: Ist die Soll-Menge leer, ist sie nicht "leer", sondern UNBEKANNT -
# die Herleitung ist ausgefallen. Dann darf hier nicht 0 herauskommen, denn
# 0 liest sich wie "nichts offen". In dem Fall zaehlen alle Berichte, auch
# verwaiste: lieber zu viel melden als im Ausfall Entwarnung geben.
laufend = {p for p in kritisch if p[0] in soll} if soll else set(kritisch)
offen = laufend - set(getroffen)
lines.append(f"cve_critical_offen {len(offen)}")
lines.append(f"cve_critical_entschieden {len(laufend & set(getroffen))}")
lines.append(f"cve_entscheidungen_lesefehler {1 if fehler else 0}")
abgelaufen = 0
ohne_befund = 0
for (ziel, cve), (art, ablauf) in sorted(getroffen.items()):
lines.append(
f'cve_entscheidung_gueltig_bis{{target="{esc(ziel)}",cve="{esc(cve)}",art="{esc(art)}"}} {int(ablauf)}'
)
if now > ablauf:
abgelaufen += 1
# ⚠️ 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:
ohne_befund += 1
lines.append(f"cve_entscheidungen_abgelaufen {abgelaufen}")
lines.append(f"cve_entscheidungen_ohne_befund {ohne_befund}")
lines.append(f"cve_entscheidungen_total {len(getroffen)}")
try:
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
with open(STATE_FILE, "w") as f:
@@ -209,4 +289,9 @@ class Handler(BaseHTTPRequestHandler):
pass
ThreadingHTTPServer(("0.0.0.0", 9101), Handler).serve_forever()
# ⚠️ Unter __main__, damit test_entscheidungen.py die ECHTE Datei importieren
# kann statt eine zurechtgeschnittene Kopie. Ohne die Wache startet beim
# Import der Server, und der Test haette einen Nachbau geprueft - genau die
# Fehlerklasse, die scan-loop.sh am 2026-08-21 schon einmal gekostet hat.
if __name__ == "__main__":
ThreadingHTTPServer(("0.0.0.0", 9101), Handler).serve_forever()
+212
View File
@@ -0,0 +1,212 @@
{
"_hinweis": "Entschiedene CRITICAL-Befunde (#0051, Abnahmekriterien 1 und 5).\nJEDER Eintrag nennt die CVE-Kennungen EINZELN - eine Pauschale je Image wuerde\nauch jeden NEUEN Befund verschlucken, und genau das soll nicht passieren.\n'pruefen_am' ist bindend: der cve-exporter zaehlt abgelaufene Entscheidungen,\nder Alarm CveEntscheidungAbgelaufen meldet sie. art=hingenommen heisst 'es gibt\nnichts zu tun', art=geplant heisst 'die Arbeit steht aus'.\n⚠️ Nichts hiervon wird unterdrueckt: die Befunde bleiben in trivy_vuln_info\nsichtbar. Entschieden heisst gezaehlt, nicht versteckt.",
"eintraege": [
{
"ziel": "coturn/coturn:4.17.2",
"art": "hingenommen",
"grund": "4.17.2 ist die neueste veroeffentlichte Fassung (danach nur 'edge'). Alle Befunde sitzen ohne Fix-Fassung in libxml2, perl-base, liblmdb0 und mariadb-common der Debian-Basis - es gibt kein Image, das sie schliesst. coturn laeuft mit hostNetwork ausserhalb der NetworkPolicy und ist ueber die Hetzner-Firewall abgeschottet.",
"pruefen_am": "2026-11-21",
"cves": [
"CVE-2019-16224",
"CVE-2019-16225",
"CVE-2019-16227",
"CVE-2026-13221",
"CVE-2026-42496",
"CVE-2026-44172",
"CVE-2026-49261",
"CVE-2026-57433",
"CVE-2026-6653",
"CVE-2026-8376"
]
},
{
"ziel": "ghcr.io/goauthentik/server:2026.8.0",
"art": "hingenommen",
"grund": "Heute erst auf die neueste Fassung gehoben (2026.2.3 -> 2026.8.0, -22 CRITICAL). Die verbliebenen fuenf sitzen in libxml2 und perl-base der Basis und haben keine Fix-Fassung.",
"pruefen_am": "2026-11-21",
"cves": [
"CVE-2026-13221",
"CVE-2026-42496",
"CVE-2026-57433",
"CVE-2026-6653",
"CVE-2026-8376"
]
},
{
"ziel": "ghcr.io/requarks/wiki:2.5",
"art": "hingenommen",
"grund": "GEMESSEN, nicht vermutet: das neuere Release-Tag 2.5.277 traegt 40 CRITICAL statt 12. Das Update waere eine Verschlechterung. Wiki.js 3 ist ein eigenes Vorhaben.",
"pruefen_am": "2026-10-21",
"cves": [
"CVE-2021-23358",
"CVE-2023-45133",
"CVE-2025-54419",
"CVE-2025-7783",
"CVE-2026-28292",
"CVE-2026-33845",
"CVE-2026-42010",
"CVE-2026-59873"
]
},
{
"ziel": "gnuxie/draupnir:v3.1.0",
"art": "hingenommen",
"grund": "v3.1.0 ist die neueste Release; 'develop' ist keine. Die fuenf mit Fix-Fassung sind Debian- und npm-Pakete, die das veroeffentlichte Image nicht neu gebaut hat - 'Paket hat einen Fix' heisst nicht 'es gibt ein Image damit'.",
"pruefen_am": "2026-11-21",
"cves": [
"CVE-2021-23358",
"CVE-2023-45853",
"CVE-2025-7783",
"CVE-2026-13221",
"CVE-2026-33845",
"CVE-2026-42010",
"CVE-2026-42496",
"CVE-2026-57433",
"CVE-2026-59873",
"CVE-2026-8376"
]
},
{
"ziel": "portainer/agent:2.27.5",
"art": "geplant",
"grund": "GEMESSEN: 2.44.0 traegt 0 CRITICAL. Der Agent wird nicht aus einem unserer Repos ausgerollt, die Aktualisierung liegt bei sorb auf dem Betriebs-Host.",
"pruefen_am": "2026-09-21",
"cves": [
"CVE-2025-68121",
"CVE-2026-33186"
]
},
{
"ziel": "postgres:15-alpine",
"art": "hingenommen",
"grund": "Datenbank-Client der Wiki-Gruppenpruefung (#0103), schwebendes Tag auf der neuesten 15er-Fassung. Der eine Befund ist die Go-stdlib des Basis-Images - nur ein Neubau des Images schliesst ihn.",
"pruefen_am": "2026-09-21",
"cves": [
"CVE-2025-68121"
]
},
{
"ziel": "postgres:16-alpine",
"art": "hingenommen",
"grund": "Wiki.js-Datenbank, schwebendes Tag auf der neuesten 16er-Fassung. Befund wie bei 15-alpine in der Go-stdlib des Basis-Images.",
"pruefen_am": "2026-09-21",
"cves": [
"CVE-2025-68121"
]
},
{
"ziel": "postgres:17-alpine",
"art": "hingenommen",
"grund": "Datenbank der ESS-Stacks, Fassung vom Chart 26.8.0 gesetzt - heute erst aktualisiert. Der eine Befund ist die Go-stdlib des Basis-Images; ein Neubau des Images schliesst ihn, ein Tag-Wechsel nicht.",
"pruefen_am": "2026-11-21",
"cves": [
"CVE-2025-68121"
]
},
{
"ziel": "postgres:17.11-bookworm",
"art": "hingenommen",
"grund": "Neueste 17er-Bookworm-Fassung; kommt als Chart-Abhaengigkeit von Authentik mit und ist beim Sprung heute selbst von 17.9 auf 17.11 mitgewandert. Die Befunde sitzen in libxml2, libsqlite3, perl-modules und zlib der Debian-Basis. Ein Sprung auf 18 waere ein Datenbank-Major, kein CVE-Schritt.",
"pruefen_am": "2026-11-21",
"cves": [
"CVE-2023-45853",
"CVE-2025-68121",
"CVE-2025-7458",
"CVE-2026-13221",
"CVE-2026-42496",
"CVE-2026-57433",
"CVE-2026-6653",
"CVE-2026-8376"
]
},
{
"ziel": "prometheuscommunity/postgres-exporter:v0.18.1",
"art": "hingenommen",
"grund": "Beiwagen im ESS-Postgres, Fassung vom Chart 26.8.0 gesetzt. GEMESSEN: v0.20.1 traegt 0 CRITICAL - die Fassung hier zu ueberschreiben hiesse aber, an einer Stelle vom Chart abzuweichen, die der naechste Chart-Sprung wieder einholt. Beim naechsten ESS-Sprung pruefen, ob er sie mitbringt.",
"pruefen_am": "2026-11-21",
"cves": [
"CVE-2025-68121"
]
},
{
"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:v2",
"art": "geplant",
"grund": "Eigenes Image; ein Befund in der Go-stdlib. Faellt mit dem naechsten Neubau weg.",
"pruefen_am": "2026-09-21",
"cves": [
"CVE-2025-68121"
]
},
{
"ziel": "rohana.axion1337.de/sorb/clamav-http-scanner:v1.0.0",
"art": "geplant",
"grund": "Eigenes Image auf Debian-Basis; die vier Befunde sind perl-base ohne Fix-Fassung. Faellt mit einem Neubau auf einer schlankeren Basis weg - dieselbe Umstellung, die die drei Python-Dienste des Betriebs-Stacks heute auf alpine gebracht hat.",
"pruefen_am": "2026-09-21",
"cves": [
"CVE-2026-13221",
"CVE-2026-42496",
"CVE-2026-57433",
"CVE-2026-8376"
]
}
]
}
+171
View File
@@ -0,0 +1,171 @@
#!/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)
+19 -3
View File
@@ -45,7 +45,11 @@ services:
# Alertmanager-Webhook -> Matrix (wartung-Raum), gleiche Machart wie
# maintenance-notify (gitops Issue #24). Secrets kommen aus .env.
matrix-alerts:
image: python:3.13-slim
# 3.13-slim trug 4 CRITICAL (Debian-Basis, ohne Fix in der Fassung);
# 3.13-alpine gemessen am 2026-08-21: 0 CRITICAL, 2 HIGH. Alle drei
# Dienste hier laufen rein auf der stdlib, TLS gegen /etc/ssl/cert.pem
# im Image geprueft - kein Paket, das musl stoeren koennte.
image: python:3.13-alpine
container_name: matrix-alerts
restart: unless-stopped
environment:
@@ -71,7 +75,11 @@ services:
# Upstreams in den Alerts-Raum (🚨 bei Security-Verdacht). Gleicher Bot/Raum
# wie matrix-alerts, eigener State (Erstlauf merkt nur, flutet nicht).
release-watch:
image: python:3.13-slim
# 3.13-slim trug 4 CRITICAL (Debian-Basis, ohne Fix in der Fassung);
# 3.13-alpine gemessen am 2026-08-21: 0 CRITICAL, 2 HIGH. Alle drei
# Dienste hier laufen rein auf der stdlib, TLS gegen /etc/ssl/cert.pem
# im Image geprueft - kein Paket, das musl stoeren koennte.
image: python:3.13-alpine
container_name: release-watch
restart: unless-stopped
environment:
@@ -107,7 +115,11 @@ services:
# Teil 2: Reports -> Prometheus-Metriken (Schema + Pflichtfelder siehe gitops#47)
cve-exporter:
image: python:3.13-slim
# 3.13-slim trug 4 CRITICAL (Debian-Basis, ohne Fix in der Fassung);
# 3.13-alpine gemessen am 2026-08-21: 0 CRITICAL, 2 HIGH. Alle drei
# Dienste hier laufen rein auf der stdlib, TLS gegen /etc/ssl/cert.pem
# im Image geprueft - kein Paket, das musl stoeren koennte.
image: python:3.13-alpine
container_name: cve-exporter
restart: unless-stopped
environment:
@@ -125,6 +137,10 @@ services:
# Nicht je Scrape herleiten: bei 15 s waeren das ~21 000
# Registry-Anfragen am Tag; eine Runde kostet ~90.
- SOLL_INTERVALL_SEKUNDEN=3600
# Entschiedene CRITICAL-Befunde (#0051). Liegt im selben ./cve-Ordner,
# der hier als /app eingehaengt ist - ausdruecklich gesetzt, damit man
# die Datei findet, ohne den Vorgabewert im Quelltext zu suchen.
- ENTSCHEIDUNGEN_FILE=/app/entscheidungen.json
volumes:
- ./cve:/app:ro
- cve_results:/results:ro
+42
View File
@@ -158,6 +158,48 @@ groups:
# Ausfall, den das Issue beschreibt. Metriken kommen aus kube-state-metrics im
# Matrix-Cluster (Alloy -> remote_write, kube_job_*/kube_cronjob_* passieren den
# Filter, der nur go_.*|process_.* verwirft).
# --- Entschieden oder offen (#0051) -------------------------------
# ⚠️ Diese vier bewachen eine ENTSCHEIDUNG, kein Werkzeug. Ein CRITICAL,
# zu dem es nichts zu tun gibt, ist erlaubt - aber nur benannt, begruendet
# und mit Pruefdatum. Ohne diese Regeln waere 'entschieden' ein Wort in
# einer Datei, das mit der Zeit still zu 'vergessen' wird.
- alert: CveCriticalOffen
expr: cve_critical_offen > 0
for: 24h
labels:
severity: warning
room: security
annotations:
summary: "{{ $value }} CRITICAL auf laufenden Images ohne Entscheidung — weder behoben noch mit Begruendung und Pruefdatum in cve/entscheidungen.json. Details: https://selendis.axion1337.de/d/cve-overview"
# Der Ablauf ist der Sinn der Sache: eine Entscheidung ohne Verfallsdatum
# ist keine Entscheidung, sondern Wegschauen mit Zusatzschritt.
- alert: CveEntscheidungAbgelaufen
expr: cve_entscheidungen_abgelaufen > 0
for: 6h
labels:
severity: warning
room: security
annotations:
summary: "{{ $value }} CVE-Entscheidung(en) haben ihr Pruefdatum ueberschritten — die Begruendung von damals gilt bis zum Gegenbeweis nicht mehr"
- alert: CveEntscheidungOhneBefund
expr: cve_entscheidungen_ohne_befund > 0
for: 24h
labels:
severity: info
room: security
annotations:
summary: "{{ $value }} CVE-Entscheidung(en) treffen auf keinen Befund mehr — Altpapier in cve/entscheidungen.json, das kuenftig Befunde decken koennte, die niemand geprueft hat"
# ⚠️ Eine unlesbare Datei laesst ALLES als offen zaehlen (so gebaut). Das
# faellt ueber CveCriticalOffen auf - aber erst nach 24h und ohne den Grund
# zu nennen. Diese Regel nennt ihn sofort.
- alert: CveEntscheidungenUnlesbar
expr: cve_entscheidungen_lesefehler > 0
for: 30m
labels:
severity: warning
room: security
annotations:
summary: "cve/entscheidungen.json ist nicht lesbar — bis das behoben ist, zaehlt jeder CRITICAL als unentschieden"
- name: axion-backup
rules:
# Ein Backup- oder Probe-Job ist fehlgeschlagen.
+100 -2
View File
@@ -1,6 +1,6 @@
# Regeltests fuer die Deckungs-Alarme (#0106).
# Regeltests fuer die Deckungs-Alarme (#0106) und die Entscheidungs-Alarme (#0051).
# docker run --rm --entrypoint promtool -v "$PWD/prometheus:/p:ro" \
# prom/prometheus:v3.3.1 test rules /p/alerts_test.yml
# prom/prometheus:v3.14.0 test rules /p/alerts_test.yml
#
# ⚠️ ZWECK: Eine Regel, die nur je gruen war, ist eine Vermutung. In diesem
# Projekt ist "meldet Erfolg, ist aber blind" die haeufigste Fehlerklasse
@@ -148,3 +148,101 @@ tests:
- eval_time: 7h
alertname: CveTargetSourceStale
exp_alerts: []
# --- CveCriticalOffen (#0051) ---------------------------------------------
- interval: 1m
name: "Ein CRITICAL ohne Entscheidung faellt nach 24h auf"
input_series:
- series: 'cve_critical_offen'
values: '3+0x1800'
alert_rule_test:
- eval_time: 20h
alertname: CveCriticalOffen
exp_alerts: [] # Karenz: ein frisch entdeckter Befund darf
# einen Arbeitstag lang unentschieden sein
- eval_time: 26h
alertname: CveCriticalOffen
exp_alerts:
- exp_labels:
severity: warning
room: security
exp_annotations:
summary: "3 CRITICAL auf laufenden Images ohne Entscheidung — weder behoben noch mit Begruendung und Pruefdatum in cve/entscheidungen.json. Details: https://selendis.axion1337.de/d/cve-overview"
# --- CveEntscheidungAbgelaufen --------------------------------------------
# ⚠️ Der eigentliche Zweck der Entscheidungsdatei. Ohne diese Regel wuerde
# aus "wir haben uns das angesehen und es ist vertretbar" nach ein paar
# Monaten still "das steht halt so in der Datei".
- interval: 1m
name: "Ein ueberschrittenes Pruefdatum meldet sich"
input_series:
- series: 'cve_entscheidungen_abgelaufen'
values: '0+0x60 1+0x600' # erst alles gueltig, dann laeuft eine ab
alert_rule_test:
- eval_time: 1h
alertname: CveEntscheidungAbgelaufen
exp_alerts: [] # noch nichts abgelaufen
- eval_time: 8h
alertname: CveEntscheidungAbgelaufen
exp_alerts:
- exp_labels:
severity: warning
room: security
exp_annotations:
summary: "1 CVE-Entscheidung(en) haben ihr Pruefdatum ueberschritten — die Begruendung von damals gilt bis zum Gegenbeweis nicht mehr"
# --- CveEntscheidungenUnlesbar --------------------------------------------
- interval: 1m
name: "Eine kaputte Entscheidungsdatei nennt sich selbst"
input_series:
- series: 'cve_entscheidungen_lesefehler'
values: '1+0x120'
# Gleichzeitig zaehlt alles als offen - so ist der Exporter gebaut.
- series: 'cve_critical_offen'
values: '63+0x120'
alert_rule_test:
- eval_time: 15m
alertname: CveEntscheidungenUnlesbar
exp_alerts: [] # Karenz 30m
- eval_time: 45m
alertname: CveEntscheidungenUnlesbar
exp_alerts:
- exp_labels:
severity: warning
room: security
exp_annotations:
summary: "cve/entscheidungen.json ist nicht lesbar — bis das behoben ist, zaehlt jeder CRITICAL als unentschieden"
# ⚠️ Gegenprobe zum Zusammenspiel: CveCriticalOffen schweigt hier noch
# (Karenz 24h). Ohne die Regel darueber bliebe der wahre Grund also
# einen ganzen Tag lang unsichtbar.
- eval_time: 45m
alertname: CveCriticalOffen
exp_alerts: []
# --- Gegenprobe: entschiedener Bestand bleibt still ------------------------
- interval: 1m
name: "Alles entschieden und gueltig bleibt still"
input_series:
- series: 'cve_critical_offen'
values: '0+0x1800'
- series: 'cve_critical_entschieden'
values: '63+0x1800'
- series: 'cve_entscheidungen_abgelaufen'
values: '0+0x1800'
- series: 'cve_entscheidungen_ohne_befund'
values: '0+0x1800'
- series: 'cve_entscheidungen_lesefehler'
values: '0+0x1800'
alert_rule_test:
- eval_time: 26h
alertname: CveCriticalOffen
exp_alerts: []
- eval_time: 26h
alertname: CveEntscheidungAbgelaufen
exp_alerts: []
- eval_time: 26h
alertname: CveEntscheidungOhneBefund
exp_alerts: []
- eval_time: 26h
alertname: CveEntscheidungenUnlesbar
exp_alerts: []