The scan loop is untouched and still reads images.txt, so nothing about this deployment behaves differently. What changes is that the exporter now knows what *should* be scanned and can say how much of it is: three sources, none of them new infrastructure. The cluster and the operating host both already sit in the same Prometheus, and the registry answers an anonymous token — the same token dance Trivy performs to pull. The coverage numbers are the point of this slice. Once deployed they have to read 24 missing, 2 orphaned and about 0.52, because that is what was counted by hand on 2026-08-21. A different answer means the derivation is wrong, not the hand count stale. Failure handling is the substance rather than an afterthought. A source that fails costs only its own share; its freshness timestamp keeps ageing instead of disappearing, because a series that vanishes can never fire a rule — the third finding of the 2026-08-01 AAR. When every source fails the target file is left untouched, so a restart during a Prometheus outage cannot clear the estate. Twenty-one assertions cover it, each paired with its counter-proof: names that must collapse and names that must not, a source filter that is shown to matter by removing it, and time-ordered tag selection against the name ordering that would silently drop v0.10.0. Sabotaging the normalisation turns eleven of them red, so the suite demonstrably can fail.
213 lines
9.5 KiB
Python
213 lines
9.5 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")
|
|
|
|
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", " ")
|
|
|
|
|
|
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)
|
|
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
|
|
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", "?")
|
|
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}"
|
|
)
|
|
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
|
|
|
|
|
|
ThreadingHTTPServer(("0.0.0.0", 9101), Handler).serve_forever()
|