cve: a digest-pinned container was about to fall out of the target set

The alloy chart's 1.x line pins its config-reloader sidecar by digest. Kubernetes
then reports that container's image as a bare sha256 and puts the usable
reference in image_spec alone, which the derivation was not reading. An hour
after the chart bump the sidecar would have gone unscanned — the exact hole
#0106 exists to close, reopened by an upgrade rather than by neglect.

Worse than missing: the bare digest passed normalisation as repository 'sha256'
with the hex as its tag, so it would have entered targets.txt, failed every pull,
and shown up as a permanent coverage gap pointing at nothing.

Both ends are closed and both are asserted, including that the query still asks
for image_spec — an assertion on the parsing alone would stay green while the
data never arrives.

Ten more decisions cover what is fixed in the repo but not yet rolled out on the
operating host, dated a week out so the alert speaks up if the deploy does not
happen.
This commit is contained in:
Thore Cimbal
2026-08-21 12:00:00 +00:00
parent 44cc030f0b
commit d5e2995314
4 changed files with 163 additions and 3 deletions
+9 -1
View File
@@ -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.
+87
View File
@@ -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",
+20 -2
View File
@@ -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 "<hex>" 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
+47
View File
@@ -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 "<hex>" 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)