diff --git a/monitoring/README.md b/monitoring/README.md index 5c56179..76272b4 100644 --- a/monitoring/README.md +++ b/monitoring/README.md @@ -117,13 +117,21 @@ Soll-Menge bei jeder Herleitung neu aus drei Quellen und schreibt sie nach | Quelle | woher | |---|---| -| Cluster | `kube_pod_container_info` aus dem eigenen Prometheus | +| Cluster | `kube_pod_container_info` aus dem eigenen Prometheus — **`image_spec`, nicht `image`** (siehe Warnung unten) | | Betriebs-Host | `container_last_seen{job="operating_cadvisor"}` — schliesst `gameserver_cadvisor` aus | | Registry | rohana `/v2`, anonymer Token; die letzten drei Fassungen je Repo | Ein Ziel, das aus der Menge faellt, verliert seinen Bericht — sonst meldete ein ausgemustertes Image weiter. +⚠️ **Ein per Digest festgenagelter Container (`repo:tag@sha256:...`) traegt in +`kube_pod_container_info` unter `image` nur den nackten `sha256:...`.** Die +brauchbare Referenz steht allein in `image_spec`. Am 2026-08-21 kam mit dem +alloy-Chart 1.x genau so ein Beiwagen herein; ohne diesen Zusatz waere er still +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. + ⚠️ **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. diff --git a/monitoring/cve/entscheidungen.json b/monitoring/cve/entscheidungen.json index 28eb364..77d5682 100644 --- a/monitoring/cve/entscheidungen.json +++ b/monitoring/cve/entscheidungen.json @@ -1,6 +1,17 @@ { "_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": "aquasec/trivy:0.58.2", + "art": "geplant", + "grund": "0.74.0 liegt im Repo (516641b). Der Scanner selbst - eine veraltete Fassung heisst auch veraltete Erkennung. Wirkt mit dem Ausrollen auf dem Betriebs-Host.", + "pruefen_am": "2026-08-28", + "cves": [ + "CVE-2025-68121", + "CVE-2026-31789", + "CVE-2026-33186" + ] + }, { "ziel": "coturn/coturn:4.17.2", "art": "hingenommen", @@ -19,6 +30,19 @@ "CVE-2026-8376" ] }, + { + "ziel": "gcr.io/cadvisor/cadvisor:v0.49.1", + "art": "geplant", + "grund": "v0.55.1 liegt im Repo (516641b), gemessen 5 CRITICAL -> 4. Wirkt mit dem Ausrollen auf dem Betriebs-Host.", + "pruefen_am": "2026-08-28", + "cves": [ + "CVE-2024-24790", + "CVE-2024-37371", + "CVE-2024-41110", + "CVE-2025-68121", + "CVE-2026-33186" + ] + }, { "ziel": "ghcr.io/goauthentik/server:2026.8.0", "art": "hingenommen", @@ -66,6 +90,20 @@ "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": "portainer/agent:2.27.5", "art": "geplant", @@ -119,6 +157,34 @@ "CVE-2026-8376" ] }, + { + "ziel": "prom/alertmanager:v0.28.1", + "art": "geplant", + "grund": "v0.34.0 liegt im Repo (516641b), gemessen 0 CRITICAL. Wirkt mit dem Ausrollen auf dem Betriebs-Host.", + "pruefen_am": "2026-08-28", + "cves": [ + "CVE-2025-68121" + ] + }, + { + "ziel": "prom/node-exporter:v1.9.1", + "art": "geplant", + "grund": "v1.12.1 liegt im Repo (516641b), gemessen 0 CRITICAL. Wirkt mit dem Ausrollen auf dem Betriebs-Host.", + "pruefen_am": "2026-08-28", + "cves": [ + "CVE-2025-68121" + ] + }, + { + "ziel": "prom/prometheus:v3.3.1", + "art": "geplant", + "grund": "v3.14.0 liegt im Repo (516641b), gemessen 0 CRITICAL. Wirkt mit dem Ausrollen auf dem Betriebs-Host.", + "pruefen_am": "2026-08-28", + "cves": [ + "CVE-2025-68121", + "CVE-2026-33186" + ] + }, { "ziel": "prometheuscommunity/postgres-exporter:v0.18.1", "art": "hingenommen", @@ -128,6 +194,18 @@ "CVE-2025-68121" ] }, + { + "ziel": "python:3.13-slim", + "art": "geplant", + "grund": "Alle drei Python-Dienste stehen im Repo (44cc030) auf python:3.13-alpine, gemessen 4 CRITICAL -> 0; TLS im Image geprueft. Wirkt mit dem Ausrollen auf dem Betriebs-Host.", + "pruefen_am": "2026-08-28", + "cves": [ + "CVE-2026-13221", + "CVE-2026-42496", + "CVE-2026-57433", + "CVE-2026-8376" + ] + }, { "ziel": "rancher/klipper-helm:v0.9.14-build20260309", "art": "hingenommen", @@ -187,6 +265,15 @@ "CVE-2026-33186" ] }, + { + "ziel": "rohana.axion1337.de/sorb/axion-backup:v1", + "art": "hingenommen", + "grund": "Vorgaengerfassung des eigenen Backup-Images, absichtlich in der Zielmenge: sie ist das Rueckfallziel zu v2 und wird deshalb mitgeprueft, obwohl sie nicht laeuft. Derselbe Go-stdlib-Befund wie in v2; faellt mit dem naechsten Neubau weg.", + "pruefen_am": "2026-09-21", + "cves": [ + "CVE-2025-68121" + ] + }, { "ziel": "rohana.axion1337.de/sorb/axion-backup:v2", "art": "geplant", diff --git a/monitoring/cve/targets.py b/monitoring/cve/targets.py index d272e43..ca91786 100644 --- a/monitoring/cve/targets.py +++ b/monitoring/cve/targets.py @@ -29,7 +29,14 @@ from typing import NamedTuple, Optional # --- Quellen-Ausdruecke ------------------------------------------------------ # Beide zaehlen nur, um die Beschriftung `image` zu bekommen; der Wert ist egal. -AUSDRUCK_CLUSTER = "count by (image) (kube_pod_container_info)" +# ⚠️ image_spec MUSS mit abgefragt werden. Ist ein Container per Digest +# festgenagelt (`repo:tag@sha256:...`, wie der config-reloader im alloy-Chart +# ab 1.x), traegt `image` nur noch den nackten `sha256:...` - als Scanziel +# unbrauchbar. Die vollstaendige Referenz steht dann allein in `image_spec`. +# Gefunden am 2026-08-21, eine Stunde nachdem der Chart-Sprung genau so einen +# Beiwagen hereingebracht hatte: ohne diesen Zusatz waere er still aus der +# Zielmenge gefallen - dieselbe Luecke, gegen die #0106 gebaut wurde. +AUSDRUCK_CLUSTER = "count by (image_spec, image) (kube_pod_container_info)" # ⚠️ job=operating_cadvisor grenzt gegen gameserver_cadvisor ab (20 Images): # game-operating liegt ausserhalb des Auftrags. Ein Beschriftungsvergleich, # keine Ausschlussliste - eine Liste haette denselben Verfall wie images.txt. @@ -60,6 +67,13 @@ def normalisiere(ref: str) -> Optional[str]: ref = (ref or "").strip() if not ref: return None + # ⚠️ Nackter Digest ohne Repository (`sha256:<64 hex>`): kein Ziel, sondern + # das, was uebrig bleibt, wenn die Referenz verloren gegangen ist. Ohne + # diese Zeile rutscht er als Repo "sha256" mit Tag "" durch, landet in + # targets.txt, laesst sich nicht ziehen - und cve_targets_missing zaehlt + # dauerhaft eine Luecke, die in Wahrheit woanders sitzt. + if ref.startswith("sha256:") and "/" not in ref: + return None # Digest abtrennen, Tag behalten: `foo:v1@sha256:...` -> `foo:v1` if "@" in ref: ref = ref.split("@", 1)[0] @@ -100,7 +114,11 @@ def promql(basis_url: str, ausdruck: str, timeout: float = 10.0) -> list: def _images_aus_ergebnis(ergebnis: list) -> set: ziele = set() for reihe in ergebnis: - n = normalisiere(reihe.get("metric", {}).get("image", "")) + m = reihe.get("metric", {}) + # image_spec zuerst: es traegt die Referenz so, wie sie im Manifest + # steht. `image` faellt bei Digest-Festnagelung auf den nackten + # sha256 zurueck. cAdvisor kennt image_spec nicht - daher der Rueckfall. + n = normalisiere(m.get("image_spec") or "") or normalisiere(m.get("image", "")) if n: ziele.add(n) return ziele diff --git a/monitoring/cve/test_targets.py b/monitoring/cve/test_targets.py index b8c37b1..addb501 100644 --- a/monitoring/cve/test_targets.py +++ b/monitoring/cve/test_targets.py @@ -344,5 +344,52 @@ class Deckungsrechnung(unittest.TestCase): self.assertEqual(len(soll - ist_kaputt), 1) # merkt es +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 + 1.x). `kube_pod_container_info` fuehrt dessen `image` dann nur noch als + nackten sha256 - die brauchbare Referenz steht in `image_spec`. Ohne diese + Zusicherungen faellt so ein Container still aus der Zielmenge, und zwar + genau in der Herleitung, die gebaut wurde, damit nichts mehr still + herausfaellt. + """ + + # Aufgezeichnet aus dem laufenden Cluster, 2026-08-21 nach dem Chart-Sprung. + ECHTE_ANTWORT = [ + {"metric": {"image": "docker.io/grafana/alloy:v1.16.3", + "image_spec": "docker.io/grafana/alloy:v1.16.3"}}, + {"metric": {"image": "sha256:c92477446e98f056925f69537521dab09b1c57f894bf258b3344f8bce723cdc7", + "image_spec": "quay.io/prometheus-operator/prometheus-config-reloader:v0.91.0@sha256:7d9e4eea5f1139e602508871f422b0116c60e87c662f3dcd234d5ab60cd0d8c1"}}, + ] + + def test_der_beiwagen_landet_in_der_zielmenge(self): + ziele = targets._images_aus_ergebnis(self.ECHTE_ANTWORT) + self.assertIn("quay.io/prometheus-operator/prometheus-config-reloader:v0.91.0", ziele) + self.assertIn("grafana/alloy:v1.16.3", ziele) + + def test_der_nackte_digest_landet_NICHT_darin(self): + # Gegenprobe zur Zusicherung darueber: Ohne die Abweisung waere er als + # Repo "sha256" mit Tag "" durchgerutscht - ein Ziel, das sich nie + # ziehen laesst, das aber dauerhaft als Deckungsluecke zaehlt. + ziele = targets._images_aus_ergebnis(self.ECHTE_ANTWORT) + self.assertEqual(len(ziele), 2) + self.assertFalse([z for z in ziele if z.startswith("sha256:")]) + self.assertIsNone(targets.normalisiere("sha256:" + "c9" * 32)) + # ... aber ein Digest MIT Repository bleibt ein gueltiges Ziel. + self.assertEqual(targets.normalisiere("quay.io/x/y:v1@sha256:abc"), "quay.io/x/y:v1") + + def test_ohne_image_spec_wird_image_genommen(self): + # cAdvisor kennt image_spec nicht - der Rueckfall muss tragen. + ziele = targets._images_aus_ergebnis( + [{"metric": {"image": "docker.io/library/nginx:1.31.4-alpine"}}]) + self.assertEqual(ziele, {"nginx:1.31.4-alpine"}) + + def test_die_abfrage_holt_image_spec_ueberhaupt(self): + # ⚠️ Die Auswertung oben kann image_spec nur lesen, wenn die Abfrage es + # mitliefert. Ohne diese Zeile waere alles darueber gruen und trotzdem + # wirkungslos - die Fehlerklasse "meldet Erfolg, ist aber blind". + self.assertIn("image_spec", targets.AUSDRUCK_CLUSTER) + + if __name__ == "__main__": unittest.main(verbosity=2)