Restarting k3s took kube-state-metrics and alloy's log tailers down with it, so kube_pod_container_info went empty in prometheus. The derivation asked, got a clean response with zero rows, and counted it as success. Cluster targets went from 39 to none, the total from 54 to 12, and the scan loop deleted every report whose target had vanished. Coverage then read 1.0. None of the four rules fired, and each for a defensible reason: the set was not empty because the operating host still answered, and every timestamp was fresh because an empty success updates it. The gap sat exactly between them. A source that has delivered before and now delivers nothing is treated as a failure: its previous targets are kept, its timestamp ages, and the stale rule takes over. A fifth rule watches the total for a drop of more than 40 percent, and it deliberately also fires on a deliberate shrink — losing 40 percent of the checked estate is worth a line either way. The six rancher decisions are gone too. They described versions that no longer run: the k3s patch took all fifteen of their criticals with it.
312 lines
15 KiB
Python
312 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
# Trivy-JSON -> Prometheus-Metriken (gitops#47). Stdlib-only, Machart wie die
|
|
# uebrigen Monitoring-Helfer. Liest die Reports des cve-scan-Sidecars und
|
|
# serviert /metrics; merkt sich je (CVE, Target) den Erstfund (first_seen),
|
|
# damit der geforderte Zeitstrahl (erstmals gesehen / geschlossen) abbildbar
|
|
# ist - "geschlossen" = Serie verschwindet, resolved kommt via Alertmanager.
|
|
#
|
|
# Schema (Pflichtfelder-Vorgabe sorb: CVE-ID, Mitigation, Zeitstrahl, Ort, Typ):
|
|
# trivy_vuln_info{cve,severity,target,target_type,host,pkg,installed,fixed_version} 1
|
|
# (nur HIGH/CRITICAL als Einzelserien - Kardinalitaet)
|
|
# trivy_vuln_count{target,target_type,host,severity} <n> (alle Severities)
|
|
# trivy_vuln_first_seen_timestamp{cve,target} <unix>
|
|
# trivy_last_scan_timestamp{target,target_type,host} <unix>
|
|
import json
|
|
import os
|
|
import threading
|
|
import time
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
import targets as ziel_herleitung
|
|
|
|
RESULTS = os.environ.get("RESULTS_DIR", "/results")
|
|
STATE_FILE = os.environ.get("STATE_FILE", "/state/first-seen.json")
|
|
# Soll-Menge (#0106): hierhin schreibt der Exporter, von hier liest scan-loop.sh.
|
|
TARGETS_FILE = os.environ.get("TARGETS_FILE", "/targets/targets.txt")
|
|
# ⚠️ Die Herleitung laeuft NICHT je Scrape. Bei 15 s Scrape-Intervall waeren das
|
|
# rund 21 000 Registry-Anfragen am Tag; eine Runde kostet ~90.
|
|
SOLL_INTERVALL = float(os.environ.get("SOLL_INTERVALL_SEKUNDEN", "3600"))
|
|
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:
|
|
first_seen = json.load(f) # "cve|target" -> unix-ts
|
|
except Exception:
|
|
first_seen = {}
|
|
|
|
|
|
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.
|
|
|
|
⚠️ Der Stand je Quelle ueberlebt einen Ausfall absichtlich: Faellt eine
|
|
Quelle aus, bleibt ihr alter Zeitstempel stehen und ALTERT, statt zu
|
|
verschwinden. Eine verschwundene Serie kann keine Alarmregel ausloesen
|
|
(AAR 2026-08-01, Befund 3) - genau daran waere die Absicherung sonst
|
|
gescheitert.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._sperre = threading.Lock()
|
|
self._stand = {}
|
|
self._letzte = None
|
|
self._geholt_um = 0.0
|
|
|
|
def hole(self):
|
|
with self._sperre:
|
|
if self._letzte is not None and time.time() - self._geholt_um < SOLL_INTERVALL:
|
|
return self._letzte
|
|
cfg = ziel_herleitung.cfg_aus_umgebung()
|
|
h = ziel_herleitung.herleiten(
|
|
cfg, vorheriger_stand=self._stand,
|
|
vorherige_ziele=(self._letzte.je_quelle if self._letzte else None))
|
|
self._stand = dict(h.stand)
|
|
self._letzte, self._geholt_um = h, time.time()
|
|
for quelle, meldung in h.fehler.items():
|
|
print(f"soll-menge: quelle {quelle} ausgefallen: {meldung}", flush=True)
|
|
if h.brauchbar:
|
|
schreibe_targets(h.ziele)
|
|
else:
|
|
# NICHTS ueberschreiben. Sonst raeumt ein Neustart waehrend eines
|
|
# Prometheus-Ausfalls den gesamten Bestand ab.
|
|
print("soll-menge: alle Quellen aus - targets.txt bleibt unberuehrt", flush=True)
|
|
return h
|
|
|
|
|
|
def schreibe_targets(ziele):
|
|
try:
|
|
os.makedirs(os.path.dirname(TARGETS_FILE), exist_ok=True)
|
|
tmp = TARGETS_FILE + ".tmp"
|
|
with open(tmp, "w") as f:
|
|
f.write("# erzeugt von cve-exporter (#0106) - NICHT von Hand pflegen\n")
|
|
for z in sorted(ziele):
|
|
f.write(z + "\n")
|
|
os.replace(tmp, TARGETS_FILE) # atomar, der Scanner liest nebenlaeufig
|
|
except Exception as e:
|
|
print(f"targets.txt nicht schreibbar: {e}", flush=True)
|
|
|
|
|
|
ziel_cache = ZielCache()
|
|
|
|
|
|
def collect():
|
|
lines = []
|
|
now = int(time.time())
|
|
seen_keys = set()
|
|
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
|
|
berichte += 1
|
|
path = os.path.join(RESULTS, fn)
|
|
try:
|
|
rep = json.load(open(path))
|
|
except Exception as e:
|
|
# Frueher: stilles 'continue'. Ein einmaliger Lesefehler (Datei wird
|
|
# gerade geschrieben, kurzer I/O-Fehler) liess die Findings dieses
|
|
# Targets aus seen_keys verschwinden - und der Prune unten loeschte
|
|
# ihre Erstfund-Zeitstempel DAUERHAFT. Der Zeitstrahl war damit weg,
|
|
# ohne dass irgendetwas gemeldet haette. Jetzt zaehlbar und sichtbar.
|
|
lesefehler += 1
|
|
print(f"report unlesbar: {fn}: {e}", flush=True)
|
|
continue
|
|
target = rep.get("ArtifactName", fn[:-5])
|
|
gelesene_targets.add(target)
|
|
ttype = "host" if rep.get("ArtifactType") in ("filesystem", "rootfs") else "image"
|
|
host = fn.split("__host__")[1].split(".json")[0] if "__host__" in fn else DEFAULT_HOST
|
|
base = f'target="{esc(target)}",target_type="{ttype}",host="{esc(host)}"'
|
|
lines.append(f'trivy_last_scan_timestamp{{{base}}} {int(os.path.getmtime(path))}')
|
|
counts = {}
|
|
for res in rep.get("Results") or []:
|
|
for v in res.get("Vulnerabilities") or []:
|
|
sev = v.get("Severity", "UNKNOWN")
|
|
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
|
|
seen_keys.add(key)
|
|
lines.append(
|
|
'trivy_vuln_info{cve="%s",severity="%s",%s,pkg="%s",installed="%s",fixed_version="%s"} 1'
|
|
% (esc(cve), sev, base, esc(v.get("PkgName", "?")),
|
|
esc(v.get("InstalledVersion", "?")), esc(v.get("FixedVersion", ""))))
|
|
lines.append(f'trivy_vuln_first_seen_timestamp{{cve="{esc(cve)}",target="{esc(target)}"}} {first_seen[key]}')
|
|
for sev, n in sorted(counts.items()):
|
|
lines.append(f'trivy_vuln_count{{{base},severity="{sev}"}} {n}')
|
|
# State kompakt halten: verschwundene Findings raus (= "geschlossen").
|
|
# ABER nur fuer Targets, deren Bericht in DIESEM Durchgang auch wirklich
|
|
# gelesen wurde. Sonst loescht ein einzelner Lesefehler den Zeitstrahl eines
|
|
# Targets, das es noch gibt - unwiederbringlich, weil "erstmals gesehen"
|
|
# danach auf 'jetzt' neu anfaengt.
|
|
for k in list(first_seen):
|
|
ziel = k.split("|", 1)[1] if "|" in k else ""
|
|
if ziel in gelesene_targets and k not in seen_keys:
|
|
del first_seen[k]
|
|
|
|
# Sichtbarkeit des Scanners selbst: ein still gestorbener Scanner macht blind,
|
|
# und TrivyScanStale kann ein Target, das NIE einen Bericht hatte, nicht melden
|
|
# (ohne Serie kein time()-Vergleich). Diese beiden Zahlen schliessen die Luecke
|
|
# so weit, wie sie ohne Soll-Liste zu schliessen ist.
|
|
lines.append(f"trivy_reports_total {berichte}")
|
|
lines.append(f"trivy_report_read_errors {lesefehler}")
|
|
|
|
# --- Deckung gegen die Soll-Menge (#0106) --------------------------------
|
|
# Bis hierher konnte der Exporter nur sagen, WAS er gescannt hat. Was er nie
|
|
# angesehen hat, war unsichtbar - am 2026-08-21 waren das 24 von 51 Images.
|
|
# Erst der Vergleich gegen eine Soll-Menge macht die Luecke zur Zahl.
|
|
h = ziel_cache.hole()
|
|
ist = {ziel_herleitung.normalisiere(t) for t in gelesene_targets}
|
|
ist.discard(None)
|
|
soll = h.ziele
|
|
|
|
for quelle in ziel_herleitung.QUELLEN:
|
|
for z in sorted(h.je_quelle.get(quelle, ())):
|
|
lines.append(f'cve_target_desired{{target="{esc(z)}",quelle="{quelle}"}} 1')
|
|
# ⚠️ IMMER ausgeben, auch wenn die Quelle noch nie geliefert hat: Eine
|
|
# fehlende Serie kann keine Regel ausloesen, und eine ausgefallene
|
|
# Herleitung sieht sonst aus wie vollstaendige Deckung. Ohne je einen
|
|
# Erfolg zaehlt die Zeit seit Prozessstart - der Wert waechst also und
|
|
# schlaegt irgendwann an, statt still zu fehlen.
|
|
seit = h.stand.get(quelle, PROZESSSTART)
|
|
lines.append(f'cve_target_source_stale{{quelle="{quelle}"}} {int(now - seit)}')
|
|
|
|
fehlend, verwaist = soll - ist, ist - soll
|
|
lines.append(f"cve_targets_missing {len(fehlend)}")
|
|
lines.append(f"cve_targets_orphaned {len(verwaist)}")
|
|
lines.append(f"cve_targets_desired_total {len(soll)}")
|
|
# Bei leerer Soll-Menge waere 0/0 rechnerisch 1,0 - also "alles gedeckt",
|
|
# ausgerechnet im Ausfall. Deshalb 0 statt einer Division.
|
|
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.
|
|
#
|
|
# ⚠️ KEIN "und ziel in soll" mehr. Genau diese Bedingung stand hier bis
|
|
# zum 2026-08-22 und machte den Zaehler blind fuer den haeufigsten Fall:
|
|
# Ist das Image ganz aus dem Bestand gefallen (Fassungssprung), faellt
|
|
# auch seine Entscheidung aus der Pruefung - und bleibt fuer immer
|
|
# unbemerkt in der Datei stehen. Belegt am selben Tag: die Eintraege zu
|
|
# grafana 12.0.0 und 12.4.9 ueberlebten den Sprung auf 13.2.0, und der
|
|
# Zaehler meldete 0.
|
|
#
|
|
# Der Preis ist bekannt und tragbar: Wer eine Entscheidung trifft, BEVOR
|
|
# der Scanner das Image erreicht, sieht sie bis zur naechsten Runde hier
|
|
# auftauchen. Die Alarmregel wartet 24h und deckt das ab.
|
|
if (ziel, cve) not in laufend:
|
|
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:
|
|
json.dump(first_seen, f)
|
|
except Exception as e:
|
|
print(f"state save failed: {e}", flush=True)
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
if self.path != "/metrics":
|
|
self.send_response(404); self.end_headers(); return
|
|
body = collect().encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/plain; version=0.0.4")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def log_message(self, fmt, *args):
|
|
pass
|
|
|
|
|
|
# ⚠️ 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()
|