Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
937a17ecf8 | ||
|
|
9a3b1d1dd5 | ||
|
|
c7aaf388f5 | ||
|
|
d68991629e | ||
|
|
3aee13800d | ||
|
|
c942ce9328 | ||
|
|
2c0caacfb0 | ||
|
|
1ce4b55b75 | ||
|
|
422b651f43 | ||
|
|
d5e2995314 | ||
|
|
44cc030f0b | ||
|
|
516641bbba | ||
|
|
59c75c8a01 | ||
|
|
4758e35150 | ||
|
|
c5b20a1e47 | ||
|
|
07875ba6bb | ||
|
|
386b65a9e0 | ||
|
|
b989987d69 | ||
|
|
27770b6ec7 | ||
|
|
2eec485bef | ||
|
|
7b25b6e5ae | ||
|
|
fa26393649 | ||
|
|
a07c6eda09 | ||
|
|
ba59518ef0 | ||
|
|
7ee42f90e6 | ||
|
|
9a106151f1 | ||
|
|
be6b5f0657 | ||
|
|
e9c13dcf22 | ||
|
|
4cb9bfb2bc | ||
|
|
e0808bad90 | ||
|
|
1bbff5e749 | ||
|
|
32f89afebf | ||
|
|
ff87cb25fb | ||
|
|
0bd77e28d8 | ||
|
|
cdfadc0f26 | ||
|
|
b6007c50fd | ||
|
|
f45e01219b | ||
|
|
8c06329e33 | ||
|
|
6ffab68583 | ||
|
|
ea33c3b7b0 | ||
|
|
5b09a12287 | ||
|
|
682adbdd54 | ||
|
|
9594ec80ec | ||
|
|
b067c93677 | ||
|
|
25bb5dd44e | ||
|
|
adb8cf3a11 | ||
|
|
8c7a57dbf1 | ||
|
|
edac97e931 | ||
|
|
a400f8a4ba | ||
|
|
9deb205dce |
@@ -0,0 +1,28 @@
|
|||||||
|
# AGENTS.md — threadnet-operating
|
||||||
|
|
||||||
|
> **Die Gruppenregeln sind kanonisch im `management`-Repo:**
|
||||||
|
> [`AGENTS.md`](https://git.lab/axion1337.chat/management/-/blob/main/AGENTS.md)
|
||||||
|
> — von außerhalb des Labs über den Gitea-Mirror lesbar:
|
||||||
|
> `https://rohana.axion1337.de/sorb/management`. Dort stehen Repo-Topologie und
|
||||||
|
> Mirror-Regeln, das Kanban-Framework (Status-Labels, WIP-Limit 2, ADR-Pflicht),
|
||||||
|
> Deploy-Übergabe und AAR-Verfahren, Secrets-Handhabung und die
|
||||||
|
> Karpathy-Leitlinien. Sie gelten für **jede** Session in diesem Repo.
|
||||||
|
> Hier steht nur, was zusätzlich für dieses Repository gilt.
|
||||||
|
|
||||||
|
## Was dieses Repo ist
|
||||||
|
|
||||||
|
Monitoring-Stack auf **CFGMON**: Prometheus, Loki, Grafana, Alloy, Alertmanager und der
|
||||||
|
CVE-Exporter — als Compose, **nicht** von Flux verwaltet. Deploy manuell auf dem Host
|
||||||
|
(`/opt/threadnet-operating/monitoring`).
|
||||||
|
|
||||||
|
## Was hier besonders zählt
|
||||||
|
|
||||||
|
- **Alarmregeln** liegen in `monitoring/prometheus/alerts.yml`; die Zustellung läuft über
|
||||||
|
`alertmanager/matrix-alerts.py` in den Matrix-Security-Raum. Eine Regel, die nie feuern
|
||||||
|
kann, ist schlimmer als keine — **fehlende Zeitreihen erzeugen Stille, keinen Alarm**
|
||||||
|
(deshalb die `or`-Fallbacks in `axion-backup`).
|
||||||
|
- **Config-Änderungen kommen nicht automatisch an.** Prometheus und Alertmanager mounten seit
|
||||||
|
2026-08-15 ihr Config-**Verzeichnis**; bei den übrigen Diensten (loki, alloy, die Skripte)
|
||||||
|
hängt der Einzeldatei-Mount weiter am Inode und braucht `--force-recreate` nach `git pull`.
|
||||||
|
Details und Prüfbefehl in `monitoring/README.md`.
|
||||||
|
- Ob eine Änderung wirklich greift, sieht man **nur im Container**, nie auf der Platte.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Read AGENTS.md — the canonical instruction file for this repository. All rules live there.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# threadnet-operating
|
||||||
|
|
||||||
|
Der Betriebs-/Monitoring-Stack für den Operating-Host **CFGMON**: Prometheus,
|
||||||
|
Loki, Grafana, Alloy, Alertmanager und der CVE-Exporter — vollständig als Code,
|
||||||
|
ein `docker compose up -d` stellt ihn auf einem frischen Host wieder her.
|
||||||
|
|
||||||
|
**→ [`monitoring/README.md`](monitoring/README.md)** ist die eigentliche
|
||||||
|
Betriebsanleitung (Deployment, Config-Fallen, Alerting, CVE-Pipeline).
|
||||||
|
|
||||||
|
## Wo was liegt
|
||||||
|
|
||||||
|
| Pfad | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `monitoring/` | der Stack: Compose, Prometheus, Loki, Grafana, Alertmanager, Alloy |
|
||||||
|
| `monitoring/cve/` | CVE-Exporter (Trivy-Scan → Prometheus-Metriken), [ADR-0003](https://git.lab/axion1337.chat/management/-/blob/main/decisions/0003-cve-meldeweg-aggregiert.md) |
|
||||||
|
| `monitoring/grafana/` | Datasources und Dashboards als Code |
|
||||||
|
|
||||||
|
## Offene Punkte
|
||||||
|
|
||||||
|
Kein Backlog in diesem Repo. Offene Punkte sind **Issues** im
|
||||||
|
[management-Projekt](https://git.lab/axion1337.chat/management/-/issues)
|
||||||
|
([ADR-0005](https://git.lab/axion1337.chat/management/-/blob/main/decisions/0005-pm-framework-kanban.md)) —
|
||||||
|
sie betreffen meist mehrere Hosts, eine Liste je Repo würde auseinanderlaufen.
|
||||||
|
Für diesen Stack einschlägig sind unter anderem
|
||||||
|
[#8 Remote-Write und Loki ohne Auth](https://git.lab/axion1337.chat/management/-/issues/8),
|
||||||
|
[#9 Grafana-Credentials](https://git.lab/axion1337.chat/management/-/issues/9) und
|
||||||
|
[#10 Gitea-Backups off-host](https://git.lab/axion1337.chat/management/-/issues/10);
|
||||||
|
Bestand und Historie zum Host stehen in
|
||||||
|
[`hosts/cfgmon.md`](https://git.lab/axion1337.chat/management/-/blob/main/hosts/cfgmon.md).
|
||||||
|
|
||||||
|
**Kanonisch ist git.lab** ([ADR-0001](https://git.lab/axion1337.chat/management/-/blob/main/decisions/0001-gitlab-kanonisch-push-mirror.md),
|
||||||
|
[ADR-0002](https://git.lab/axion1337.chat/management/-/blob/main/decisions/0002-issues-und-management-ins-lab.md)).
|
||||||
|
Von außerhalb des Labs ist derselbe Stand über den Push-Mirror
|
||||||
|
[`sorb/management`](https://rohana.axion1337.de/sorb/management) lesbar — dorthin
|
||||||
|
aber **nie pushen**, der Mirror überschreibt.
|
||||||
@@ -2,3 +2,17 @@
|
|||||||
# Werte in Single-Quotes, damit Sonderzeichen shell-safe sind.
|
# Werte in Single-Quotes, damit Sonderzeichen shell-safe sind.
|
||||||
GRAFANA_ADMIN_USER='admin'
|
GRAFANA_ADMIN_USER='admin'
|
||||||
GRAFANA_ADMIN_PASSWORD='changeme'
|
GRAFANA_ADMIN_PASSWORD='changeme'
|
||||||
|
|
||||||
|
# Alerting (gitops#32) - Matrix-Zustellung durch den eigenen Bot @alerts in den
|
||||||
|
# dedizierten Alerts-Raum im Operating-Space (Entscheidung 2026-07-31: eigener Bot
|
||||||
|
# + eigener Raum, Token-Trennung vom maintenance-notify-Bot auf dem MATRIX-Host).
|
||||||
|
# Token erzeugen (Wert direkt hier eintragen, nicht in Chats/Logs):
|
||||||
|
# kubectl exec -n matrix deploy/matrix-stack-matrix-authentication-service -- \
|
||||||
|
# mas-cli manage issue-compatibility-token alerts
|
||||||
|
# Bot ist dem Raum bereits beigetreten (2026-07-31, inkl. Avatar + Testnachricht).
|
||||||
|
MATRIX_ALERT_HOMESERVER=https://matrix.axion1337.chat
|
||||||
|
MATRIX_ALERT_ROOM_ID=!qavWkXbhLPfGvqtifj:axion1337.chat
|
||||||
|
MATRIX_ALERT_TOKEN=changeme
|
||||||
|
|
||||||
|
# CVE-/Release-Raum fuer release-watch (gitops#47) - @alerts dort einladen!
|
||||||
|
MATRIX_RELEASE_ROOM_ID=!YRJvcEbVXtRlUIkNld:axion1337.chat
|
||||||
|
|||||||
+221
-7
@@ -13,6 +13,45 @@ docker network create traefik # falls noch nicht vorhanden
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Config-Aenderungen: was wirklich ankommt
|
||||||
|
|
||||||
|
**Prometheus und Alertmanager mounten seit 2026-08-15 ihr Config-VERZEICHNIS**
|
||||||
|
(`./prometheus`, `./alertmanager`) statt einzelner Dateien. Damit ist die frueher
|
||||||
|
hier beschriebene Inode-Falle fuer sie beseitigt: `git pull` ersetzt Dateien per
|
||||||
|
Rename (neuer Inode); ein Einzeldatei-Mount zeigt danach weiter auf die **alte**
|
||||||
|
Datei, waehrend `SIGHUP` seelenruhig Erfolg meldet. Verzeichnis-Mounts loesen bei
|
||||||
|
jedem Zugriff ueber den Pfad auf.
|
||||||
|
|
||||||
|
Warum strukturell statt per Anleitung: genau diese Falle war hier bereits
|
||||||
|
ausfuehrlich dokumentiert -- samt richtigem Kommando und Pruefbefehl -- und hat am
|
||||||
|
2026-08-14 trotzdem zugeschlagen (geladen wurden die alten Alert-Regeln, Reload
|
||||||
|
meldete Erfolg). Eine Fussangel, die man nur durch Lesen umgeht, umgeht man
|
||||||
|
irgendwann nicht.
|
||||||
|
|
||||||
|
Nach einem `git pull`, der Configs anfasst:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # Mount-/Service-Aenderungen -> Container wird neu erstellt
|
||||||
|
```
|
||||||
|
|
||||||
|
Reicht bei Prometheus/Alertmanager fuer den Inhalt bereits ein Reload, ist das in
|
||||||
|
Ordnung -- die Datei ist jetzt wirklich die neue.
|
||||||
|
|
||||||
|
**Noch als Einzeldatei gemountet** (gleiche Falle, dort weiterhin
|
||||||
|
`--force-recreate` noetig): `loki/loki-config.yaml`, `alloy/config.alloy` sowie die
|
||||||
|
Skripte `matrix-alerts.py`, `release-watch.py`, `cve-exporter.py`.
|
||||||
|
|
||||||
|
Ob eine Aenderung angekommen ist, sieht man nur **im Container**, nie auf der Platte:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec prometheus md5sum /etc/prometheus/alerts.yml
|
||||||
|
md5sum prometheus/alerts.yml # muessen uebereinstimmen
|
||||||
|
```
|
||||||
|
|
||||||
|
Grafana liest **Provider-Definitionen** nur beim Start: ein neuer Ordner in
|
||||||
|
`provisioning/dashboards/dashboards.yml` braucht `docker compose restart grafana`.
|
||||||
|
Dashboard-JSONs innerhalb eines bestehenden Providers werden laufend nachgezogen.
|
||||||
|
|
||||||
## Struktur
|
## Struktur
|
||||||
|
|
||||||
| Pfad | Inhalt |
|
| Pfad | Inhalt |
|
||||||
@@ -23,14 +62,23 @@ docker compose up -d
|
|||||||
| `alloy/config.alloy` | Docker-Log-Collection -> Loki, node-exporter -> Prometheus |
|
| `alloy/config.alloy` | Docker-Log-Collection -> Loki, node-exporter -> Prometheus |
|
||||||
| `grafana/provisioning/` | Datasources (feste UIDs!) und Dashboard-Provider |
|
| `grafana/provisioning/` | Datasources (feste UIDs!) und Dashboard-Provider |
|
||||||
| `grafana/dashboards/<ordner>/*.json` | Dashboards, je Unterordner ein Grafana-Ordner |
|
| `grafana/dashboards/<ordner>/*.json` | Dashboards, je Unterordner ein Grafana-Ordner |
|
||||||
|
| `cve/targets.py` | Bildet die Soll-Menge der Scan-Ziele aus Cluster, Betriebs-Host und Registry |
|
||||||
|
| `cve/test_targets.py` | Zusicherungen dazu, je mit Gegenprobe (`python3 test_targets.py`) |
|
||||||
|
| `cve/test_scan_loop.sh` | Zusicherungen fuer die Scan-Schleife (`sh cve/test_scan_loop.sh`) |
|
||||||
|
| `cve/scan-loop.sh` | Trivy-Scan-Schleife (24h), schreibt JSON-Reports |
|
||||||
|
| `cve/cve-exporter.py` | Reports -> Prometheus-Metriken, mit First-Seen-State |
|
||||||
|
|
||||||
## Externe Abhaengigkeiten
|
## Externe Abhaengigkeiten
|
||||||
|
|
||||||
- **Remote-Write eingehend** auf `:9090`: k3s-Cluster und Matrix-Server pushen
|
- **Remote-Write eingehend** auf `:9090`: k3s-Cluster und Matrix-Server pushen
|
||||||
Metriken (`flux`, `kube_state_metrics`, `synapse`).
|
Metriken (`flux`, `kube_state_metrics`, `synapse`).
|
||||||
- **Log-Push eingehend** auf `:3100` (Loki) von externen Alloys.
|
- **Log-Push eingehend** auf `:3100` (Loki) von externen Alloys.
|
||||||
- **Scrape ausgehend**: k3s-Host (`10.0.0.2:9100`), Pterodactyl-Host
|
- **Scrape ausgehend**: k3s-Host (`10.0.0.2:9100`), Game-Host privat ueber den
|
||||||
(`157.90.155.206:9100` + `:8080`).
|
vSwitch (`10.0.0.4:9100`, `:8081` cadvisor, `:9810` gameserver-exporter).
|
||||||
|
cadvisor liegt dort auf 8081, weil Host-Port 8080 von coolify-proxy belegt ist.
|
||||||
|
Der Exporter auf `:9810` loest den alten auf `:9531` ab -- jener rief die
|
||||||
|
Client-API mit einem Application-Key ueber http auf und lieferte deshalb nie
|
||||||
|
eine einzige `pterodactyl_*`-Metrik.
|
||||||
- `cadvisor` und `traefik` werden gescraped, laufen aber in **anderen** Stacks
|
- `cadvisor` und `traefik` werden gescraped, laufen aber in **anderen** Stacks
|
||||||
(cadvisor: Portainer-Stack `thread-net-git`; beide haengen im externen
|
(cadvisor: Portainer-Stack `thread-net-git`; beide haengen im externen
|
||||||
`traefik`-Netz).
|
`traefik`-Netz).
|
||||||
@@ -42,13 +90,179 @@ aber die JSON-Datei im Repo ist die Quelle der Wahrheit: UI-Aenderungen
|
|||||||
muessen exportiert und committet werden, sonst gehen sie beim naechsten
|
muessen exportiert und committet werden, sonst gehen sie beim naechsten
|
||||||
Datei-Update verloren.
|
Datei-Update verloren.
|
||||||
|
|
||||||
|
## CVE-Pipeline (gitops#47)
|
||||||
|
|
||||||
|
`cve-scan` scannt alle 24h die Ziele aus `/targets/targets.txt` mit Trivy und legt
|
||||||
|
JSON-Reports in ein Volume; `cve-exporter` serviert sie als Metriken auf
|
||||||
|
`:9101`, Prometheus scraped sie als Job `cve_exporter`. Dashboard:
|
||||||
|
**Security / CVE-Uebersicht (Trivy)**.
|
||||||
|
|
||||||
|
Der Exporter merkt sich je `(CVE, Target)` den Erstfund in einem persistenten
|
||||||
|
State (`cve_exporter_state`-Volume) -- daher kommt die Spalte
|
||||||
|
"erstmals gesehen". Verschwindet ein Finding, faellt der Eintrag raus
|
||||||
|
(= "geschlossen").
|
||||||
|
|
||||||
|
### Die Zielmenge wird abgeleitet, NICHT gepflegt (#0106, ADR-0026)
|
||||||
|
|
||||||
|
Bis zum 2026-08-21 stand die Zielliste in `cve/images.txt` und musste bei
|
||||||
|
Stack-Aenderungen von Hand nachgezogen werden. Sie wurde es nicht: Gemessen an
|
||||||
|
dem Tag deckte sie **27 von 51** laufenden Images ab (52 %). Ungescannt blieben
|
||||||
|
unter anderem der Web-Client, den jeder Nutzer laedt, **beide** Traefik-Schichten
|
||||||
|
und die Registry selbst; gleichzeitig lief ein Scan gegen ein Image, das
|
||||||
|
nirgends mehr laeuft.
|
||||||
|
|
||||||
|
**Es gibt daher nichts mehr nachzuziehen.** Der `cve-exporter` bildet die
|
||||||
|
Soll-Menge bei jeder Herleitung neu aus drei Quellen und schreibt sie nach
|
||||||
|
`/targets/targets.txt` (Volume `cve_targets`, fuer den Scanner nur lesbar):
|
||||||
|
|
||||||
|
| Quelle | woher |
|
||||||
|
|---|---|
|
||||||
|
| 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.
|
||||||
|
|
||||||
|
⚠️ **Eine Antwort kann erfolgreich und trotzdem wertlos sein.** Am 2026-08-23
|
||||||
|
legte ein k3s-Neustart kube-state-metrics und Alloy lahm; `kube_pod_container_info`
|
||||||
|
war daraufhin in Prometheus **leer**. Die Abfrage lief sauber durch, lieferte
|
||||||
|
null Zeilen — und galt als Erfolg. Die Cluster-Quelle fiel von 39 Zielen auf 0,
|
||||||
|
die Gesamtmenge von 54 auf 12, der Scanner loeschte **42 Berichte**, und das
|
||||||
|
Dashboard meldete Deckung **1,0**. Keine Regel schlug an: die Menge war nicht
|
||||||
|
leer, die Zeitstempel waren frisch.
|
||||||
|
|
||||||
|
Seitdem gilt: **Eine Quelle, die schon einmal geliefert hat und jetzt nichts
|
||||||
|
liefert, ist ein Ausfall.** Der alte Stand bleibt, der Zeitstempel altert,
|
||||||
|
`CveTargetSourceStale` greift — und `CveZielmengeEingebrochen` meldet zusaetzlich
|
||||||
|
jeden Einbruch der Gesamtmenge um mehr als 40 %.
|
||||||
|
|
||||||
|
⚠️ **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.
|
||||||
|
Wer hier etwas aendert, darf diese Absicherung nicht wegkuerzen; ohne sie ist
|
||||||
|
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).
|
||||||
|
|
||||||
|
⚠️ **Eintraege veralten aus zwei Gruenden, und beide sind normal.** Entweder das
|
||||||
|
Image verschwindet aus dem Bestand (Fassungssprung) — oder der Befund selbst
|
||||||
|
verschwindet, weil Trivys Datenbank ihn **umbewertet**. Am 2026-08-23 fiel
|
||||||
|
`CVE-2026-57433` (perl-base) von CRITICAL auf HIGH und machte damit sechs
|
||||||
|
Entscheidungen auf einen Schlag gegenstandslos. Ein Schweregrad ist keine
|
||||||
|
Eigenschaft unseres Bestands, sondern einer fremden Datenbank, die sich unter
|
||||||
|
uns aendert. `cve_entscheidungen_ohne_befund` faengt beide Faelle. 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.
|
||||||
|
|
||||||
|
⚠️ **Dieselbe Lage hat je nach Zaehlweise verschiedene Zahlen.** Ein Trivy-Bericht
|
||||||
|
zaehlt **Vorkommen**, Prometheus zaehlt **Serien** — und eine Serie ist
|
||||||
|
`(CVE, Paket, installierte Fassung, Fix-Fassung)` je Ziel. Wiederholt sich
|
||||||
|
derselbe Befund ueber mehrere Binaries in einem Image, sind das viele Vorkommen
|
||||||
|
und **eine** Serie. Bei Grafana 13.2.0: 162 Vorkommen, 63 Serien, 28
|
||||||
|
verschiedene CVEs. Wer eine Fassung gegen eine andere haelt, muss auf beiden
|
||||||
|
Seiten dieselbe Zaehlweise nehmen — sonst faellt das Urteil falsch aus, wie am
|
||||||
|
2026-08-21 beim ersten Blick auf 13.x.
|
||||||
|
|
||||||
|
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
|
||||||
|
Default-Route auf den `matrix`-Receiver; die frueher hier beschriebene
|
||||||
|
`room="security"`-Route auf den Null-Receiver existiert nicht mehr. Alarme **ohne**
|
||||||
|
`room`-Label (u.a. die `axion-basics`- und `axion-backup`-Gruppen) laufen ueber
|
||||||
|
diese Default-Route.
|
||||||
|
|
||||||
|
Historie, damit der Grund der damaligen Stummschaltung nicht verlorengeht: die
|
||||||
|
CVE-Regeln erzeugten je eine Alarm-Instanz **pro CVE pro Image** (beim ersten Lauf
|
||||||
|
59 CRITICAL + 445 HIGH), `matrix-alerts.py` schickte eine Nachricht pro Alarm,
|
||||||
|
Synapse rate-limitete nach ~10 Nachrichten mit 429, und weil `save_state()` hinter
|
||||||
|
der Sende-Schleife stand, wiederholte Alertmanager die komplette Gruppe mit noch
|
||||||
|
leerer Deduplizierung. Behoben durch Aggregation auf `count by (target, severity)`
|
||||||
|
(eine Instanz je Image statt je CVE); Details im Dashboard `cve-overview`.
|
||||||
|
Vollstaendige Historie: gitops#51 / AAR 2026-08-01.
|
||||||
|
|
||||||
|
**Zustellfehler sind seit 2026-08-15 selbst ueberwacht:** Prometheus scrapt
|
||||||
|
Alertmanager (`operating_alertmanager`) und `AlertDeliveryFailing` schlaegt an,
|
||||||
|
wenn `alertmanager_notifications_failed_total` steigt. Vorher war Alertmanager zwar
|
||||||
|
Alarm-Ziel, aber kein Scrape-Target — eine reissende Alarmkette haette sich also
|
||||||
|
selbst nicht melden koennen.
|
||||||
|
|
||||||
|
### Kleinere offene Punkte der Pipeline
|
||||||
|
|
||||||
|
- `TrivyScanStale` kann ein Image, das **nie** erfolgreich gescannt wurde,
|
||||||
|
nicht melden: ohne ersten Report gibt es keine Serie, an der
|
||||||
|
`time() - trivy_last_scan_timestamp` haengen koennte. Ein dauerhaft
|
||||||
|
fehlschlagendes Image bleibt damit still. `TargetDown` deckt nur den toten
|
||||||
|
Exporter ab, nicht den einzelnen blinden Fleck.
|
||||||
|
- `coturn/coturn:latest` ist als einziges Image ungepinnt -- Scan-Ergebnisse
|
||||||
|
sind dadurch nicht reproduzierbar.
|
||||||
|
- Der Exporter prunt den First-Seen-State bei **jedem** Scrape anhand der
|
||||||
|
gerade gelesenen Reports. Ein transienter Lesefehler (`except: continue`)
|
||||||
|
loescht die Erstfund-Zeitstempel des betroffenen Targets dauerhaft.
|
||||||
|
|
||||||
## Offene Punkte / Sicherheit
|
## Offene Punkte / Sicherheit
|
||||||
|
|
||||||
- `9090`, `3100`, `9100` sind auf der oeffentlichen IP ohne Auth erreichbar
|
- `9090`, `3100`, `9100`: **erledigt am 2026-08-21.** Sie standen auf `0.0.0.0`
|
||||||
(Docker umgeht ufw). Per **Hetzner Cloud Firewall** auf die IPs der
|
und waren damit auf der oeffentlichen IP ohne Auth erreichbar (Docker umgeht
|
||||||
bekannten Absender einschraenken.
|
ufw). Jetzt an Adressen gebunden -- `10.0.0.3` fuer die pushenden Absender
|
||||||
- Pterodactyl-Host `157.90.155.206`: node-exporter und cadvisor antworten
|
ueber den vSwitch, `127.0.0.1` fuer den Host selbst; node-exporter nur
|
||||||
aktuell nicht (Targets down).
|
localhost. Die Hetzner-Firewall bleibt die zweite Schicht, ist aber nicht
|
||||||
|
mehr die einzige.
|
||||||
|
|
||||||
|
⚠️ **Das war auf dem Host repariert und im Repo nicht.** Vier Monate spaeter
|
||||||
|
haette ein `git pull` die Ports wieder geoeffnet, ohne dass jemand es
|
||||||
|
bemerkt haette -- verhindert nur dadurch, dass git den Pull wegen der
|
||||||
|
lokalen Aenderung abgelehnt hat. Eine Haertung, die nur auf dem Host steht,
|
||||||
|
ist keine Haertung, sondern eine Wette auf das naechste Deployment.
|
||||||
|
- Game-Host: **erledigt**. Host-Seite am 2026-08-20 deployt und verifiziert, die
|
||||||
|
Ports liegen auf `10.0.0.4` (nicht `0.0.0.0` -- das umginge ufw). Der lokale
|
||||||
|
Prometheus/Grafana/Loki-Stack des Game-Hosts ist zurueckgebaut; er liefert nur
|
||||||
|
noch Sammler, promtail schickt nach `10.0.0.3:3100`.
|
||||||
|
Das Label-Konzept des Game-Hosts lebte in `metric_relabel_configs` und ist mit
|
||||||
|
dem Rueckbau hierher gewandert -- ohne es blieben Container nur unter ihrer UUID
|
||||||
|
sichtbar und je Serie rund 20 `container_label_*` uebrig.
|
||||||
- Loki `table_manager` ist deprecated; Retention-Enforcement laeuft aktuell
|
- Loki `table_manager` ist deprecated; Retention-Enforcement laeuft aktuell
|
||||||
nicht ueber den Compactor -- bei Gelegenheit auf Compactor-Retention
|
nicht ueber den Compactor -- bei Gelegenheit auf Compactor-Retention
|
||||||
umstellen.
|
umstellen.
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Alertmanager (gitops#32): alles an den Matrix-Receiver (wartung-Raum).
|
||||||
|
route:
|
||||||
|
receiver: matrix
|
||||||
|
group_by: [alertname, instance]
|
||||||
|
group_wait: 1m
|
||||||
|
group_interval: 5m
|
||||||
|
repeat_interval: 4h
|
||||||
|
# CVE-Alarme laufen seit gitops#51 aggregiert (eine Instanz pro Image statt
|
||||||
|
# pro CVE) und wieder ueber den Matrix-Receiver — Historie des Alarm-Sturms
|
||||||
|
# und der Stummschaltung: gitops#51 / AAR 2026-08-01.
|
||||||
|
|
||||||
|
receivers:
|
||||||
|
- name: matrix
|
||||||
|
webhook_configs:
|
||||||
|
- url: http://matrix-alerts:8080/alert
|
||||||
|
send_resolved: true
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Minimaler Alertmanager-Webhook -> Matrix-Raum (gitops#32). Gleiche Machart wie
|
||||||
|
# maintenance-notify (Issue #24 im gitops-Repo): purer Stdlib-HTTP-Server, Bot-Token
|
||||||
|
# aus der Umgebung, Nachricht per Client-Server-API.
|
||||||
|
#
|
||||||
|
# Ein Alarm = EINE Nachricht: beim Firing wird pro Alarm (Alertmanager-Fingerprint)
|
||||||
|
# eine Nachricht gesendet und deren Event-ID gemerkt; beim Resolved wird dieselbe
|
||||||
|
# Nachricht per m.replace-Edit durchgestrichen und abgehakt statt eine neue zu
|
||||||
|
# posten (Wunsch sorb 2026-08-01: append-only wird unuebersichtlich). Die Zuordnung
|
||||||
|
# ueberlebt Container-Restarts via State-Datei; ohne Zuordnung (z.B. nach Neubau)
|
||||||
|
# faellt Resolved auf eine eigenstaendige ✅-Nachricht zurueck.
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
HOMESERVER = os.environ["MATRIX_HOMESERVER"]
|
||||||
|
ROOM_ID = os.environ["MATRIX_ROOM_ID"]
|
||||||
|
TOKEN = os.environ["MATRIX_TOKEN"]
|
||||||
|
STATE_FILE = os.environ.get("MATRIX_STATE_FILE", "/tmp/matrix-alerts-state.json")
|
||||||
|
# Raum-Routing (gitops#47): Alerts mit Label room="<name>" landen im Raum aus
|
||||||
|
# MATRIX_ROOM_<NAME> (z.B. room="security" -> MATRIX_ROOM_SECURITY), alles
|
||||||
|
# andere im Default-Raum. Der Bot muss in jedem Zielraum Mitglied sein.
|
||||||
|
ROOM_MAP = {k[len("MATRIX_ROOM_"):].lower(): v
|
||||||
|
for k, v in os.environ.items()
|
||||||
|
if k.startswith("MATRIX_ROOM_") and k != "MATRIX_ROOM_ID" and v}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(STATE_FILE) as f:
|
||||||
|
_persisted = json.load(f)
|
||||||
|
state = _persisted.get("alerts", _persisted) # alt: flaches Format
|
||||||
|
recent_resolved = _persisted.get("recent_resolved", [])
|
||||||
|
except Exception:
|
||||||
|
state = {}
|
||||||
|
recent_resolved = []
|
||||||
|
|
||||||
|
|
||||||
|
def save_state():
|
||||||
|
try:
|
||||||
|
with open(STATE_FILE, "w") as f:
|
||||||
|
json.dump({"alerts": state, "recent_resolved": recent_resolved[-200:]}, f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"state save failed: {e}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def matrix_put(content, room=ROOM_ID):
|
||||||
|
url = (f"{HOMESERVER}/_matrix/client/v3/rooms/{urllib.parse.quote(room)}"
|
||||||
|
f"/send/m.room.message/{uuid.uuid4()}")
|
||||||
|
req = urllib.request.Request(url, data=json.dumps(content).encode(), method="PUT",
|
||||||
|
headers={"Authorization": f"Bearer {TOKEN}",
|
||||||
|
"Content-Type": "application/json"})
|
||||||
|
return json.loads(urllib.request.urlopen(req, timeout=10).read()).get("event_id")
|
||||||
|
|
||||||
|
|
||||||
|
def send_text(text, room=ROOM_ID):
|
||||||
|
return matrix_put({"msgtype": "m.text", "body": text}, room)
|
||||||
|
|
||||||
|
|
||||||
|
def edit_resolved(event_id, old_text, room=ROOM_ID):
|
||||||
|
# m.replace-Edit: Original wird in Element in-place ersetzt (durchgestrichen + Haken)
|
||||||
|
new_body = f"✅ ~~{old_text}~~"
|
||||||
|
new_html = f"✅ <del>{html.escape(old_text)}</del>"
|
||||||
|
matrix_put({
|
||||||
|
"msgtype": "m.text",
|
||||||
|
"body": f"* {new_body}",
|
||||||
|
"m.new_content": {"msgtype": "m.text", "body": new_body,
|
||||||
|
"format": "org.matrix.custom.html", "formatted_body": new_html},
|
||||||
|
"m.relates_to": {"rel_type": "m.replace", "event_id": event_id},
|
||||||
|
}, room)
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path != "/alert":
|
||||||
|
self.send_response(404); self.end_headers(); return
|
||||||
|
data = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
|
||||||
|
failed = 0
|
||||||
|
for a in data.get("alerts", []):
|
||||||
|
name = a.get("labels", {}).get("alertname", "?")
|
||||||
|
summary = a.get("annotations", {}).get("summary", "")
|
||||||
|
fp = a.get("fingerprint", "")
|
||||||
|
room = ROOM_MAP.get(a.get("labels", {}).get("room", ""), ROOM_ID)
|
||||||
|
try:
|
||||||
|
if a.get("status") == "firing":
|
||||||
|
if fp in state: # re-notify/Batch-Retry -> keine Doppelnachricht
|
||||||
|
continue
|
||||||
|
text = f"\U0001F534 [firing] {name}: {summary}"
|
||||||
|
event_id = send_text(text, room)
|
||||||
|
if fp and event_id:
|
||||||
|
state[fp] = {"event_id": event_id, "text": text, "room": room}
|
||||||
|
else:
|
||||||
|
if fp in recent_resolved: # Batch-Retry -> Fallback nicht doppeln
|
||||||
|
continue
|
||||||
|
known = state.pop(fp, None)
|
||||||
|
if known:
|
||||||
|
edit_resolved(known["event_id"], known["text"], known.get("room", ROOM_ID))
|
||||||
|
else:
|
||||||
|
send_text(f"✅ [resolved] {name}: {summary}", room)
|
||||||
|
recent_resolved.append(fp)
|
||||||
|
save_state() # inkrementell: Teilfortschritt uebersteht Fehler/Retry
|
||||||
|
time.sleep(1) # Synapse-Ratelimit (rc_message) nicht reizen
|
||||||
|
except Exception as e:
|
||||||
|
failed += 1
|
||||||
|
self.log_message("matrix send failed (%s): %s", name, e)
|
||||||
|
save_state()
|
||||||
|
time.sleep(2)
|
||||||
|
if failed:
|
||||||
|
# Alertmanager wiederholt den Batch; Dedup liefert nur den Rest nach
|
||||||
|
self.send_response(502); self.end_headers(); return
|
||||||
|
self.send_response(200); self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
print(fmt % args, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Release-/Advisory-Watch fuer den Element-Stack (gitops#22). Gleiche Machart wie
|
||||||
|
# matrix-alerts.py: purer Stdlib-Daemon, Bot-Token aus der Umgebung, Nachricht per
|
||||||
|
# Client-Server-API in den Alerts-Raum.
|
||||||
|
#
|
||||||
|
# Quelle sind die oeffentlichen GitHub-Release-Atom-Feeds (kein API-Token noetig).
|
||||||
|
# Element veroeffentlicht Security-Fixes als Releases - der Feed ist damit der
|
||||||
|
# praktikable Advisory-Kanal; echte GHSA-Advisories haben keinen oeffentlichen Feed.
|
||||||
|
# Neue Eintraege werden einmalig als 📦-Notiz gemeldet; Security-verdaechtige
|
||||||
|
# Titel/Inhalte (CVE/security/vulnerab...) bekommen 🚨 und stehen vorn.
|
||||||
|
#
|
||||||
|
# Abgrenzung (Frage sorb 2026-08-01): REPOS unten ist eine HANDGEPFLEGTE Liste -
|
||||||
|
# de facto ein Mini-SBOM auf Repo-Granularitaet, ohne Versions-/Dependency-Wissen.
|
||||||
|
# Bei Stack-Aenderungen (neue Komponente, Fork-Wechsel) muss sie mitgezogen werden.
|
||||||
|
# Trivy (gitops#31/#47) braucht dagegen keine Pflege: es leitet sein SBOM selbst
|
||||||
|
# aus den Images ab. Beide ergaenzen sich, ersetzen sich nicht.
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
HOMESERVER = os.environ["MATRIX_HOMESERVER"]
|
||||||
|
# Eigener CVE-/Release-Raum (Entscheidung sorb 2026-08-01, gitops#47);
|
||||||
|
# Fallback auf den Alerts-Raum, solange der Bot dort noch nicht eingeladen ist.
|
||||||
|
ROOM_ID = os.environ.get("MATRIX_RELEASE_ROOM_ID") or os.environ["MATRIX_ROOM_ID"]
|
||||||
|
TOKEN = os.environ["MATRIX_TOKEN"]
|
||||||
|
STATE_FILE = os.environ.get("RELEASE_WATCH_STATE_FILE", "/state/release-watch.json")
|
||||||
|
INTERVAL = int(os.environ.get("RELEASE_WATCH_INTERVAL", "21600")) # 6h
|
||||||
|
|
||||||
|
REPOS = [
|
||||||
|
"element-hq/synapse",
|
||||||
|
"element-hq/ess-helm",
|
||||||
|
"element-hq/element-web",
|
||||||
|
"element-hq/matrix-authentication-service",
|
||||||
|
"element-hq/element-call",
|
||||||
|
]
|
||||||
|
SECURITY_RE = re.compile(r"cve|security|vulnerab|advisory", re.IGNORECASE)
|
||||||
|
ATOM = "{http://www.w3.org/2005/Atom}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(STATE_FILE) as f:
|
||||||
|
seen = json.load(f) # repo -> [entry ids]
|
||||||
|
except Exception:
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
|
||||||
|
def send_notice(text):
|
||||||
|
url = (f"{HOMESERVER}/_matrix/client/v3/rooms/{urllib.parse.quote(ROOM_ID)}"
|
||||||
|
f"/send/m.room.message/{uuid.uuid4()}")
|
||||||
|
req = urllib.request.Request(url, data=json.dumps({"msgtype": "m.notice", "body": text}).encode(),
|
||||||
|
method="PUT", headers={"Authorization": f"Bearer {TOKEN}",
|
||||||
|
"Content-Type": "application/json"})
|
||||||
|
urllib.request.urlopen(req, timeout=10).read()
|
||||||
|
|
||||||
|
|
||||||
|
def check(repo):
|
||||||
|
feed = urllib.request.urlopen(f"https://github.com/{repo}/releases.atom", timeout=20).read()
|
||||||
|
root = ET.fromstring(feed)
|
||||||
|
entries = root.findall(f"{ATOM}entry")
|
||||||
|
known = set(seen.get(repo, []))
|
||||||
|
first_run = repo not in seen
|
||||||
|
new = []
|
||||||
|
for e in entries:
|
||||||
|
eid = e.findtext(f"{ATOM}id", "")
|
||||||
|
title = e.findtext(f"{ATOM}title", "?").strip()
|
||||||
|
link = ""
|
||||||
|
le = e.find(f"{ATOM}link")
|
||||||
|
if le is not None:
|
||||||
|
link = le.get("href", "")
|
||||||
|
content = e.findtext(f"{ATOM}content", "") or ""
|
||||||
|
if eid and eid not in known:
|
||||||
|
new.append((eid, title, link, bool(SECURITY_RE.search(title + " " + content[:2000]))))
|
||||||
|
# Erstlauf: nur Stand merken, nicht den Raum mit Historie fluten
|
||||||
|
seen[repo] = [e.findtext(f"{ATOM}id", "") for e in entries][:30]
|
||||||
|
if first_run:
|
||||||
|
return
|
||||||
|
for eid, title, link, is_sec in new:
|
||||||
|
icon = "\U0001F6A8" if is_sec else "\U0001F4E6"
|
||||||
|
kind = "Security-verdaechtiges Release" if is_sec else "Neues Release"
|
||||||
|
send_notice(f"{icon} {kind}: {repo} — {title}\n{link}")
|
||||||
|
|
||||||
|
|
||||||
|
while True:
|
||||||
|
for repo in REPOS:
|
||||||
|
try:
|
||||||
|
check(repo)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"{repo}: {exc}", flush=True)
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
|
||||||
|
with open(STATE_FILE, "w") as f:
|
||||||
|
json.dump(seen, f)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"state: {exc}", flush=True)
|
||||||
|
time.sleep(INTERVAL)
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
#!/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()
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
{
|
||||||
|
"_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-6653",
|
||||||
|
"CVE-2026-8376"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ziel": "gcr.io/cadvisor/cadvisor:v0.49.1",
|
||||||
|
"art": "geplant",
|
||||||
|
"grund": "Laeuft im Portainer-Stack 'thread-net-git', NICHT aus diesem Repo - die Fassung wird hier gar nicht gesetzt. GEMESSEN: v0.55.1 traegt 4 statt 5 CRITICAL, also ein schwacher Gewinn. Die Aktualisierung liegt wie beim portainer-agent bei sorb auf dem Betriebs-Host.",
|
||||||
|
"pruefen_am": "2026-09-21",
|
||||||
|
"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",
|
||||||
|
"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-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-59873",
|
||||||
|
"CVE-2026-8376"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ziel": "oci.element.io/synapse:v1.158.0",
|
||||||
|
"art": "hingenommen",
|
||||||
|
"grund": "Fassung vom ESS-Chart 26.8.0 gesetzt und heute erst gehoben (v1.151.0-ess.1 -> v1.158.0, 11 CRITICAL -> 6). Fuenf der sechs sitzen ohne Fix-Fassung in libxml2 und perl-base der Debian-Basis; der sechste ist die Go-stdlib des mitgelieferten Werkzeugs. Ein eigenes Image zu bauen, nur um diese Basis zu tauschen, waere unverhaeltnismaessig - der naechste ESS-Sprung bringt sie mit.",
|
||||||
|
"pruefen_am": "2026-09-21",
|
||||||
|
"cves": [
|
||||||
|
"CVE-2025-68121",
|
||||||
|
"CVE-2026-13221",
|
||||||
|
"CVE-2026-42496",
|
||||||
|
"CVE-2026-6653",
|
||||||
|
"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-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": "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",
|
||||||
|
"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-8376"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Trivy-Scan-Schleife (gitops#47, seit #0106 mit abgeleiteter Zielmenge).
|
||||||
|
#
|
||||||
|
# Die Ziele standen bis 2026-08-21 in cve/images.txt und wurden von Hand
|
||||||
|
# nachgezogen. Gemessen an dem Tag: 27 von 51 laufenden Images, also 52 % -
|
||||||
|
# nicht gescannt wurden unter anderem der Web-Client, den jeder Nutzer laedt,
|
||||||
|
# beide Traefik-Schichten und die Registry selbst. Gleichzeitig lief ein Scan
|
||||||
|
# gegen threadnet-web:v0.3.0, ein Image, das nirgends mehr laeuft.
|
||||||
|
#
|
||||||
|
# Seitdem schreibt der cve-exporter die Soll-Menge nach /targets/targets.txt
|
||||||
|
# (Quellen: Cluster-Metriken, cAdvisor des Betriebs-Hosts, Registry). Diese
|
||||||
|
# Datei wird NICHT von Hand gepflegt - siehe ADR-0026.
|
||||||
|
#
|
||||||
|
# Kein Abbruch bei Einzelfehlern: Ein nicht mehr pullbares Image darf den Rest
|
||||||
|
# nicht verhindern; der Exporter alarmiert ueber trivy_last_scan_timestamp,
|
||||||
|
# wenn ein Report veraltet.
|
||||||
|
set -u
|
||||||
|
# Ueber die Umgebung ueberschreibbar - der Exporter fuehrt RESULTS_DIR
|
||||||
|
# bereits so, und test_scan_loop.sh laedt diese Datei damit.
|
||||||
|
RESULTS="${RESULTS_DIR:-/results}"
|
||||||
|
TARGETS="${TARGETS_FILE:-/targets/targets.txt}"
|
||||||
|
INTERVAL="${SCAN_INTERVAL_SECONDS:-86400}"
|
||||||
|
# ⚠️ Kurze Wartezeit, wenn die Zielliste (noch) fehlt: Beim ersten Start nach
|
||||||
|
# einem Ausrollen schreibt der Exporter sie erst beim naechsten Scrape. Mit dem
|
||||||
|
# vollen 24h-Intervall stuende der Scanner deswegen einen Tag still.
|
||||||
|
WARTEZEIT="${WARTE_INTERVAL_SECONDS:-60}"
|
||||||
|
# Trivy ist im Test durch eine Attrappe ersetzbar; hier der echte Aufruf.
|
||||||
|
scanne() { trivy image --scanners vuln --format json --output "$2" "$1"; }
|
||||||
|
|
||||||
|
runde() {
|
||||||
|
# ⚠️ Ohne brauchbare Zielliste wird NICHTS getan. Der Exporter schreibt sie
|
||||||
|
# nur, wenn mindestens eine Quelle geliefert hat - faellt die Herleitung
|
||||||
|
# ganz aus, bleibt die alte Datei stehen und diese Schleife arbeitet
|
||||||
|
# weiter mit dem letzten bekannten Stand, statt ins Leere zu laufen.
|
||||||
|
if [ ! -s "$TARGETS" ]; then
|
||||||
|
echo "warte: $TARGETS fehlt oder ist leer - keine Runde"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
anzahl=0
|
||||||
|
erwartet=$(mktemp)
|
||||||
|
while IFS= read -r img; do
|
||||||
|
case "$img" in ""|\#*) continue;; esac
|
||||||
|
anzahl=$((anzahl + 1))
|
||||||
|
safe=$(echo "$img" | tr '/:@' '___')
|
||||||
|
echo "$safe" >> "$erwartet"
|
||||||
|
echo "scan: $img"
|
||||||
|
scanne "$img" "$RESULTS/$safe.json.tmp" \
|
||||||
|
&& mv "$RESULTS/$safe.json.tmp" "$RESULTS/$safe.json" \
|
||||||
|
|| echo "FEHLER bei $img (Report bleibt auf altem Stand)"
|
||||||
|
done < "$TARGETS"
|
||||||
|
|
||||||
|
# Berichte ohne Ziel entfernen (#0106, Kriterium 3).
|
||||||
|
#
|
||||||
|
# ⚠️ WARUM DAS NOETIG IST: Der Exporter liest JEDE *.json in /results und
|
||||||
|
# nimmt das Ziel aus Trivys ArtifactName. Ein Bericht, dessen Image aus der
|
||||||
|
# Soll-Menge gefallen ist, meldet sonst weiter - genau der Zustand vom
|
||||||
|
# 2026-08-20, als der Security-Raum HIGH-CVEs zu threadnet-web:v0.3.0 meldete,
|
||||||
|
# das nirgends mehr lief. Rauschen, das wie Signal aussieht.
|
||||||
|
#
|
||||||
|
# ⚠️ WARUM ES HIER STEHT UND NICHT WEITER OBEN: Die Wache am Schleifenanfang
|
||||||
|
# hat bereits sichergestellt, dass eine nicht-leere Zielliste vorliegt. Ohne
|
||||||
|
# sie wird die Runde ausgesetzt - und damit auch nichts geloescht. Sonst
|
||||||
|
# raeumte ein Neustart waehrend eines Prometheus-Ausfalls den gesamten
|
||||||
|
# Bestand ab, und der Verlust faellt erst beim naechsten Ernstfall auf.
|
||||||
|
entfernt=0
|
||||||
|
for bericht in "$RESULTS"/*.json; do
|
||||||
|
[ -e "$bericht" ] || continue
|
||||||
|
name=$(basename "$bericht" .json)
|
||||||
|
# Host-rootfs-Scans (Ausbaustufe, Dateiname traegt __host__) stammen nicht
|
||||||
|
# aus dieser Zielliste und werden deshalb nicht danach beurteilt.
|
||||||
|
case "$name" in *__host__*) continue;; esac
|
||||||
|
if ! grep -qxF "$name" "$erwartet"; then
|
||||||
|
echo "verwaist, entfernt: $name"
|
||||||
|
rm -f "$bericht"
|
||||||
|
entfernt=$((entfernt + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
rm -f "$erwartet"
|
||||||
|
|
||||||
|
echo "runde fertig: ${anzahl} Ziele, ${entfernt} verwaiste Berichte entfernt"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ⚠️ Ladewache: test_scan_loop.sh laedt diese Datei, um die ECHTE runde() zu
|
||||||
|
# pruefen statt eine nachgebaute. Ohne die Wache liefe beim Laden die
|
||||||
|
# Endlosschleife an, und der Test haette einen Nachbau geprueft - also den
|
||||||
|
# Nachbau, nicht die Sache.
|
||||||
|
[ "${SCAN_LOOP_NUR_LADEN:-0}" = "1" ] && return 0
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
# Ausdruecklich als if, nicht als `runde || sleep A && sleep B`: Das gruppiert
|
||||||
|
# links-assoziativ zu `(runde || sleep A) && sleep B` und schliefe bei
|
||||||
|
# fehlender Zielliste erst die Wartezeit UND danach das volle Intervall -
|
||||||
|
# also doch einen Tag, genau das, was die Wartezeit verhindern soll.
|
||||||
|
if runde; then
|
||||||
|
sleep "$INTERVAL"
|
||||||
|
else
|
||||||
|
sleep "$WARTEZEIT"
|
||||||
|
fi
|
||||||
|
done
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Soll-Menge der CVE-Scan-Ziele (#0106, ADR-0026): abgeleitet statt gepflegt.
|
||||||
|
#
|
||||||
|
# Vorher stand die Zielliste in cve/images.txt und wurde von Hand nachgezogen.
|
||||||
|
# Am 2026-08-21 gemessen: 27 von 51 laufenden Images, also 52 % Deckung - nicht
|
||||||
|
# gescannt wurden unter anderem der Web-Client, den jeder Nutzer laedt, beide
|
||||||
|
# Traefik-Schichten und die Registry selbst. Niemand hatte einen Fehler gemacht;
|
||||||
|
# die Pflege haengt an keinem Schritt, den ein Ausrollen ohnehin durchlaeuft.
|
||||||
|
#
|
||||||
|
# Drei Quellen, alle ohne neue Zugangsdaten und ohne neuen Netzweg:
|
||||||
|
# cluster - kube_pod_container_info (Prometheus)
|
||||||
|
# operating - container_last_seen{job=operating_*} (Prometheus, cAdvisor)
|
||||||
|
# registry - rohana /v2, anonymer Token-Tanz
|
||||||
|
#
|
||||||
|
# ⚠️ WARUM DIE FEHLER-BEHANDLUNG HIER DER KERN IST: Faellt eine Quelle aus, wird
|
||||||
|
# die Soll-Menge KLEINER - und die Deckung saehe damit BESSER aus, nicht
|
||||||
|
# schlechter. Ein Ausfall der Herleitung ist von Erfolg also nicht zu
|
||||||
|
# unterscheiden, solange niemand die Frische je Quelle misst. Deshalb traegt
|
||||||
|
# Herleitung.fehler und Herleitung.stand je Quelle, und deshalb wird bei
|
||||||
|
# vollstaendigem Ausfall NICHTS geschrieben (siehe cve-exporter.py).
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import NamedTuple, Optional
|
||||||
|
|
||||||
|
# --- Quellen-Ausdruecke ------------------------------------------------------
|
||||||
|
# Beide zaehlen nur, um die Beschriftung `image` zu bekommen; der Wert ist egal.
|
||||||
|
# ⚠️ 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.
|
||||||
|
AUSDRUCK_OPERATING = (
|
||||||
|
'count by (image) (container_last_seen{job="operating_cadvisor",image!=""})'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tags, die nie ausgeliefert werden - Bau-Artefakte der CI.
|
||||||
|
TAG_PRAEFIXE_IGNORIEREN = ("sha-", "latest")
|
||||||
|
|
||||||
|
QUELLEN = ("cluster", "operating", "registry")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Normalisierung ----------------------------------------------------------
|
||||||
|
def normalisiere(ref: str) -> Optional[str]:
|
||||||
|
"""Bringt eine Image-Referenz auf eine vergleichbare Form.
|
||||||
|
|
||||||
|
kube_pod_container_info liefert voll qualifiziert
|
||||||
|
(`docker.io/library/postgres:17-alpine`), cAdvisor und die alte Liste oft
|
||||||
|
kurz (`postgres:17-alpine`). Ohne diesen Schritt meldet jeder Abgleich
|
||||||
|
Scheinluecken - beim ersten Handvergleich am 2026-08-21 war das nur deshalb
|
||||||
|
folgenlos, weil die gemischten Formen zufaellig nicht kollidierten.
|
||||||
|
|
||||||
|
Gibt None zurueck, wenn die Referenz kein Ziel sein kann. ⚠️ Insbesondere
|
||||||
|
wird ein fehlender Tag NICHT zu `:latest` ergaenzt - das erzeugte ein Ziel,
|
||||||
|
das es so nicht gibt, und genau davon wollen wir weg.
|
||||||
|
"""
|
||||||
|
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]
|
||||||
|
|
||||||
|
teile = ref.split("/", 1)
|
||||||
|
if len(teile) == 2 and ("." in teile[0] or ":" in teile[0] or teile[0] == "localhost"):
|
||||||
|
registry, rest = teile[0], teile[1]
|
||||||
|
else:
|
||||||
|
registry, rest = None, ref
|
||||||
|
|
||||||
|
# Docker Hub ist der implizite Standard - beide Schreibweisen sind dasselbe.
|
||||||
|
if registry in ("docker.io", "index.docker.io"):
|
||||||
|
registry = None
|
||||||
|
if registry is None and rest.startswith("library/"):
|
||||||
|
rest = rest[len("library/") :]
|
||||||
|
|
||||||
|
# Tag pruefen: der Doppelpunkt muss im LETZTEN Pfadteil stehen, sonst ist es
|
||||||
|
# ein Registry-Port (`myreg:5000/foo`).
|
||||||
|
if ":" not in rest.rsplit("/", 1)[-1]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return rest if registry is None else f"{registry}/{rest}"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Prometheus --------------------------------------------------------------
|
||||||
|
def promql(basis_url: str, ausdruck: str, timeout: float = 10.0) -> list:
|
||||||
|
"""Eine Instant-Query. Wirft bei allem, was nicht sauber 'success' ist."""
|
||||||
|
url = basis_url.rstrip("/") + "/api/v1/query?" + urllib.parse.urlencode(
|
||||||
|
{"query": ausdruck}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(url, timeout=timeout) as antwort:
|
||||||
|
d = json.load(antwort)
|
||||||
|
if d.get("status") != "success":
|
||||||
|
raise RuntimeError(f"Prometheus: {d.get('status')} {d.get('error', '')}".strip())
|
||||||
|
return d.get("data", {}).get("result", [])
|
||||||
|
|
||||||
|
|
||||||
|
def _images_aus_ergebnis(ergebnis: list) -> set:
|
||||||
|
ziele = set()
|
||||||
|
for reihe in ergebnis:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def ziele_cluster(basis_url: str, timeout: float = 10.0) -> set:
|
||||||
|
return _images_aus_ergebnis(promql(basis_url, AUSDRUCK_CLUSTER, timeout))
|
||||||
|
|
||||||
|
|
||||||
|
def ziele_operating(basis_url: str, timeout: float = 10.0) -> set:
|
||||||
|
return _images_aus_ergebnis(promql(basis_url, AUSDRUCK_OPERATING, timeout))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Registry (OCI Distribution v2) -----------------------------------------
|
||||||
|
# Der Token-Tanz ist Standard und anonym moeglich: /v2/ antwortet immer 401 mit
|
||||||
|
# einem realm-Verweis, der Client holt dort einen Token und wiederholt. Trivy
|
||||||
|
# macht genau das beim Ziehen - am 2026-08-01 in der AAR schon gemessen
|
||||||
|
# ("zieht anonym, keine Credentials noetig"), am 2026-08-21 erneut bestaetigt,
|
||||||
|
# diesmal auch fuer Katalog und Tag-Listen.
|
||||||
|
#
|
||||||
|
# ⚠️ Giteas eigene API (/api/v1/packages) waere bequemer - sie liefert
|
||||||
|
# created_at je Fassung in einem Aufruf -, verlangt aber einen Token. Das haette
|
||||||
|
# die Randbedingung "keine neuen Zugangsdaten" gebrochen, deshalb der Umweg
|
||||||
|
# ueber Manifest + Config-Blob.
|
||||||
|
MANIFEST_TYPEN = ",".join(
|
||||||
|
[
|
||||||
|
"application/vnd.oci.image.index.v1+json",
|
||||||
|
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||||
|
"application/vnd.oci.image.manifest.v1+json",
|
||||||
|
"application/vnd.docker.distribution.manifest.v2+json",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _hole_json(url: str, token: str = "", accept: str = "", timeout: float = 15.0):
|
||||||
|
anfrage = urllib.request.Request(url)
|
||||||
|
if token:
|
||||||
|
anfrage.add_header("Authorization", f"Bearer {token}")
|
||||||
|
if accept:
|
||||||
|
anfrage.add_header("Accept", accept)
|
||||||
|
with urllib.request.urlopen(anfrage, timeout=timeout) as antwort:
|
||||||
|
return json.load(antwort)
|
||||||
|
|
||||||
|
|
||||||
|
def registry_token(registry: str, scope: str, timeout: float = 15.0) -> str:
|
||||||
|
url = (
|
||||||
|
f"https://{registry}/v2/token?"
|
||||||
|
+ urllib.parse.urlencode({"service": "container_registry", "scope": scope})
|
||||||
|
)
|
||||||
|
return _hole_json(url, timeout=timeout).get("token", "")
|
||||||
|
|
||||||
|
|
||||||
|
def registry_repos(registry: str, timeout: float = 15.0) -> list:
|
||||||
|
token = registry_token(registry, "registry:catalog:*", timeout)
|
||||||
|
d = _hole_json(f"https://{registry}/v2/_catalog?n=1000", token, timeout=timeout)
|
||||||
|
return list(d.get("repositories") or [])
|
||||||
|
|
||||||
|
|
||||||
|
def registry_tags(registry: str, repo: str, token: str = "", timeout: float = 15.0) -> list:
|
||||||
|
token = token or registry_token(registry, f"repository:{repo}:pull", timeout)
|
||||||
|
d = _hole_json(f"https://{registry}/v2/{repo}/tags/list", token, timeout=timeout)
|
||||||
|
return list(d.get("tags") or [])
|
||||||
|
|
||||||
|
|
||||||
|
def tag_erstellt(
|
||||||
|
registry: str, repo: str, tag: str, token: str = "", timeout: float = 15.0
|
||||||
|
) -> Optional[datetime]:
|
||||||
|
"""Erstellzeitpunkt eines Tags: Manifest -> ggf. Index-Eintrag -> Config-Blob.
|
||||||
|
|
||||||
|
⚠️ Nach dem NAMEN zu sortieren waere falsch: `v0.10.0` steht lexikografisch
|
||||||
|
vor `v0.9.0`. Massgeblich ist der Zeitstempel.
|
||||||
|
"""
|
||||||
|
token = token or registry_token(registry, f"repository:{repo}:pull", timeout)
|
||||||
|
basis = f"https://{registry}/v2/{repo}"
|
||||||
|
manifest = _hole_json(f"{basis}/manifests/{tag}", token, MANIFEST_TYPEN, timeout)
|
||||||
|
|
||||||
|
# Multi-Arch: der Index verweist auf Einzelmanifeste - eines genuegt, die
|
||||||
|
# Erstellzeit unterscheidet sich zwischen den Architekturen nicht relevant.
|
||||||
|
if "manifests" in manifest and manifest.get("manifests"):
|
||||||
|
digest = manifest["manifests"][0]["digest"]
|
||||||
|
manifest = _hole_json(f"{basis}/manifests/{digest}", token, MANIFEST_TYPEN, timeout)
|
||||||
|
|
||||||
|
digest = (manifest.get("config") or {}).get("digest")
|
||||||
|
if not digest:
|
||||||
|
return None
|
||||||
|
config = _hole_json(f"{basis}/blobs/{digest}", token, timeout=timeout)
|
||||||
|
roh = config.get("created")
|
||||||
|
if not roh:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(roh.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ziele_registry(registry: str, je_repo: int = 3, laufend: Optional[set] = None,
|
||||||
|
timeout: float = 15.0) -> set:
|
||||||
|
"""Die juengsten `je_repo` Fassungen jedes Repos.
|
||||||
|
|
||||||
|
Reichweite laut Gate 1 (Entscheidung sorb, Option C): Bestand plus das, was
|
||||||
|
ein Rollback realistisch treffen kann. Ein Rollback-Ziel mit bekannter
|
||||||
|
Luecke ist der einzige Fall, in dem ein nicht laufendes Image betrieblich
|
||||||
|
zaehlt - bei v0.1.0 rollt niemand mehr zurueck.
|
||||||
|
|
||||||
|
⚠️ EINENGUNG (#0051, Entscheidung sorb 2026-08-21): Beruecksichtigt werden nur
|
||||||
|
Repos, von denen gerade MINDESTENS EIN Tag laeuft. Ohne diese Bedingung
|
||||||
|
kamen reine Bau-Artefakte in die Zielmenge - `element-desktop-build` und
|
||||||
|
`windows-vm` trugen zusammen 61 CRITICAL, davon 1 behebbar. Befunde ueber
|
||||||
|
Images, die niemand betreibt, sind Rauschen, das wie Signal aussieht.
|
||||||
|
|
||||||
|
Die Einengung ist ABGELEITET, nicht gepflegt: `laufend` ist dieselbe Menge,
|
||||||
|
die die Herleitung ohnehin bildet. Keine zweite Liste (ADR-0026).
|
||||||
|
"""
|
||||||
|
ziele = set()
|
||||||
|
for repo in registry_repos(registry, timeout):
|
||||||
|
if laufend is not None and not any(
|
||||||
|
z.startswith(f"{registry}/{repo}:") for z in laufend
|
||||||
|
):
|
||||||
|
# Kein Tag dieses Repos laeuft - Bau-Artefakt, kein Rollback-Ziel.
|
||||||
|
continue
|
||||||
|
token = registry_token(registry, f"repository:{repo}:pull", timeout)
|
||||||
|
kandidaten = [
|
||||||
|
t
|
||||||
|
for t in registry_tags(registry, repo, token, timeout)
|
||||||
|
if not t.startswith(TAG_PRAEFIXE_IGNORIEREN)
|
||||||
|
]
|
||||||
|
datiert = []
|
||||||
|
for tag in kandidaten:
|
||||||
|
try:
|
||||||
|
wann = tag_erstellt(registry, repo, tag, token, timeout)
|
||||||
|
except (urllib.error.URLError, OSError, ValueError, KeyError):
|
||||||
|
# Ein einzelner unlesbarer Tag darf das Repo nicht kosten.
|
||||||
|
wann = None
|
||||||
|
if wann is not None:
|
||||||
|
datiert.append((wann, tag))
|
||||||
|
datiert.sort(reverse=True)
|
||||||
|
for _, tag in datiert[:je_repo]:
|
||||||
|
n = normalisiere(f"{registry}/{repo}:{tag}")
|
||||||
|
if n:
|
||||||
|
ziele.add(n)
|
||||||
|
return ziele
|
||||||
|
|
||||||
|
|
||||||
|
# --- Herleitung --------------------------------------------------------------
|
||||||
|
class Herleitung(NamedTuple):
|
||||||
|
ziele: set
|
||||||
|
je_quelle: dict # Quelle -> set
|
||||||
|
fehler: dict # Quelle -> Meldung (nur bei Ausfall)
|
||||||
|
stand: dict # Quelle -> unix-ts des letzten Erfolgs
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vollstaendig(self) -> bool:
|
||||||
|
return not self.fehler
|
||||||
|
|
||||||
|
@property
|
||||||
|
def brauchbar(self) -> bool:
|
||||||
|
"""Mindestens eine Quelle hat geliefert.
|
||||||
|
|
||||||
|
⚠️ Nur dann darf targets.txt ueberschrieben und duerfen Berichte
|
||||||
|
geloescht werden. Sonst raeumt ein Neustart waehrend eines
|
||||||
|
Prometheus-Ausfalls den gesamten Bestand ab.
|
||||||
|
"""
|
||||||
|
return bool(self.ziele)
|
||||||
|
|
||||||
|
|
||||||
|
def herleiten(cfg: dict, vorheriger_stand: Optional[dict] = None,
|
||||||
|
vorherige_ziele: Optional[dict] = None) -> Herleitung:
|
||||||
|
"""Fragt alle drei Quellen. Ein Ausfall kostet nur die eigene Quelle.
|
||||||
|
|
||||||
|
⚠️ REIHENFOLGE IST BEDEUTSAM: Die Registry-Auswahl braucht den laufenden
|
||||||
|
Bestand, um Bau-Artefakte auszuschliessen (#0051). Faellt der Bestand aus,
|
||||||
|
faellt die Registry-Auswahl mit - und das ist richtig so: Eine
|
||||||
|
Registry-Auswahl ohne Bestandswissen koennte nicht unterscheiden, was ein
|
||||||
|
Rollback-Ziel ist und was ein Bau-Artefakt.
|
||||||
|
"""
|
||||||
|
stand = dict(vorheriger_stand or {})
|
||||||
|
vorher = dict(vorherige_ziele or {})
|
||||||
|
je_quelle, fehler = {}, {}
|
||||||
|
jetzt = time.time()
|
||||||
|
|
||||||
|
def versuche(name, hole):
|
||||||
|
if not cfg.get(f"{name}_an", True):
|
||||||
|
je_quelle[name] = set()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
neu = hole()
|
||||||
|
except Exception as e: # noqa: BLE001 - bewusst breit
|
||||||
|
je_quelle[name] = set(vorher.get(name, ()))
|
||||||
|
fehler[name] = f"{type(e).__name__}: {e}"
|
||||||
|
return
|
||||||
|
# ⚠️ EINE ANTWORT KANN ERFOLGREICH UND TROTZDEM WERTLOS SEIN.
|
||||||
|
# Am 2026-08-23 hat der k3s-Neustart kube-state-metrics und Alloy
|
||||||
|
# lahmgelegt; `kube_pod_container_info` war in Prometheus daraufhin
|
||||||
|
# LEER. Die Abfrage lief sauber durch, lieferte null Zeilen - und
|
||||||
|
# galt als Erfolg. Damit fiel die Cluster-Quelle von 39 Zielen auf 0,
|
||||||
|
# die Gesamtmenge von 54 auf 12, der Scanner loeschte 42 Berichte, und
|
||||||
|
# das Dashboard meldete Deckung 1,0. Keine der drei Regeln schlug an:
|
||||||
|
# die Menge war nicht leer, und der Zeitstempel war frisch.
|
||||||
|
#
|
||||||
|
# Eine Quelle, die schon einmal geliefert hat und jetzt NICHTS liefert,
|
||||||
|
# gilt deshalb als Ausfall: alter Stand bleibt stehen, der Zeitstempel
|
||||||
|
# altert, CveTargetSourceStale greift. Lieber eine veraltete Menge als
|
||||||
|
# eine, die stillschweigend Ziele fallen laesst.
|
||||||
|
if not neu and vorher.get(name):
|
||||||
|
je_quelle[name] = set(vorher[name])
|
||||||
|
fehler[name] = (f"leere Antwort trotz {len(vorher[name])} Zielen beim "
|
||||||
|
f"letzten Mal - alter Stand behalten")
|
||||||
|
return
|
||||||
|
je_quelle[name] = neu
|
||||||
|
stand[name] = jetzt
|
||||||
|
|
||||||
|
versuche("cluster", lambda: ziele_cluster(cfg["prometheus"], cfg["timeout"]))
|
||||||
|
versuche("operating", lambda: ziele_operating(cfg["prometheus"], cfg["timeout"]))
|
||||||
|
|
||||||
|
laufend = je_quelle.get("cluster", set()) | je_quelle.get("operating", set())
|
||||||
|
if laufend:
|
||||||
|
versuche("registry", lambda: ziele_registry(
|
||||||
|
cfg["registry"], cfg["je_repo"], laufend, cfg["timeout"]))
|
||||||
|
else:
|
||||||
|
# Ohne Bestandswissen keine Registry-Auswahl - lieber eine kleinere
|
||||||
|
# Menge als eine, die Bau-Artefakte mitschleppt. Als Fehler gemeldet,
|
||||||
|
# damit es nicht wie Erfolg aussieht.
|
||||||
|
je_quelle["registry"] = set()
|
||||||
|
if "registry" not in fehler:
|
||||||
|
fehler["registry"] = "uebersprungen: kein laufender Bestand bekannt"
|
||||||
|
|
||||||
|
ziele = set().union(*je_quelle.values()) if je_quelle else set()
|
||||||
|
return Herleitung(ziele=ziele, je_quelle=je_quelle, fehler=fehler, stand=stand)
|
||||||
|
|
||||||
|
|
||||||
|
def cfg_aus_umgebung() -> dict:
|
||||||
|
return {
|
||||||
|
"prometheus": os.environ.get("PROMETHEUS_URL", "http://prometheus:9090"),
|
||||||
|
"registry": os.environ.get("REGISTRY_HOST", "rohana.axion1337.de"),
|
||||||
|
"je_repo": int(os.environ.get("REGISTRY_TAGS_JE_REPO", "3")),
|
||||||
|
"timeout": float(os.environ.get("QUELLEN_TIMEOUT", "15")),
|
||||||
|
"cluster_an": os.environ.get("QUELLE_CLUSTER", "1") != "0",
|
||||||
|
"operating_an": os.environ.get("QUELLE_OPERATING", "1") != "0",
|
||||||
|
"registry_an": os.environ.get("QUELLE_REGISTRY", "1") != "0",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Handlauf zur Uebergabe: zeigt die Mengen, ohne etwas zu schreiben.
|
||||||
|
h = herleiten(cfg_aus_umgebung())
|
||||||
|
for q in QUELLEN:
|
||||||
|
print(f"{q:10} {len(h.je_quelle.get(q, ())):3} {h.fehler.get(q, '')}")
|
||||||
|
print(f"{'gesamt':10} {len(h.ziele):3}")
|
||||||
|
for z in sorted(h.ziele):
|
||||||
|
print(" ", z)
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
#!/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 CollectTest(unittest.TestCase):
|
||||||
|
"""⚠️ Diese Klasse prueft die ECHTE collect(), nicht die nachgebaute Rechnung
|
||||||
|
in RechnungTest. Der Unterschied ist an diesem Projekt schon einmal teuer
|
||||||
|
geworden: Am 2026-08-22 stand in collect() ein `and ziel in soll`, das den
|
||||||
|
Zaehler fuer verwaiste Entscheidungen blind machte, sobald ein Image ganz aus
|
||||||
|
dem Bestand fiel - und RechnungTest konnte das nicht sehen, weil dort die
|
||||||
|
Bedingung gar nicht stand.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def laufe(self, berichte, entscheidungen, soll):
|
||||||
|
res = tempfile.mkdtemp()
|
||||||
|
for i, (ziel, cves) in enumerate(berichte):
|
||||||
|
json.dump({"ArtifactName": ziel, "ArtifactType": "container_image",
|
||||||
|
"Results": [{"Vulnerabilities": [
|
||||||
|
{"VulnerabilityID": c, "Severity": "CRITICAL", "PkgName": "p",
|
||||||
|
"InstalledVersion": "1", "FixedVersion": ""} for c in cves]}]},
|
||||||
|
open(os.path.join(res, f"b{i}.json"), "w"))
|
||||||
|
pfad = schreibe({"eintraege": entscheidungen})
|
||||||
|
alt_res, alt_ent = exporter.RESULTS, exporter.ENTSCHEIDUNGEN_FILE
|
||||||
|
exporter.RESULTS, exporter.ENTSCHEIDUNGEN_FILE = res, pfad
|
||||||
|
exporter.ziel_cache._letzte = exporter.ziel_herleitung.Herleitung(
|
||||||
|
ziele=set(soll), je_quelle={"cluster": set(soll)}, fehler={},
|
||||||
|
stand={"cluster": exporter.time.time()})
|
||||||
|
exporter.ziel_cache._geholt_um = exporter.time.time()
|
||||||
|
try:
|
||||||
|
zeilen = exporter.collect().splitlines()
|
||||||
|
finally:
|
||||||
|
exporter.RESULTS, exporter.ENTSCHEIDUNGEN_FILE = alt_res, alt_ent
|
||||||
|
self.zeilen = zeilen
|
||||||
|
return {z.split(" ")[0]: z.split(" ")[1] for z in zeilen if " " in z and "{" not in z}
|
||||||
|
|
||||||
|
E = [{"ziel": "a:1", "art": "hingenommen", "grund": "x" * 70,
|
||||||
|
"pruefen_am": "2099-01-01", "cves": ["CVE-1"]}]
|
||||||
|
|
||||||
|
def test_entschieden_und_offen_werden_getrennt_gezaehlt(self):
|
||||||
|
m = self.laufe([("a:1", ["CVE-1", "CVE-2"])], self.E, ["a:1"])
|
||||||
|
self.assertEqual(m["cve_critical_entschieden"], "1")
|
||||||
|
self.assertEqual(m["cve_critical_offen"], "1")
|
||||||
|
self.assertEqual(m["cve_entscheidungen_ohne_befund"], "0")
|
||||||
|
|
||||||
|
def test_entscheidung_zu_einem_VERSCHWUNDENEN_image_faellt_auf(self):
|
||||||
|
# Der Fall vom 2026-08-22: das Image ist ersetzt, das Ziel steht nicht
|
||||||
|
# mehr im Soll, der Bericht ist weg. Die Entscheidung ist Altpapier und
|
||||||
|
# muss sich melden - vorher meldete sie sich NICHT.
|
||||||
|
m = self.laufe([("b:1", [])], self.E, ["b:1"])
|
||||||
|
self.assertEqual(m["cve_entscheidungen_ohne_befund"], "1")
|
||||||
|
|
||||||
|
def test_gegenprobe_eine_gebrauchte_entscheidung_meldet_sich_nicht(self):
|
||||||
|
m = self.laufe([("a:1", ["CVE-1"])], self.E, ["a:1"])
|
||||||
|
self.assertEqual(m["cve_entscheidungen_ohne_befund"], "0")
|
||||||
|
self.assertEqual(m["cve_critical_offen"], "0")
|
||||||
|
|
||||||
|
def test_der_entschiedene_befund_bleibt_SICHTBAR(self):
|
||||||
|
# Der Kern der Entscheidung gegen eine Trivy-Ignore-Datei: entschieden
|
||||||
|
# heisst gezaehlt, nicht versteckt. Waere er unterdrueckt, fehlte diese
|
||||||
|
# Serie - und "0, weil behoben" saehe aus wie "0, weil weggeschaut".
|
||||||
|
m = self.laufe([("a:1", ["CVE-1"])], self.E, ["a:1"])
|
||||||
|
self.assertEqual(m["cve_critical_entschieden"], "1")
|
||||||
|
treffer = [z for z in self.zeilen if z.startswith("trivy_vuln_info{") and "CVE-1" in z]
|
||||||
|
self.assertEqual(len(treffer), 1, "der entschiedene Befund fehlt in trivy_vuln_info")
|
||||||
|
# Gegenprobe: ein Ziel ohne Befund erzeugt auch keine Serie.
|
||||||
|
self.laufe([("a:1", [])], self.E, ["a:1"])
|
||||||
|
self.assertFalse([z for z in self.zeilen if z.startswith("trivy_vuln_info{")])
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Zusicherungen fuer scan-loop.sh (#0106), ohne Trivy und ohne Netz:
|
||||||
|
# sh cve/test_scan_loop.sh
|
||||||
|
#
|
||||||
|
# Gate 3 hatte diese Faelle bei test_targets.py vorgesehen. Sie stehen hier,
|
||||||
|
# weil die Logik in POSIX-Shell lebt - ein Python-Test haette sie nachgebaut
|
||||||
|
# statt geprueft, und ein nachgebauter Test prueft den Nachbau.
|
||||||
|
#
|
||||||
|
# ⚠️ Der gefaehrliche Fall ist nicht "loescht zu wenig", sondern "loescht zu
|
||||||
|
# viel": Ohne Zielliste darf NICHTS entfernt werden, sonst raeumt ein Neustart
|
||||||
|
# waehrend eines Prometheus-Ausfalls den gesamten Bestand ab. Deshalb steht zu
|
||||||
|
# jeder Zusicherung ihre Gegenprobe.
|
||||||
|
set -u
|
||||||
|
FEHLER=0
|
||||||
|
ARBEIT=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$ARBEIT"' EXIT
|
||||||
|
|
||||||
|
pruefe() { # name erwartet ist
|
||||||
|
if [ "$2" = "$3" ]; then
|
||||||
|
echo " ok $1"
|
||||||
|
else
|
||||||
|
echo " FEHL $1 — erwartet '$2', bekommen '$3'"
|
||||||
|
FEHLER=$((FEHLER + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ⚠️ Die ECHTE runde() wird geladen, nicht nachgebaut. Ein nachgebauter Test
|
||||||
|
# prueft den Nachbau: er kann gruen bleiben, waehrend die ausgelieferte Datei
|
||||||
|
# kaputt ist. Die Ladewache in scan-loop.sh verhindert, dass dabei die
|
||||||
|
# Endlosschleife anlaeuft.
|
||||||
|
ARBEIT_RESULTS="$ARBEIT/results"
|
||||||
|
ARBEIT_TARGETS="$ARBEIT/targets.txt"
|
||||||
|
RESULTS_DIR="$ARBEIT_RESULTS"
|
||||||
|
TARGETS_FILE="$ARBEIT_TARGETS"
|
||||||
|
SCAN_LOOP_NUR_LADEN=1
|
||||||
|
export RESULTS_DIR TARGETS_FILE
|
||||||
|
. "$(dirname "$0")/scan-loop.sh"
|
||||||
|
# Trivy durch eine Attrappe ersetzen - die einzige Stelle, die Netz braeuchte.
|
||||||
|
scanne() { : > "$2"; }
|
||||||
|
|
||||||
|
# Aus der ECHTEN Ausgabe lesen, statt eine eigene Form zu erwarten.
|
||||||
|
entfernt_aus() { echo "$1" | sed -n "s/.*, \([0-9]*\) verwaiste.*/\1/p" | tail -1; }
|
||||||
|
ausgesetzt_aus() { echo "$1" | grep -q "^warte:" && echo AUSGESETZT || echo GELAUFEN; }
|
||||||
|
|
||||||
|
mkdir -p "$ARBEIT_RESULTS"
|
||||||
|
|
||||||
|
echo "1) Ohne Zielliste wird NICHTS geloescht (der gefaehrliche Fall)"
|
||||||
|
: > "$ARBEIT/results/altbestand.json"
|
||||||
|
: > "$ARBEIT/results/zweiter.json"
|
||||||
|
rm -f "$ARBEIT_TARGETS"
|
||||||
|
ergebnis=$(runde)
|
||||||
|
pruefe "Runde ausgesetzt" "AUSGESETZT" "$(ausgesetzt_aus "$ergebnis")"
|
||||||
|
pruefe "Berichte unberuehrt" "2" "$(ls -1 "$ARBEIT_RESULTS" | wc -l | tr -d ' ')"
|
||||||
|
|
||||||
|
echo "2) Leere Zielliste ebenso"
|
||||||
|
: > "$ARBEIT_TARGETS"
|
||||||
|
ergebnis=$(runde)
|
||||||
|
pruefe "Runde ausgesetzt" "AUSGESETZT" "$(ausgesetzt_aus "$ergebnis")"
|
||||||
|
pruefe "Berichte unberuehrt" "2" "$(ls -1 "$ARBEIT_RESULTS" | wc -l | tr -d ' ')"
|
||||||
|
|
||||||
|
echo "3) Mit Zielliste: verwaiste Berichte verschwinden, neue entstehen"
|
||||||
|
printf '# Kopfzeile\npostgres:17-alpine\nrohana.axion1337.de/sorb/threadnet-web:v0.6.0\n' > "$ARBEIT_TARGETS"
|
||||||
|
ergebnis=$(runde)
|
||||||
|
pruefe "zwei verwaiste entfernt" "2" "$(entfernt_aus "$ergebnis")"
|
||||||
|
pruefe "genau die zwei Ziele bleiben" "2" "$(ls -1 "$ARBEIT_RESULTS" | wc -l | tr -d ' ')"
|
||||||
|
pruefe "altbestand ist fort" "" "$(ls "$ARBEIT_RESULTS" | grep altbestand || true)"
|
||||||
|
pruefe "v0.6.0 ist da" "rohana.axion1337.de_sorb_threadnet-web_v0.6.0.json" \
|
||||||
|
"$(ls "$ARBEIT_RESULTS" | grep v0.6.0)"
|
||||||
|
|
||||||
|
echo "4) Der Fall vom 2026-08-20: v0.3.0 faellt aus der Menge und verstummt"
|
||||||
|
: > "$ARBEIT/results/rohana.axion1337.de_sorb_threadnet-web_v0.3.0.json"
|
||||||
|
ergebnis=$(runde)
|
||||||
|
pruefe "v0.3.0 entfernt" "1" "$(entfernt_aus "$ergebnis")"
|
||||||
|
pruefe "v0.3.0 ist fort" "" "$(ls "$ARBEIT_RESULTS" | grep 'v0.3.0' || true)"
|
||||||
|
|
||||||
|
echo "5) Host-Scans werden nicht nach dieser Liste beurteilt"
|
||||||
|
: > "$ARBEIT/results/etwas__host__cfgmon.json"
|
||||||
|
ergebnis=$(runde)
|
||||||
|
pruefe "nichts entfernt" "0" "$(entfernt_aus "$ergebnis")"
|
||||||
|
pruefe "Host-Bericht bleibt" "etwas__host__cfgmon.json" \
|
||||||
|
"$(ls "$ARBEIT_RESULTS" | grep __host__)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$FEHLER" -eq 0 ]; then
|
||||||
|
echo "ALLE ZUSICHERUNGEN GRUEN"
|
||||||
|
else
|
||||||
|
echo "$FEHLER ZUSICHERUNG(EN) ROT"
|
||||||
|
fi
|
||||||
|
exit "$FEHLER"
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Zusicherungen fuer targets.py (#0106). Stdlib, laeuft ohne Netz:
|
||||||
|
# python3 test_targets.py
|
||||||
|
#
|
||||||
|
# Die Antworten sind AUFGEZEICHNET, nicht erfunden - abgenommen am 2026-08-21
|
||||||
|
# von der laufenden Anlage (Prometheus auf dem Betriebs-Host, rohana /v2).
|
||||||
|
#
|
||||||
|
# ⚠️ Jede Zusicherung hat ihre GEGENPROBE. Eine Pruefung, die nur je "gleich"
|
||||||
|
# gesagt hat, ist eine Vermutung - das ist in diesem Projekt die haeufigste
|
||||||
|
# Fehlerklasse ("meldet Erfolg, ist aber blind"), zuletzt belegt in der AAR vom
|
||||||
|
# 2026-08-01. Deshalb steht neben jedem "muss zusammenfallen" ein "darf NICHT
|
||||||
|
# zusammenfallen".
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import targets
|
||||||
|
|
||||||
|
|
||||||
|
def ts(jahr, monat, tag):
|
||||||
|
return datetime(jahr, monat, tag, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Aufgezeichnete Antworten ------------------------------------------------
|
||||||
|
# Auszug aus `count by (image) (kube_pod_container_info)`, 2026-08-21.
|
||||||
|
PROM_CLUSTER = [
|
||||||
|
{"metric": {"image": "docker.io/library/postgres:17-alpine"}},
|
||||||
|
{"metric": {"image": "docker.io/rancher/mirrored-library-traefik:3.6.10"}},
|
||||||
|
{"metric": {"image": "rohana.axion1337.de/sorb/threadnet-web:v0.6.0"}},
|
||||||
|
{"metric": {"image": "oci.element.io/synapse:v1.151.0-ess.1"}},
|
||||||
|
]
|
||||||
|
# Auszug aus `container_last_seen{...}` - BEIDE Jobs, damit die Abgrenzung
|
||||||
|
# ueberhaupt etwas zu tun hat.
|
||||||
|
PROM_CADVISOR_GEMISCHT = [
|
||||||
|
{"metric": {"image": "gitea/gitea:1.27.0", "job": "operating_cadvisor"}},
|
||||||
|
{"metric": {"image": "traefik:v3.7.9", "job": "operating_cadvisor"}},
|
||||||
|
{"metric": {"image": "ghcr.io/coollabsio/coolify:4.3.9", "job": "gameserver_cadvisor"}},
|
||||||
|
{"metric": {"image": "redis:7-alpine", "job": "gameserver_cadvisor"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Normalisierung(unittest.TestCase):
|
||||||
|
def test_docker_hub_schreibweisen_fallen_zusammen(self):
|
||||||
|
formen = [
|
||||||
|
"docker.io/library/postgres:17-alpine",
|
||||||
|
"index.docker.io/library/postgres:17-alpine",
|
||||||
|
"library/postgres:17-alpine",
|
||||||
|
"postgres:17-alpine",
|
||||||
|
]
|
||||||
|
ergebnisse = {targets.normalisiere(f) for f in formen}
|
||||||
|
self.assertEqual(ergebnisse, {"postgres:17-alpine"})
|
||||||
|
|
||||||
|
def test_gegenprobe_verschiedene_fassungen_fallen_NICHT_zusammen(self):
|
||||||
|
# Ohne diese Zusicherung koennte normalisiere() alles auf denselben Wert
|
||||||
|
# abbilden und der Test oben waere trotzdem gruen.
|
||||||
|
self.assertNotEqual(
|
||||||
|
targets.normalisiere("postgres:15-alpine"),
|
||||||
|
targets.normalisiere("postgres:17-alpine"),
|
||||||
|
)
|
||||||
|
self.assertNotEqual(
|
||||||
|
targets.normalisiere("rohana.axion1337.de/sorb/threadnet-web:v0.3.0"),
|
||||||
|
targets.normalisiere("rohana.axion1337.de/sorb/threadnet-web:v0.6.0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_fremde_registry_bleibt_unangetastet(self):
|
||||||
|
for ref in (
|
||||||
|
"rohana.axion1337.de/sorb/threadnet-web:v0.6.0",
|
||||||
|
"ghcr.io/fluxcd/source-controller:v1.8.2",
|
||||||
|
"quay.io/jetstack/cert-manager-webhook:v1.14.0",
|
||||||
|
):
|
||||||
|
self.assertEqual(targets.normalisiere(ref), ref)
|
||||||
|
|
||||||
|
def test_ohne_tag_wird_verworfen_und_NICHT_zu_latest(self):
|
||||||
|
for ref in ("postgres", "docker.io/library/postgres", "ghcr.io/a/b"):
|
||||||
|
self.assertIsNone(targets.normalisiere(ref))
|
||||||
|
|
||||||
|
def test_digest_wird_abgetrennt_tag_bleibt(self):
|
||||||
|
self.assertEqual(
|
||||||
|
targets.normalisiere("postgres:17-alpine@sha256:" + "ab" * 32),
|
||||||
|
"postgres:17-alpine",
|
||||||
|
)
|
||||||
|
# Nur Digest, kein Tag -> kein Ziel.
|
||||||
|
self.assertIsNone(targets.normalisiere("postgres@sha256:" + "ab" * 32))
|
||||||
|
|
||||||
|
def test_registry_mit_port_wird_nicht_als_tag_gelesen(self):
|
||||||
|
self.assertEqual(targets.normalisiere("reg.lan:5000/a/b:v1"), "reg.lan:5000/a/b:v1")
|
||||||
|
self.assertIsNone(targets.normalisiere("reg.lan:5000/a/b"))
|
||||||
|
|
||||||
|
def test_leeres_und_muell(self):
|
||||||
|
for ref in ("", " ", None):
|
||||||
|
self.assertIsNone(targets.normalisiere(ref))
|
||||||
|
|
||||||
|
|
||||||
|
class Quellentrennung(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._echt = targets.promql
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
targets.promql = self._echt
|
||||||
|
|
||||||
|
def test_operating_nimmt_gameserver_nicht_mit(self):
|
||||||
|
# Der Ausdruck filtert serverseitig; hier wird geprueft, dass er den
|
||||||
|
# Filter ueberhaupt traegt - sonst faellt die Abgrenzung still weg.
|
||||||
|
self.assertIn('job="operating_cadvisor"', targets.AUSDRUCK_OPERATING)
|
||||||
|
|
||||||
|
gesehen = {}
|
||||||
|
|
||||||
|
def fake(basis, ausdruck, timeout=10.0):
|
||||||
|
gesehen["ausdruck"] = ausdruck
|
||||||
|
# Prometheus wuerde bereits gefiltert antworten - wir simulieren das,
|
||||||
|
# indem wir den Filter anwenden.
|
||||||
|
job = "operating_cadvisor" if 'job="operating_cadvisor"' in ausdruck else None
|
||||||
|
return [r for r in PROM_CADVISOR_GEMISCHT if r["metric"]["job"] == job]
|
||||||
|
|
||||||
|
targets.promql = fake
|
||||||
|
ziele = targets.ziele_operating("http://x")
|
||||||
|
self.assertEqual(ziele, {"gitea/gitea:1.27.0", "traefik:v3.7.9"})
|
||||||
|
|
||||||
|
def test_gegenprobe_ohne_filter_kaeme_gameserver_mit(self):
|
||||||
|
# Beweist, dass die Zusicherung oben ueberhaupt etwas prueft.
|
||||||
|
def fake_ohne_filter(basis, ausdruck, timeout=10.0):
|
||||||
|
return PROM_CADVISOR_GEMISCHT
|
||||||
|
|
||||||
|
targets.promql = fake_ohne_filter
|
||||||
|
ziele = targets.ziele_operating("http://x")
|
||||||
|
self.assertIn("redis:7-alpine", ziele)
|
||||||
|
|
||||||
|
def test_cluster_normalisiert_beim_einlesen(self):
|
||||||
|
targets.promql = lambda b, a, timeout=10.0: PROM_CLUSTER
|
||||||
|
ziele = targets.ziele_cluster("http://x")
|
||||||
|
self.assertIn("postgres:17-alpine", ziele)
|
||||||
|
self.assertIn("rancher/mirrored-library-traefik:3.6.10", ziele)
|
||||||
|
self.assertNotIn("docker.io/library/postgres:17-alpine", ziele)
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryAuswahl(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._repos, self._tags, self._erstellt = (
|
||||||
|
targets.registry_repos,
|
||||||
|
targets.registry_tags,
|
||||||
|
targets.tag_erstellt,
|
||||||
|
)
|
||||||
|
targets.registry_token = lambda *a, **k: "egal"
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
targets.registry_repos, targets.registry_tags, targets.tag_erstellt = (
|
||||||
|
self._repos,
|
||||||
|
self._tags,
|
||||||
|
self._erstellt,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _stelle(self, tags, zeiten):
|
||||||
|
targets.registry_repos = lambda reg, timeout=15.0: ["sorb/x"]
|
||||||
|
targets.registry_tags = lambda reg, repo, token="", timeout=15.0: tags
|
||||||
|
targets.tag_erstellt = lambda reg, repo, tag, token="", timeout=15.0: zeiten.get(tag)
|
||||||
|
|
||||||
|
def test_juengste_drei_nach_ZEIT_nicht_nach_name(self):
|
||||||
|
# ⚠️ Der Kern: lexikografisch stuende v0.9.0 vor v0.10.0.
|
||||||
|
tags = ["v0.9.0", "v0.10.0", "v0.8.0", "v0.7.0"]
|
||||||
|
zeiten = {
|
||||||
|
"v0.7.0": ts(2026, 1, 1),
|
||||||
|
"v0.8.0": ts(2026, 2, 1),
|
||||||
|
"v0.9.0": ts(2026, 3, 1),
|
||||||
|
"v0.10.0": ts(2026, 4, 1),
|
||||||
|
}
|
||||||
|
self._stelle(tags, zeiten)
|
||||||
|
ziele = targets.ziele_registry("reg.example", je_repo=3)
|
||||||
|
self.assertEqual(
|
||||||
|
ziele,
|
||||||
|
{"reg.example/sorb/x:v0.10.0", "reg.example/sorb/x:v0.9.0", "reg.example/sorb/x:v0.8.0"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_gegenprobe_namenssortierung_haette_anders_gewaehlt(self):
|
||||||
|
# Belegt, dass der Test oben nicht zufaellig gruen ist: nach Namen
|
||||||
|
# sortiert faellt die JUENGSTE Fassung heraus und eine alte kommt mit.
|
||||||
|
tags = ["v0.9.0", "v0.10.0", "v0.8.0", "v0.7.0"]
|
||||||
|
nach_name = sorted(tags, reverse=True)[:3]
|
||||||
|
self.assertNotIn("v0.10.0", nach_name) # die juengste - waere verloren
|
||||||
|
self.assertIn("v0.7.0", nach_name) # die aelteste - waere mitgekommen
|
||||||
|
|
||||||
|
def test_bau_artefakte_fallen_raus(self):
|
||||||
|
tags = ["v1.0.0", "sha-13547676", "latest-ci", "latest"]
|
||||||
|
zeiten = {t: ts(2026, 5, 10) for t in tags}
|
||||||
|
self._stelle(tags, zeiten)
|
||||||
|
ziele = targets.ziele_registry("reg.example", je_repo=5)
|
||||||
|
self.assertEqual(ziele, {"reg.example/sorb/x:v1.0.0"})
|
||||||
|
|
||||||
|
def _stelle_repos(self, repos):
|
||||||
|
targets.registry_repos = lambda reg, timeout=15.0: repos
|
||||||
|
targets.registry_tags = lambda reg, repo, token="", timeout=15.0: ["v1.0.0"]
|
||||||
|
targets.tag_erstellt = lambda reg, repo, tag, token="", timeout=15.0: ts(2026, 5, 1)
|
||||||
|
|
||||||
|
def test_nur_repos_mit_laufendem_tag_zaehlen(self):
|
||||||
|
# ⚠️ Der Kern der Einengung (#0051): Von drei Repos laeuft nur eines.
|
||||||
|
# element-desktop-build und windows-vm sind Bau-Artefakte und trugen
|
||||||
|
# zusammen 61 CRITICAL, davon 1 behebbar.
|
||||||
|
self._stelle_repos(["sorb/threadnet-web", "sorb/element-desktop-build", "sorb/windows-vm"])
|
||||||
|
laufend = {"reg.example/sorb/threadnet-web:v0.6.0"}
|
||||||
|
ziele = targets.ziele_registry("reg.example", je_repo=3, laufend=laufend)
|
||||||
|
self.assertEqual(ziele, {"reg.example/sorb/threadnet-web:v1.0.0"})
|
||||||
|
|
||||||
|
def test_gegenprobe_ohne_einengung_kaemen_die_bau_artefakte_mit(self):
|
||||||
|
# Belegt, dass der Test darueber ueberhaupt etwas prueft: ohne
|
||||||
|
# Bestandswissen liefert dieselbe Funktion alle drei Repos.
|
||||||
|
self._stelle_repos(["sorb/threadnet-web", "sorb/element-desktop-build", "sorb/windows-vm"])
|
||||||
|
ziele = targets.ziele_registry("reg.example", je_repo=3, laufend=None)
|
||||||
|
self.assertEqual(len(ziele), 3)
|
||||||
|
self.assertIn("reg.example/sorb/windows-vm:v1.0.0", ziele)
|
||||||
|
|
||||||
|
def test_einengung_trifft_nur_das_repo_nicht_den_tag(self):
|
||||||
|
# Laeuft v0.6.0, ist v0.5.4 trotzdem ein gueltiges Rollback-Ziel -
|
||||||
|
# die Einengung schliesst REPOS aus, nicht alte Fassungen.
|
||||||
|
targets.registry_repos = lambda reg, timeout=15.0: ["sorb/threadnet-web"]
|
||||||
|
targets.registry_tags = lambda reg, repo, token="", timeout=15.0: ["v0.6.0", "v0.5.4"]
|
||||||
|
targets.tag_erstellt = lambda reg, repo, tag, token="", timeout=15.0: (
|
||||||
|
ts(2026, 8, 19) if tag == "v0.6.0" else ts(2026, 8, 17))
|
||||||
|
laufend = {"reg.example/sorb/threadnet-web:v0.6.0"}
|
||||||
|
ziele = targets.ziele_registry("reg.example", je_repo=3, laufend=laufend)
|
||||||
|
self.assertIn("reg.example/sorb/threadnet-web:v0.5.4", ziele)
|
||||||
|
|
||||||
|
def test_tag_ohne_zeitstempel_wird_uebersprungen_nicht_geraten(self):
|
||||||
|
tags = ["v1.0.0", "v2.0.0"]
|
||||||
|
zeiten = {"v1.0.0": ts(2026, 5, 10)} # v2.0.0 liefert None
|
||||||
|
self._stelle(tags, zeiten)
|
||||||
|
ziele = targets.ziele_registry("reg.example", je_repo=5)
|
||||||
|
self.assertEqual(ziele, {"reg.example/sorb/x:v1.0.0"})
|
||||||
|
|
||||||
|
|
||||||
|
class Ausfallverhalten(unittest.TestCase):
|
||||||
|
"""⚠️ Der eigentliche Kern (ADR-0026): Ein Ausfall der Herleitung darf nicht
|
||||||
|
wie vollstaendige Deckung aussehen."""
|
||||||
|
|
||||||
|
CFG = {
|
||||||
|
"prometheus": "http://x",
|
||||||
|
"registry": "reg.example",
|
||||||
|
"je_repo": 3,
|
||||||
|
"timeout": 1.0,
|
||||||
|
"cluster_an": True,
|
||||||
|
"operating_an": True,
|
||||||
|
"registry_an": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._c, self._o, self._r = (
|
||||||
|
targets.ziele_cluster,
|
||||||
|
targets.ziele_operating,
|
||||||
|
targets.ziele_registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
targets.ziele_cluster, targets.ziele_operating, targets.ziele_registry = (
|
||||||
|
self._c,
|
||||||
|
self._o,
|
||||||
|
self._r,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _stelle(self, cluster=None, operating=None, registry=None):
|
||||||
|
def macher(wert):
|
||||||
|
if isinstance(wert, Exception):
|
||||||
|
def f(*a, **k):
|
||||||
|
raise wert
|
||||||
|
return f
|
||||||
|
return lambda *a, **k: (wert or set())
|
||||||
|
|
||||||
|
targets.ziele_cluster = macher(cluster)
|
||||||
|
targets.ziele_operating = macher(operating)
|
||||||
|
targets.ziele_registry = macher(registry)
|
||||||
|
|
||||||
|
def test_alles_gut(self):
|
||||||
|
self._stelle({"a:1"}, {"b:1"}, {"c:1"})
|
||||||
|
h = targets.herleiten(self.CFG)
|
||||||
|
self.assertEqual(h.ziele, {"a:1", "b:1", "c:1"})
|
||||||
|
self.assertTrue(h.vollstaendig)
|
||||||
|
self.assertTrue(h.brauchbar)
|
||||||
|
self.assertEqual(set(h.stand), {"cluster", "operating", "registry"})
|
||||||
|
|
||||||
|
def test_eine_quelle_faellt_aus_die_anderen_liefern_weiter(self):
|
||||||
|
self._stelle({"a:1"}, OSError("Prometheus weg"), {"c:1"})
|
||||||
|
h = targets.herleiten(self.CFG)
|
||||||
|
self.assertEqual(h.ziele, {"a:1", "c:1"})
|
||||||
|
self.assertFalse(h.vollstaendig)
|
||||||
|
self.assertTrue(h.brauchbar)
|
||||||
|
self.assertIn("operating", h.fehler)
|
||||||
|
self.assertNotIn("operating", h.stand) # Frische bleibt aus
|
||||||
|
self.assertIn("cluster", h.stand)
|
||||||
|
|
||||||
|
def test_stand_der_ausgefallenen_quelle_bleibt_auf_dem_ALTEN_wert(self):
|
||||||
|
# Damit cve_target_source_stale altert, statt zu verschwinden.
|
||||||
|
self._stelle(OSError("weg"), {"b:1"}, {"c:1"})
|
||||||
|
h = targets.herleiten(self.CFG, vorheriger_stand={"cluster": 1000.0})
|
||||||
|
self.assertEqual(h.stand["cluster"], 1000.0)
|
||||||
|
self.assertGreater(h.stand["operating"], 1000.0)
|
||||||
|
|
||||||
|
def test_alle_quellen_weg_ergibt_leere_und_UNBRAUCHBARE_menge(self):
|
||||||
|
self._stelle(OSError("a"), OSError("b"), OSError("c"))
|
||||||
|
h = targets.herleiten(self.CFG)
|
||||||
|
self.assertEqual(h.ziele, set())
|
||||||
|
self.assertFalse(h.brauchbar) # -> nichts schreiben, nichts loeschen
|
||||||
|
self.assertEqual(set(h.fehler), {"cluster", "operating", "registry"})
|
||||||
|
|
||||||
|
def test_gegenprobe_leere_menge_ist_NICHT_dasselbe_wie_erfolg(self):
|
||||||
|
# Der Fall, der ohne diese Unterscheidung wie 100 % Deckung aussaehe.
|
||||||
|
#
|
||||||
|
# ⚠️ VERHALTEN GEAENDERT am 2026-08-21 (#0051): Antworten Cluster UND
|
||||||
|
# Betriebs-Host erfolgreich LEER, wird die Registry-Auswahl jetzt
|
||||||
|
# uebersprungen und als Fehler gemeldet. Frueher galt das als
|
||||||
|
# "vollstaendig, nur unbrauchbar". Der Grund fuer die Aenderung: Ohne
|
||||||
|
# laufenden Bestand kann die Registry-Auswahl Rollback-Ziele nicht von
|
||||||
|
# Bau-Artefakten unterscheiden - und laeuft wirklich nichts, ist das ein
|
||||||
|
# Ausfall und kein Normalzustand. Er soll deshalb als Fehler sichtbar
|
||||||
|
# sein, nicht nur an `brauchbar` haengen.
|
||||||
|
self._stelle(set(), set(), set())
|
||||||
|
h = targets.herleiten(self.CFG)
|
||||||
|
self.assertEqual(h.ziele, set())
|
||||||
|
self.assertFalse(h.brauchbar) # nicht verwertbar ...
|
||||||
|
self.assertFalse(h.vollstaendig) # ... UND es wird gesagt
|
||||||
|
self.assertIn("registry", h.fehler)
|
||||||
|
self.assertIn("kein laufender Bestand", h.fehler["registry"])
|
||||||
|
|
||||||
|
def test_registry_ohne_laufenden_bestand_wird_uebersprungen(self):
|
||||||
|
# Gegenprobe zur Zeile darueber: Mit Bestand laeuft die Registry sehr wohl.
|
||||||
|
self._stelle({"a:1"}, set(), {"reg/x:v1"})
|
||||||
|
h = targets.herleiten(self.CFG)
|
||||||
|
self.assertIn("reg/x:v1", h.ziele)
|
||||||
|
self.assertNotIn("registry", h.fehler)
|
||||||
|
|
||||||
|
|
||||||
|
class Deckungsrechnung(unittest.TestCase):
|
||||||
|
"""Die Mengenarithmetik, auf der die Kriterien 2 und 3 beruhen."""
|
||||||
|
|
||||||
|
def test_zahlen_der_handmessung_vom_2026_08_21(self):
|
||||||
|
soll = {f"i{n}:v1" for n in range(51)}
|
||||||
|
ist = {f"i{n}:v1" for n in range(27)} | {"alt:v1", "weg:v1"}
|
||||||
|
fehlend = soll - ist
|
||||||
|
verwaist = ist - soll
|
||||||
|
self.assertEqual(len(fehlend), 24)
|
||||||
|
self.assertEqual(len(verwaist), 2)
|
||||||
|
self.assertAlmostEqual(len(soll & ist) / len(soll), 27 / 51, places=4)
|
||||||
|
|
||||||
|
def test_gegenprobe_die_rechnung_kann_rot_werden(self):
|
||||||
|
soll = {"a:1", "b:1"}
|
||||||
|
ist = {"a:1", "b:1"}
|
||||||
|
self.assertEqual(len(soll - ist), 0)
|
||||||
|
ist_kaputt = {"a:1"}
|
||||||
|
self.assertEqual(len(soll - ist_kaputt), 1) # merkt es
|
||||||
|
|
||||||
|
|
||||||
|
class LeereAntwortIstKeinErfolg(unittest.TestCase):
|
||||||
|
"""⚠️ Nachgetragen am 2026-08-23, nachdem genau dieser Fall die Zielmenge
|
||||||
|
von 54 auf 12 fallen liess und der Scanner 42 Berichte loeschte.
|
||||||
|
|
||||||
|
Der k3s-Neustart legte kube-state-metrics und Alloy lahm; die
|
||||||
|
Prometheus-Abfrage lief daraufhin sauber durch und lieferte NULL Zeilen.
|
||||||
|
Das galt als Erfolg: Zeitstempel frisch, Menge nicht leer (der Betriebs-Host
|
||||||
|
lieferte ja), also schlug keine der drei Regeln an. Deckung 1,0, waehrend
|
||||||
|
drei Viertel des Bestands unbeobachtet waren.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CFG = {"prometheus": "http://x", "registry": "r", "je_repo": 3, "timeout": 1}
|
||||||
|
|
||||||
|
def herleite(self, antworten, vorher=None, stand=None):
|
||||||
|
echt = (targets.ziele_cluster, targets.ziele_operating, targets.ziele_registry)
|
||||||
|
targets.ziele_cluster = lambda *a, **k: antworten["cluster"]
|
||||||
|
targets.ziele_operating = lambda *a, **k: antworten["operating"]
|
||||||
|
targets.ziele_registry = lambda *a, **k: antworten["registry"]
|
||||||
|
try:
|
||||||
|
return targets.herleiten(self.CFG, vorheriger_stand=stand, vorherige_ziele=vorher)
|
||||||
|
finally:
|
||||||
|
targets.ziele_cluster, targets.ziele_operating, targets.ziele_registry = echt
|
||||||
|
|
||||||
|
VORHER = {"cluster": {"a:1", "b:1"}, "operating": {"c:1"}, "registry": {"d:1"}}
|
||||||
|
|
||||||
|
def test_leere_antwort_behaelt_den_alten_stand(self):
|
||||||
|
h = self.herleite({"cluster": set(), "operating": {"c:1"}, "registry": {"d:1"}},
|
||||||
|
vorher=self.VORHER, stand={"cluster": 100.0})
|
||||||
|
self.assertEqual(h.je_quelle["cluster"], {"a:1", "b:1"}) # NICHT verloren
|
||||||
|
self.assertIn("cluster", h.fehler)
|
||||||
|
self.assertEqual(h.stand["cluster"], 100.0) # Zeitstempel altert
|
||||||
|
|
||||||
|
def test_gegenprobe_eine_ECHT_leere_erstmenge_ist_kein_fehler(self):
|
||||||
|
# Ohne Vorwissen ist eine leere Antwort schlicht eine leere Antwort -
|
||||||
|
# sonst koennte die Anlage nie sauber anlaufen.
|
||||||
|
h = self.herleite({"cluster": set(), "operating": {"c:1"}, "registry": {"d:1"}},
|
||||||
|
vorher=None, stand=None)
|
||||||
|
self.assertEqual(h.je_quelle["cluster"], set())
|
||||||
|
self.assertNotIn("cluster", h.fehler)
|
||||||
|
|
||||||
|
def test_gegenprobe_eine_gefuellte_antwort_ersetzt_den_alten_stand(self):
|
||||||
|
h = self.herleite({"cluster": {"neu:1"}, "operating": {"c:1"}, "registry": {"d:1"}},
|
||||||
|
vorher=self.VORHER, stand={"cluster": 100.0})
|
||||||
|
self.assertEqual(h.je_quelle["cluster"], {"neu:1"})
|
||||||
|
self.assertNotIn("cluster", h.fehler)
|
||||||
|
self.assertGreater(h.stand["cluster"], 100.0)
|
||||||
|
|
||||||
|
def test_ein_ausfall_verliert_die_ziele_der_quelle_NICHT(self):
|
||||||
|
def kaputt(*a, **k):
|
||||||
|
raise RuntimeError("Prometheus weg")
|
||||||
|
echt = targets.ziele_cluster
|
||||||
|
targets.ziele_cluster = kaputt
|
||||||
|
try:
|
||||||
|
h = targets.herleiten(self.CFG, vorheriger_stand={"cluster": 100.0},
|
||||||
|
vorherige_ziele=self.VORHER)
|
||||||
|
finally:
|
||||||
|
targets.ziele_cluster = echt
|
||||||
|
self.assertEqual(h.je_quelle["cluster"], {"a:1", "b:1"})
|
||||||
|
self.assertIn("cluster", h.fehler)
|
||||||
|
|
||||||
|
def test_die_gesamtmenge_bricht_dadurch_nicht_ein(self):
|
||||||
|
# Der eigentliche Schaden war die SUMME: 54 -> 12. Genau die darf nicht
|
||||||
|
# mehr passieren, solange die Quelle schon einmal geliefert hat.
|
||||||
|
h = self.herleite({"cluster": set(), "operating": {"c:1"}, "registry": set()},
|
||||||
|
vorher=self.VORHER, stand=None)
|
||||||
|
self.assertEqual(len(h.ziele), 4) # a,b,c,d - nichts verloren
|
||||||
|
h_ohne = self.herleite({"cluster": set(), "operating": {"c:1"}, "registry": set()},
|
||||||
|
vorher=None, stand=None)
|
||||||
|
self.assertEqual(len(h_ohne.ziele), 1) # Gegenprobe: ohne Vorwissen sehr wohl
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
+184
-17
@@ -1,10 +1,15 @@
|
|||||||
services:
|
services:
|
||||||
prometheus:
|
prometheus:
|
||||||
image: prom/prometheus:v3.3.1
|
image: prom/prometheus:v3.14.0
|
||||||
container_name: prometheus
|
container_name: prometheus
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
# Verzeichnis- statt Einzeldatei-Mount: Docker haengt Einzeldatei-Mounts am
|
||||||
|
# Inode auf, und `git pull` ersetzt Dateien per Rename (neuer Inode) - der
|
||||||
|
# Container sah die Aenderung dann nie, waehrend SIGHUP brav Erfolg meldete.
|
||||||
|
# Verzeichnis-Mounts loesen bei jedem Zugriff ueber den Pfad auf.
|
||||||
|
# Config-Pfade bleiben unveraendert (--config.file zeigt weiter dorthin).
|
||||||
|
- ./prometheus:/etc/prometheus:ro
|
||||||
- prometheus_data:/prometheus
|
- prometheus_data:/prometheus
|
||||||
command:
|
command:
|
||||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||||
@@ -12,38 +17,177 @@ services:
|
|||||||
- '--storage.tsdb.retention.time=45d'
|
- '--storage.tsdb.retention.time=45d'
|
||||||
- '--storage.tsdb.retention.size=15GB'
|
- '--storage.tsdb.retention.size=15GB'
|
||||||
# Remote-Write-Receiver: k3s-Cluster und Matrix-Server pushen hierher.
|
# Remote-Write-Receiver: k3s-Cluster und Matrix-Server pushen hierher.
|
||||||
# ACHTUNG: Port 9090 ist oeffentlich und ohne Auth erreichbar --
|
# ⚠️ Prometheus hat KEINE Authentisierung. Bis 2026-08-21 stand hier
|
||||||
# per Hetzner Cloud Firewall auf die Absender-IPs einschraenken!
|
# "9090:9090", also 0.0.0.0 - oeffentlich erreichbar, abgesichert allein
|
||||||
|
# durch die Hetzner-Firewall. Seither an Adressen gebunden: der private
|
||||||
|
# vSwitch fuer die pushenden Absender, localhost fuer den Host selbst.
|
||||||
|
# Wer das zurueckdreht, macht die Zeitreihen wieder oeffentlich lesbar
|
||||||
|
# UND beschreibbar.
|
||||||
- '--web.enable-remote-write-receiver'
|
- '--web.enable-remote-write-receiver'
|
||||||
ports:
|
ports:
|
||||||
- "9090:9090"
|
- "10.0.0.3:9090:9090"
|
||||||
|
- "127.0.0.1:9090:9090"
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
alertmanager:
|
||||||
|
image: prom/alertmanager:v0.34.0
|
||||||
|
container_name: alertmanager
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
# Verzeichnis-Mount, gleicher Grund wie bei Prometheus. Die beiden .py aus
|
||||||
|
# diesem Ordner liegen dadurch mit unter /etc/alertmanager - ungenutzt und
|
||||||
|
# harmlos, alertmanager laedt ausschliesslich --config.file.
|
||||||
|
- ./alertmanager:/etc/alertmanager:ro
|
||||||
|
- alertmanager_data:/alertmanager
|
||||||
|
command:
|
||||||
|
- '--config.file=/etc/alertmanager/alertmanager.yml'
|
||||||
|
- '--storage.path=/alertmanager'
|
||||||
|
# bewusst kein oeffentlicher Port - nur Prometheus/Receiver im traefik-Netz
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
# Alertmanager-Webhook -> Matrix (wartung-Raum), gleiche Machart wie
|
||||||
|
# maintenance-notify (gitops Issue #24). Secrets kommen aus .env.
|
||||||
|
matrix-alerts:
|
||||||
|
# 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:
|
||||||
|
- MATRIX_HOMESERVER=${MATRIX_ALERT_HOMESERVER}
|
||||||
|
- MATRIX_ROOM_ID=${MATRIX_ALERT_ROOM_ID}
|
||||||
|
- MATRIX_TOKEN=${MATRIX_ALERT_TOKEN}
|
||||||
|
# Raum-Routing (gitops#47): Alerts mit Label room=security -> Security-Raum
|
||||||
|
- MATRIX_ROOM_SECURITY=${MATRIX_RELEASE_ROOM_ID:-}
|
||||||
|
# Ohne das liegt der State unter /tmp im Writable Layer: der ueberlebt
|
||||||
|
# zwar ein "compose restart", aber kein "up -d", das den Container neu
|
||||||
|
# baut -- also genau jeden Deploy. Dann verlieren offene Alarme ihre
|
||||||
|
# Event-Zuordnung und loesen sich ueber den Fallback als separate
|
||||||
|
# Nachricht auf, statt die Firing-Nachricht abzuhaken.
|
||||||
|
- MATRIX_STATE_FILE=/state/matrix-alerts-state.json
|
||||||
|
volumes:
|
||||||
|
- ./alertmanager:/app:ro
|
||||||
|
- matrix_alerts_data:/state
|
||||||
|
command: python3 /app/matrix-alerts.py
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
# Release-/Advisory-Watch (gitops#22): meldet neue Releases der Element-Stack-
|
||||||
|
# Upstreams in den Alerts-Raum (🚨 bei Security-Verdacht). Gleicher Bot/Raum
|
||||||
|
# wie matrix-alerts, eigener State (Erstlauf merkt nur, flutet nicht).
|
||||||
|
release-watch:
|
||||||
|
# 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:
|
||||||
|
- MATRIX_HOMESERVER=${MATRIX_ALERT_HOMESERVER}
|
||||||
|
- MATRIX_ROOM_ID=${MATRIX_ALERT_ROOM_ID}
|
||||||
|
# CVE-/Release-Raum (gitops#47): leer lassen = Fallback Alerts-Raum
|
||||||
|
- MATRIX_RELEASE_ROOM_ID=${MATRIX_RELEASE_ROOM_ID:-}
|
||||||
|
- MATRIX_TOKEN=${MATRIX_ALERT_TOKEN}
|
||||||
|
volumes:
|
||||||
|
- ./alertmanager:/app:ro
|
||||||
|
- release_watch_data:/state
|
||||||
|
command: python3 /app/release-watch.py
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
# CVE-Pipeline (gitops#47), Teil 1: Trivy scannt die real deployten Images
|
||||||
|
# (Ziele aus /targets/targets.txt - abgeleitet vom cve-exporter, NICHT von Hand
|
||||||
|
# pflegen: #0106 / ADR-0026)
|
||||||
|
cve-scan:
|
||||||
|
image: aquasec/trivy:0.74.0
|
||||||
|
container_name: cve-scan
|
||||||
|
restart: unless-stopped
|
||||||
|
entrypoint: ["/bin/sh", "/config/scan-loop.sh"]
|
||||||
|
volumes:
|
||||||
|
- ./cve:/config:ro
|
||||||
|
- cve_results:/results
|
||||||
|
- cve_trivy_cache:/root/.cache
|
||||||
|
# Ab Slice 3 die Quelle der Ziele; in Slice 1 nur eingehaengt, damit
|
||||||
|
# der Pfad existiert und beim Umschalten kein Rennen entsteht.
|
||||||
|
- cve_targets:/targets:ro
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
# Teil 2: Reports -> Prometheus-Metriken (Schema + Pflichtfelder siehe gitops#47)
|
||||||
|
cve-exporter:
|
||||||
|
# 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:
|
||||||
|
- RESULTS_DIR=/results
|
||||||
|
- STATE_FILE=/state/first-seen.json
|
||||||
|
# Soll-Menge der Scan-Ziele (#0106, ADR-0026): abgeleitet, nicht gepflegt.
|
||||||
|
# Der Exporter bildet sie, weil er fuer die Deckungsmetrik ohnehin BEIDE
|
||||||
|
# Mengen kennen muss - ein zweiter Ort waere eine zweite Wahrheit.
|
||||||
|
- TARGETS_FILE=/targets/targets.txt
|
||||||
|
- PROMETHEUS_URL=http://prometheus:9090
|
||||||
|
- REGISTRY_HOST=rohana.axion1337.de
|
||||||
|
# Reichweite (Entscheidung sorb 2026-08-21, Option C): Bestand plus die
|
||||||
|
# letzten drei Fassungen je Repo - so weit, wie ein Rollback zielt.
|
||||||
|
- REGISTRY_TAGS_JE_REPO=3
|
||||||
|
# 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
|
||||||
|
- cve_exporter_state:/state
|
||||||
|
- cve_targets:/targets
|
||||||
|
command: python3 /app/cve-exporter.py
|
||||||
networks:
|
networks:
|
||||||
- traefik
|
- traefik
|
||||||
|
|
||||||
loki:
|
loki:
|
||||||
image: grafana/loki:3.7.1
|
# 3.7.1 -> 3.7.6, gemessen: 41 HIGH -> 8, keine CRITICAL in beiden.
|
||||||
|
image: grafana/loki:3.7.6
|
||||||
container_name: loki
|
container_name: loki
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./loki/loki-config.yaml:/etc/loki/local-config.yaml:ro
|
- ./loki:/etc/loki:ro
|
||||||
- loki_data:/loki
|
- loki_data:/loki
|
||||||
command: -config.file=/etc/loki/local-config.yaml
|
command: -config.file=/etc/loki/loki-config.yaml
|
||||||
# Port 3100 oeffentlich: externe Alloys pushen Logs hierher.
|
# ⚠️ Externe Alloys pushen Logs hierher; Loki hat ebenfalls keine
|
||||||
# Ebenfalls per Firewall auf bekannte Absender-IPs einschraenken.
|
# Authentisierung. Bis 2026-08-21 auf 0.0.0.0 gebunden, seither wie
|
||||||
|
# Prometheus: privater vSwitch fuer die Absender, localhost fuer den Host.
|
||||||
ports:
|
ports:
|
||||||
- "3100:3100"
|
- "10.0.0.3:3100:3100"
|
||||||
|
- "127.0.0.1:3100:3100"
|
||||||
networks:
|
networks:
|
||||||
- traefik
|
- traefik
|
||||||
|
|
||||||
alloy:
|
alloy:
|
||||||
image: grafana/alloy:v1.16.0
|
# v1.16.0 -> v1.18.1, gemessen: 44 HIGH -> 14. v1.19.0 gibt es nur als
|
||||||
|
# Vorabfassung; im Cluster laeuft alloy aus dem Chart, nicht von hier.
|
||||||
|
image: grafana/alloy:v1.18.1
|
||||||
container_name: alloy
|
container_name: alloy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./alloy/config.alloy:/etc/alloy/config.alloy:ro
|
- ./alloy:/etc/alloy:ro
|
||||||
- /var/log:/var/log:ro
|
- /var/log:/var/log:ro
|
||||||
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
# Muss persistent sein, sonst ist die Positions-Datei nach jedem
|
||||||
|
# Recreate weg und Alloy liest alle Container-Logs von vorn. Loki
|
||||||
|
# weist die alten Eintraege dann ab (reject_old_samples, Default 7d).
|
||||||
|
- alloy_data:/var/lib/alloy
|
||||||
command: run --storage.path=/var/lib/alloy/data /etc/alloy/config.alloy
|
command: run --storage.path=/var/lib/alloy/data /etc/alloy/config.alloy
|
||||||
cap_add:
|
cap_add:
|
||||||
- DAC_READ_SEARCH
|
- DAC_READ_SEARCH
|
||||||
@@ -55,7 +199,20 @@ services:
|
|||||||
- loki
|
- loki
|
||||||
|
|
||||||
grafana:
|
grafana:
|
||||||
image: grafana/grafana:12.0.0
|
# ⚠️ NICHT auf die 12er-Reihe zurueckdrehen, weil irgendwo eine groessere
|
||||||
|
# HIGH-Zahl steht. Ab 13.x liegen 13 Datenquellen-Plugins IM Image (12.4.9
|
||||||
|
# hatte keine): aus 5 Scan-Zielen werden 16, und die Befunde sitzen
|
||||||
|
# saemtlich in diesen Plugin-Binaries - opentsdb, jaeger, stackdriver,
|
||||||
|
# tempo, cloudmonitoring, also Datenquellen, die wir nicht benutzen.
|
||||||
|
# Der KERN von 13.2.0 traegt 0 CRITICAL und 0 HIGH; 12.4.9 trug dort 1
|
||||||
|
# und 3.
|
||||||
|
#
|
||||||
|
# ⚠️ UND: dieselbe Lage hat drei verschiedene Zahlen, je nachdem, was man
|
||||||
|
# zaehlt - 162 Vorkommen, 63 Prometheus-Serien, 28 verschiedene CVEs.
|
||||||
|
# Dieselbe Go-stdlib wiederholt sich ueber 13 Binaries. Auf dem Dashboard
|
||||||
|
# (das Serien zaehlt) steht der Sprung als 70 -> 63, also WENIGER.
|
||||||
|
# Wer hier vergleicht, muss dieselbe Zaehlweise auf beiden Seiten nehmen.
|
||||||
|
image: grafana/grafana:13.2.0
|
||||||
container_name: grafana
|
container_name: grafana
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -72,7 +229,7 @@ services:
|
|||||||
- "traefik.docker.network=traefik"
|
- "traefik.docker.network=traefik"
|
||||||
- "traefik.http.routers.grafana.rule=Host(`selendis.axion1337.de`)"
|
- "traefik.http.routers.grafana.rule=Host(`selendis.axion1337.de`)"
|
||||||
- "traefik.http.routers.grafana.entrypoints=websecure"
|
- "traefik.http.routers.grafana.entrypoints=websecure"
|
||||||
- "traefik.http.routers.grafana.tls.certresolver=le"
|
- "traefik.http.routers.grafana.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.grafana.loadbalancer.server.port=3000"
|
- "traefik.http.services.grafana.loadbalancer.server.port=3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
- prometheus
|
- prometheus
|
||||||
@@ -81,7 +238,7 @@ services:
|
|||||||
- traefik
|
- traefik
|
||||||
|
|
||||||
node-exporter:
|
node-exporter:
|
||||||
image: prom/node-exporter:v1.9.1
|
image: prom/node-exporter:v1.12.1
|
||||||
container_name: node-exporter
|
container_name: node-exporter
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
@@ -94,15 +251,25 @@ services:
|
|||||||
- '--path.rootfs=/rootfs'
|
- '--path.rootfs=/rootfs'
|
||||||
- '--web.listen-address=:9100'
|
- '--web.listen-address=:9100'
|
||||||
- '--web.disable-exporter-metrics'
|
- '--web.disable-exporter-metrics'
|
||||||
|
# Nur localhost: gescrapt wird aus demselben Host heraus, von aussen hat
|
||||||
|
# hier niemand etwas zu suchen. Bis 2026-08-21 stand auch das auf 0.0.0.0.
|
||||||
ports:
|
ports:
|
||||||
- "9100:9100"
|
- "127.0.0.1:9100:9100"
|
||||||
networks:
|
networks:
|
||||||
- traefik
|
- traefik
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
prometheus_data:
|
prometheus_data:
|
||||||
|
alertmanager_data:
|
||||||
|
matrix_alerts_data:
|
||||||
|
release_watch_data:
|
||||||
|
cve_results:
|
||||||
|
cve_trivy_cache:
|
||||||
|
cve_exporter_state:
|
||||||
|
cve_targets:
|
||||||
grafana_data:
|
grafana_data:
|
||||||
loki_data:
|
loki_data:
|
||||||
|
alloy_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
traefik:
|
traefik:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
|||||||
|
{
|
||||||
|
"uid": "threadnet-clamav",
|
||||||
|
"title": "ClamAV — blockierte Inhalte",
|
||||||
|
"description": "Wie oft ClamAV tatsaechlich etwas blockiert (management #0077). Zwei Quellen: der Client-seitige Scanner (clamav-http-scanner, greift beim Senden UND beim Empfangen, deckt auch E2E-verschluesselte Raeume ab) und das Synapse-Modul (nur unverschluesselte Uploads). Das dritte Panel zeigt Fail-open-Faelle - wenn clamd nicht erreichbar ist, laesst der Scanner Dateien bewusst durch; ohne dieses Panel bliebe das unsichtbar.",
|
||||||
|
"tags": ["security", "clamav", "matrix"],
|
||||||
|
"timezone": "browser",
|
||||||
|
"schemaVersion": 41,
|
||||||
|
"editable": true,
|
||||||
|
"time": { "from": "now-7d", "to": "now" },
|
||||||
|
"refresh": "5m",
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Blockiert — Client (Senden + Empfangen)",
|
||||||
|
"description": "clamav-http-scanner. Deckt auch verschluesselte Raeume ab, weil der Client vor dem Verschluesseln bzw. nach dem Entschluesseln prueft.",
|
||||||
|
"gridPos": { "h": 4, "w": 8, "x": 0, "y": 0 },
|
||||||
|
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
|
||||||
|
"queryType": "instant",
|
||||||
|
"expr": "sum(count_over_time({job=\"loki.source.kubernetes.k8s_logs\", instance=~\"matrix/clamav-http-scanner.*\"} |= \"ClamAV flagged\" [$__range]))"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short",
|
||||||
|
"thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 1 } ] }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Blockiert — Synapse-Modul",
|
||||||
|
"description": "Server-seitiges Modul, sieht nur unverschluesselte Uploads. Null ist hier der Normalfall, solange der Client schon vor dem Upload blockt.",
|
||||||
|
"gridPos": { "h": 4, "w": 8, "x": 8, "y": 0 },
|
||||||
|
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
|
||||||
|
"queryType": "instant",
|
||||||
|
"expr": "sum(count_over_time({job=\"loki.source.kubernetes.k8s_logs\", instance=~\"matrix/matrix-stack-synapse.*\"} |= \"ClamAV rejected an upload\" [$__range]))"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short",
|
||||||
|
"thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 1 } ] }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Fail-open — Scanner nicht erreichbar",
|
||||||
|
"description": "Wenn clamd nicht antwortet, laesst der Scanner die Datei bewusst durch (fail-open). Jeder Treffer hier ist eine Datei, die UNGEPRUEFT durchging - deshalb rot ab eins.",
|
||||||
|
"gridPos": { "h": 4, "w": 8, "x": 16, "y": 0 },
|
||||||
|
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
|
||||||
|
"queryType": "instant",
|
||||||
|
"expr": "sum(count_over_time({job=\"loki.source.kubernetes.k8s_logs\", instance=~\"matrix/clamav-http-scanner.*\"} |= \"ClamAV scan failed\" [$__range]))"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short",
|
||||||
|
"thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 1 } ] }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Blockierte Inhalte ueber Zeit",
|
||||||
|
"gridPos": { "h": 8, "w": 14, "x": 0, "y": 4 },
|
||||||
|
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
|
||||||
|
"legendFormat": "Client (Senden/Empfangen)",
|
||||||
|
"expr": "sum(count_over_time({job=\"loki.source.kubernetes.k8s_logs\", instance=~\"matrix/clamav-http-scanner.*\"} |= \"ClamAV flagged\" [$__interval]))"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"refId": "B",
|
||||||
|
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
|
||||||
|
"legendFormat": "Synapse-Modul",
|
||||||
|
"expr": "sum(count_over_time({job=\"loki.source.kubernetes.k8s_logs\", instance=~\"matrix/matrix-stack-synapse.*\"} |= \"ClamAV rejected an upload\" [$__interval]))"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"refId": "C",
|
||||||
|
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
|
||||||
|
"legendFormat": "Fail-open",
|
||||||
|
"expr": "sum(count_over_time({job=\"loki.source.kubernetes.k8s_logs\", instance=~\"matrix/clamav-http-scanner.*\"} |= \"ClamAV scan failed\" [$__interval]))"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": { "unit": "short", "custom": { "drawStyle": "bars", "fillOpacity": 60, "stacking": { "mode": "normal" } } },
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": { "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "table",
|
||||||
|
"title": "Nach Signatur",
|
||||||
|
"description": "Welche Schadsoftware tatsaechlich auftaucht. Die Signatur wird aus der Logzeile geschnitten.",
|
||||||
|
"gridPos": { "h": 8, "w": 10, "x": 14, "y": 4 },
|
||||||
|
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
|
||||||
|
"queryType": "instant",
|
||||||
|
"format": "table",
|
||||||
|
"expr": "sum by (signature) (count_over_time({job=\"loki.source.kubernetes.k8s_logs\", instance=~\"matrix/clamav-http-scanner.*\"} |= \"ClamAV flagged\" | regexp \"ClamAV flagged an upload/download: (?P<signature>[^\\\\s]+)\" [$__range]))"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformations": [ { "id": "organize", "options": { "excludeByName": { "Time": true } } } ],
|
||||||
|
"fieldConfig": { "defaults": {}, "overrides": [] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "logs",
|
||||||
|
"title": "Ereignisse",
|
||||||
|
"description": "Rohzeilen beider Quellen, neueste zuerst.",
|
||||||
|
"gridPos": { "h": 9, "w": 24, "x": 0, "y": 12 },
|
||||||
|
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"datasource": { "type": "loki", "uid": "cfk1pam8svmkgd" },
|
||||||
|
"expr": "{job=\"loki.source.kubernetes.k8s_logs\", instance=~\"matrix/(clamav-http-scanner|matrix-stack-synapse).*\"} |~ \"ClamAV (flagged|rejected an upload|scan failed)\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": { "showTime": true, "sortOrder": "Descending", "wrapLogMessage": true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
{
|
||||||
|
"title": "CVE-Übersicht (Trivy)",
|
||||||
|
"uid": "cve-overview",
|
||||||
|
"tags": [
|
||||||
|
"security",
|
||||||
|
"cve",
|
||||||
|
"gitops-47"
|
||||||
|
],
|
||||||
|
"timezone": "browser",
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"refresh": "5m",
|
||||||
|
"time": {
|
||||||
|
"from": "now-30d",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Offene CRITICAL",
|
||||||
|
"id": 1,
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"w": 4,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
},
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(trivy_vuln_count{severity=\"CRITICAL\"}) or vector(0)",
|
||||||
|
"instant": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Offene HIGH",
|
||||||
|
"id": 2,
|
||||||
|
"gridPos": {
|
||||||
|
"x": 4,
|
||||||
|
"y": 0,
|
||||||
|
"w": 4,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
},
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "orange",
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(trivy_vuln_count{severity=\"HIGH\"}) or vector(0)",
|
||||||
|
"instant": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "MEDIUM/LOW (Summe)",
|
||||||
|
"id": 3,
|
||||||
|
"gridPos": {
|
||||||
|
"x": 8,
|
||||||
|
"y": 0,
|
||||||
|
"w": 4,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(trivy_vuln_count{severity=~\"MEDIUM|LOW\"}) or vector(0)",
|
||||||
|
"instant": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Ältester Scan (Stunden)",
|
||||||
|
"id": 4,
|
||||||
|
"gridPos": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 0,
|
||||||
|
"w": 4,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "h",
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
},
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 48
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "(time() - min(trivy_last_scan_timestamp)) / 3600",
|
||||||
|
"instant": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "table",
|
||||||
|
"title": "Offene CVEs (HIGH/CRITICAL) — CVE · Ort · Typ · Paket · Fix · seit",
|
||||||
|
"id": 5,
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 4,
|
||||||
|
"w": 24,
|
||||||
|
"h": 12
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "trivy_vuln_info",
|
||||||
|
"instant": true,
|
||||||
|
"format": "table",
|
||||||
|
"refId": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "trivy_vuln_first_seen_timestamp * 1000",
|
||||||
|
"instant": true,
|
||||||
|
"format": "table",
|
||||||
|
"refId": "B"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformations": [
|
||||||
|
{
|
||||||
|
"id": "merge",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "organize",
|
||||||
|
"options": {
|
||||||
|
"excludeByName": {
|
||||||
|
"Time": true,
|
||||||
|
"__name__": true,
|
||||||
|
"job": true,
|
||||||
|
"instance": true,
|
||||||
|
"Value #A": true
|
||||||
|
},
|
||||||
|
"renameByName": {
|
||||||
|
"cve": "CVE",
|
||||||
|
"severity": "Severity",
|
||||||
|
"host": "Ort (Host)",
|
||||||
|
"target_type": "Typ",
|
||||||
|
"target": "Target",
|
||||||
|
"pkg": "Paket",
|
||||||
|
"installed": "Installiert",
|
||||||
|
"fixed_version": "Fix-Version",
|
||||||
|
"Value #B": "Erstmals gesehen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "Erstmals gesehen"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "unit",
|
||||||
|
"value": "dateTimeAsIso"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "CVE"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "links",
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"title": "NVD (Mitigation/Details)",
|
||||||
|
"targetBlank": true,
|
||||||
|
"url": "https://nvd.nist.gov/vuln/detail/${__value.text}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "state-timeline",
|
||||||
|
"title": "Zeitstrahl: offene HIGH/CRITICAL je Target",
|
||||||
|
"id": 6,
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 16,
|
||||||
|
"w": 24,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "count by (target) (trivy_vuln_info)",
|
||||||
|
"legendFormat": "{{target}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {
|
||||||
|
"fillOpacity": 70
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Funde je Severity über Zeit",
|
||||||
|
"id": 7,
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 24,
|
||||||
|
"w": 24,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum by (severity) (trivy_vuln_count)",
|
||||||
|
"legendFormat": "{{severity}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Deckung der Zielmenge",
|
||||||
|
"id": 8,
|
||||||
|
"gridPos": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 0,
|
||||||
|
"w": 4,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percentunit",
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
},
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "orange",
|
||||||
|
"value": 0.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "cve_target_coverage_ratio",
|
||||||
|
"instant": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Ungeprüfte Images",
|
||||||
|
"id": 9,
|
||||||
|
"gridPos": {
|
||||||
|
"x": 20,
|
||||||
|
"y": 0,
|
||||||
|
"w": 4,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "none",
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
},
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "cve_targets_missing or vector(0)",
|
||||||
|
"instant": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bargauge",
|
||||||
|
"title": "Frische der Zielquellen (Sekunden seit letzter Herleitung)",
|
||||||
|
"id": 10,
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 32,
|
||||||
|
"w": 24,
|
||||||
|
"h": 6
|
||||||
|
},
|
||||||
|
"description": "Faellt eine Quelle aus, wird die Soll-Menge kleiner - und die Deckung sieht dadurch BESSER aus statt schlechter. Diese Zeile ist die einzige Stelle, an der ein Ausfall der Herleitung von Erfolg zu unterscheiden ist (ADR-0026).",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "s",
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
},
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "orange",
|
||||||
|
"value": 7200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 10800
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"displayMode": "gradient",
|
||||||
|
"orientation": "horizontal"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "cve_target_source_stale",
|
||||||
|
"instant": true,
|
||||||
|
"refId": "A",
|
||||||
|
"legendFormat": "{{quelle}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -37,3 +37,10 @@ providers:
|
|||||||
allowUiUpdates: true
|
allowUiUpdates: true
|
||||||
options:
|
options:
|
||||||
path: /var/lib/grafana/dashboards/general
|
path: /var/lib/grafana/dashboards/general
|
||||||
|
|
||||||
|
- name: security
|
||||||
|
folder: 'Security'
|
||||||
|
type: file
|
||||||
|
allowUiUpdates: true
|
||||||
|
options:
|
||||||
|
path: /var/lib/grafana/dashboards/security
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
# Alert-Regeln (gitops#32). Bewusst wenige, dafuer die real erlebten Fehlerklassen:
|
||||||
|
# - TargetDown: "Exporter/Dienst tot und keiner merkt es" (coturn-Klasse)
|
||||||
|
# - KubePodRestartLoop: Crashloops im k3s-Cluster (36k-coturn-Restarts-Klasse)
|
||||||
|
# - HostOom/HostLowMemory/HostLowDisk/HostHighSwap: Ressourcendruck der Hosts
|
||||||
|
groups:
|
||||||
|
- name: axion-basics
|
||||||
|
rules:
|
||||||
|
- alert: TargetDown
|
||||||
|
expr: up == 0
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Target {{ $labels.job }}/{{ $labels.instance }} ist seit 5m nicht erreichbar"
|
||||||
|
|
||||||
|
- alert: KubePodRestartLoop
|
||||||
|
expr: increase(kube_pod_container_status_restarts_total[1h]) > 3
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Pod {{ $labels.namespace }}/{{ $labels.pod }} restartet wiederholt ({{ $value | printf \"%.0f\" }}x in 1h)"
|
||||||
|
|
||||||
|
- alert: HostOomKill
|
||||||
|
expr: increase(node_vmstat_oom_kill[15m]) > 0
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "OOM-Kill auf {{ $labels.instance }}"
|
||||||
|
|
||||||
|
- alert: HostLowMemory
|
||||||
|
expr: node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes < 0.08
|
||||||
|
for: 15m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.instance }}: <8% RAM verfuegbar seit 15m"
|
||||||
|
|
||||||
|
- alert: HostHighSwap
|
||||||
|
expr: (node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes) / node_memory_SwapTotal_bytes > 0.5
|
||||||
|
for: 30m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.instance }}: >50% Swap in Nutzung seit 30m"
|
||||||
|
|
||||||
|
- alert: HostLowDisk
|
||||||
|
expr: node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay"} < 0.10
|
||||||
|
for: 15m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.instance }}: <10% Platz auf / frei"
|
||||||
|
|
||||||
|
# CVE-Pipeline (gitops#47): Funde aus den Trivy-Scans. Pflichtfelder je Alarm:
|
||||||
|
# CVE-ID, Mitigation (Fix-Version + NVD-Link), Ort (host), Typ (target_type);
|
||||||
|
# der Zeitstrahl kommt aus trivy_vuln_first_seen_timestamp + resolved-Edit.
|
||||||
|
# Alle Regeln routen per room-Label in den Security-Raum.
|
||||||
|
- name: axion-cve
|
||||||
|
rules:
|
||||||
|
# Aggregiert pro Image statt pro CVE (gitops#51, Entscheidung sorb
|
||||||
|
# 2026-08-01): ~1000 Einzelalarme waeren Rauschen; die menschlich
|
||||||
|
# handhabbare Einheit ist "Image X hat N kritische CVEs". Die
|
||||||
|
# CVE-Details (Pflichtfelder) liegen im Grafana-Dashboard cve-overview,
|
||||||
|
# die trivy_vuln_info-Einzelserien bleiben dafuer erhalten.
|
||||||
|
- alert: TrivyCriticalVulns
|
||||||
|
expr: count by (target, target_type, host) (trivy_vuln_info{severity="CRITICAL"}) > 0
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
room: security
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.target }} [{{ $labels.target_type }}@{{ $labels.host }}]: {{ $value }} CRITICAL-CVEs — Details: https://selendis.axion1337.de/d/cve-overview"
|
||||||
|
- alert: TrivyHighVulns
|
||||||
|
expr: count by (target, target_type, host) (trivy_vuln_info{severity="HIGH"}) > 0
|
||||||
|
for: 24h
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
room: security
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.target }} [{{ $labels.target_type }}@{{ $labels.host }}]: {{ $value }} HIGH-CVEs (seit 24h offen) — Details: https://selendis.axion1337.de/d/cve-overview"
|
||||||
|
- alert: TrivyScanStale
|
||||||
|
expr: time() - trivy_last_scan_timestamp > 172800
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
room: security
|
||||||
|
annotations:
|
||||||
|
summary: "CVE-Scan fuer {{ $labels.target }} ist aelter als 2 Tage — Scanner pruefen (ein still gestorbener Scanner macht blind)"
|
||||||
|
# TrivyScanStale hat eine Blindstelle, die es prinzipiell nicht schliessen
|
||||||
|
# kann: ein Target OHNE je erfolgreichen Bericht hat gar keine Serie, an der
|
||||||
|
# 'time() - ...' haengen koennte. Es bleibt also still. Die beiden Regeln
|
||||||
|
# darunter fangen die zwei Faelle ab, die dahinterstecken.
|
||||||
|
- alert: TrivyReportUnreadable
|
||||||
|
expr: trivy_report_read_errors > 0
|
||||||
|
for: 30m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
room: security
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $value }} CVE-Bericht(e) sind seit 30 min unlesbar — die betroffenen Targets werden NICHT ausgewertet und faellt sonst niemandem auf"
|
||||||
|
- alert: TrivyNoReports
|
||||||
|
expr: trivy_reports_total == 0
|
||||||
|
for: 1h
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
room: security
|
||||||
|
annotations:
|
||||||
|
summary: "Der CVE-Exporter findet ueberhaupt keine Berichte mehr — der Scanner liefert nichts, die Schwachstellen-Sicht ist blind"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Deckung der Zielmenge (#0106, ADR-0026) ---------------------
|
||||||
|
# Bis 2026-08-21 konnte die Pipeline nur sagen, WAS sie gescannt hat.
|
||||||
|
# Was sie nie angesehen hatte, war unsichtbar: 27 von 51 laufenden
|
||||||
|
# Images (52 %) - darunter der Web-Client, beide Traefik-Schichten und
|
||||||
|
# die Registry selbst. Die Zielliste war handgepflegt.
|
||||||
|
# 6h, weil eine volle Runde ueber ~65 Images dauert: nach einem
|
||||||
|
# Ausrollen ist die Luecke voruebergehend echt und kein Fehler.
|
||||||
|
- alert: CveTargetsMissing
|
||||||
|
expr: cve_targets_missing > 0
|
||||||
|
for: 6h
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
room: security
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $value }} laufende Images werden NICHT auf CVEs geprueft — die Schwachstellen-Sicht hat ein Loch, das sie selbst nicht zeigt. Details: https://selendis.axion1337.de/d/cve-overview"
|
||||||
|
- alert: CveTargetsOrphaned
|
||||||
|
expr: cve_targets_orphaned > 0
|
||||||
|
for: 6h
|
||||||
|
labels:
|
||||||
|
severity: info
|
||||||
|
room: security
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $value }} CVE-Bericht(e) betreffen Images, die nirgends laufen — Befunde, auf die niemand handeln kann (Rauschen, das wie Signal aussieht)"
|
||||||
|
# ⚠️ DIE WICHTIGSTE DER VIER. Faellt eine Quelle aus, wird die
|
||||||
|
# Soll-Menge KLEINER - und die Deckung saehe damit BESSER aus, nicht
|
||||||
|
# schlechter. Ein Ausfall waere ohne diese Regel von Erfolg nicht zu
|
||||||
|
# unterscheiden. Schwelle: 3x das Herleitungs-Intervall (1h).
|
||||||
|
- alert: CveTargetSourceStale
|
||||||
|
expr: cve_target_source_stale > 10800
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
room: security
|
||||||
|
annotations:
|
||||||
|
summary: "Die Quelle '{{ $labels.quelle }}' der CVE-Zielmenge ist seit {{ $value }}s nicht erneuert worden — die Zielmenge schrumpft still, und die Deckung sieht dadurch BESSER aus statt schlechter"
|
||||||
|
# Der Fall, den die drei darueber NICHT sehen: alle Quellen antworten
|
||||||
|
# sauber, aber leer. Dann ist 'missing' 0 (leere Soll-Menge) und
|
||||||
|
# 'stale' frisch - und es wird nichts mehr gescannt. In
|
||||||
|
# test_targets.py als "leere Menge ist NICHT dasselbe wie Erfolg".
|
||||||
|
- alert: CveTargetsNoneDesired
|
||||||
|
expr: cve_targets_desired_total == 0
|
||||||
|
for: 30m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
room: security
|
||||||
|
annotations:
|
||||||
|
summary: "Die CVE-Zielmenge ist LEER — es wird nichts mehr geprueft, obwohl keine Quelle einen Fehler meldet"
|
||||||
|
# Sicherungen (management #0030). Bis 2026-08-14 gab es hierzu KEINE Regel: ein
|
||||||
|
# fehlgeschlagenes naechtliches Backup waere unbemerkt geblieben - genau der stille
|
||||||
|
# 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"
|
||||||
|
# ⚠️ Die Regel, die am 2026-08-23 gefehlt hat. Ein k3s-Neustart legte
|
||||||
|
# kube-state-metrics und Alloy lahm, `kube_pod_container_info` war leer,
|
||||||
|
# die Herleitung nahm das als Erfolg - und die Zielmenge fiel von 54 auf
|
||||||
|
# 12. Der Scanner loeschte 42 Berichte, das Dashboard meldete Deckung 1,0.
|
||||||
|
# Keine der vier Regeln darueber schlug an: die Menge war nicht LEER, und
|
||||||
|
# die Zeitstempel waren FRISCH. Die Luecke sass genau dazwischen.
|
||||||
|
#
|
||||||
|
# ⚠️ Sie feuert auch bei einem gewollten Rueckbau. Das ist Absicht: ein
|
||||||
|
# Einbruch der Pruefmenge um 40 % ist immer eine Zeile wert, egal warum.
|
||||||
|
- alert: CveZielmengeEingebrochen
|
||||||
|
expr: cve_targets_desired_total < 0.6 * max_over_time(cve_targets_desired_total[24h])
|
||||||
|
for: 15m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
room: security
|
||||||
|
annotations:
|
||||||
|
summary: "Die CVE-Zielmenge ist auf {{ $value }} Ziele eingebrochen — ueber 40 % weniger als in den letzten 24 h. Faellt eine Quelle still aus, sieht das Dashboard danach BESSER aus, nicht schlechter."
|
||||||
|
- name: axion-backup
|
||||||
|
rules:
|
||||||
|
# Ein Backup- oder Probe-Job ist fehlgeschlagen.
|
||||||
|
- alert: BackupJobFailed
|
||||||
|
expr: max by (namespace, job_name) (kube_job_status_failed{job_name=~"(synapse|authentik|wikijs)-backup.*|restore-drill.*"}) > 0
|
||||||
|
for: 15m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Backup-Job {{ $labels.namespace }}/{{ $labels.job_name }} ist fehlgeschlagen — ohne Sicherung ist der naechste Ausfall ein Datenverlust"
|
||||||
|
|
||||||
|
# Der CronJob plant gar nicht mehr (ausgesetzt, geloescht, Cluster-Problem).
|
||||||
|
# 26h Toleranz fuer die taeglichen Laeufe um 03:00/03:15/03:30.
|
||||||
|
# Fallback auf die Anlagezeit: ein frisch (neu) angelegter CronJob hat noch
|
||||||
|
# keine last_schedule_time-Serie - ein Ausdruck ueber eine fehlende Serie
|
||||||
|
# liefert nichts, der Alarm koennte also nie feuern. max by(...) fasst beide
|
||||||
|
# Metriken zu EINER Serie je CronJob zusammen; ohne das wuerde die nie
|
||||||
|
# aktualisierte created-Serie nach Ablauf der Frist dauerhaft falsch feuern
|
||||||
|
# ('or' matcht inkl. __name__, ergibt also eine Vereinigung beider Serien).
|
||||||
|
- alert: BackupNotRunning
|
||||||
|
expr: time() - max by (namespace, cronjob) (kube_cronjob_status_last_schedule_time{cronjob=~"(synapse|authentik|wikijs)-backup"} or kube_cronjob_created{cronjob=~"(synapse|authentik|wikijs)-backup"}) > 93600
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Seit ueber 26h kein Lauf von {{ $labels.cronjob }} — CronJob ausgesetzt oder verschwunden?"
|
||||||
|
|
||||||
|
# Die monatlichen Restore-Proben laufen nicht mehr - restore-drill (Datenbanken)
|
||||||
|
# und restore-drill-media (Synapse-Medien). Praefix-Matcher und cronjob-Name in der
|
||||||
|
# Meldung, sonst sagt der Alarm nicht, WELCHE Probe steht. Eine Sicherung, die nie
|
||||||
|
# zurueckgespielt wurde, ist eine Vermutung — deshalb ist auch das Ausbleiben
|
||||||
|
# der PRUEFUNG ein Alarm, nicht nur ihr Fehlschlag. 40 Tage = Monatsrhythmus + Puffer.
|
||||||
|
# Bis zum ersten REGULAEREN Lauf gibt es keine last_schedule_time-Serie (ein
|
||||||
|
# manuell ausgeloester Job setzt sie nicht) - ohne Fallback waere dieser
|
||||||
|
# Alarm bis dahin blind. Gleiche max-by-Konstruktion wie oben.
|
||||||
|
- alert: RestoreDrillStale
|
||||||
|
expr: time() - max by (namespace, cronjob) (kube_cronjob_status_last_schedule_time{cronjob=~"restore-drill.*"} or kube_cronjob_created{cronjob=~"restore-drill.*"}) > 3456000
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.cronjob }} seit ueber 40 Tagen nicht gelaufen — Wiederherstellbarkeit ist wieder unbewiesen (Verfahren: notfallhandbuch)"
|
||||||
|
|
||||||
|
# Ein Backup-CronJob existiert gar nicht mehr (geloescht, Kustomization
|
||||||
|
# entfernt, Namespace weg). Bei den Alarmen oben verschwindet dann die
|
||||||
|
# Serie - und eine verschwundene Serie ist in Prometheus kein Alarm,
|
||||||
|
# sondern Stille. Genau das war der Fehler, den dieses Regelwerk
|
||||||
|
# verhindern soll, also ist das Fehlen selbst der Alarm.
|
||||||
|
# for: 30m puffert kurze Luecken der Metrik-Pipeline ab.
|
||||||
|
- alert: BackupCronJobMissing
|
||||||
|
expr: >-
|
||||||
|
absent(kube_cronjob_info{namespace="matrix", cronjob="synapse-backup"})
|
||||||
|
or absent(kube_cronjob_info{namespace="matrix", cronjob="wikijs-backup"})
|
||||||
|
or absent(kube_cronjob_info{namespace="authentik", cronjob="authentik-backup"})
|
||||||
|
or absent(kube_cronjob_info{namespace="matrix", cronjob="restore-drill"})
|
||||||
|
or absent(kube_cronjob_info{namespace="matrix", cronjob="restore-drill-media"})
|
||||||
|
for: 30m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Ein Backup-/Probe-CronJob fehlt im Cluster — Sicherung oder Wiederherstellungs-Nachweis laeuft nicht mehr"
|
||||||
|
|
||||||
|
# Die Alarmkette meldet ihr eigenes Reissen. Ironie inklusive: schlaegt die
|
||||||
|
# Zustellung komplett fehl, kommt auch dieser Alarm nicht an - er ist dann
|
||||||
|
# aber in Prometheus/Grafana sichtbar, statt dass gar nichts existiert.
|
||||||
|
# Teilausfaelle (ein Receiver von mehreren, Rate-Limit 429) meldet er sauber.
|
||||||
|
- alert: AlertDeliveryFailing
|
||||||
|
expr: increase(alertmanager_notifications_failed_total[15m]) > 0
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Alertmanager konnte Benachrichtigungen nicht zustellen ({{ $labels.integration }}) — Alarme laufen ins Leere"
|
||||||
|
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
# Regeltests fuer die Deckungs-Alarme (#0106) und die Entscheidungs-Alarme (#0051).
|
||||||
|
# docker run --rm --entrypoint promtool -v "$PWD/prometheus:/p:ro" \
|
||||||
|
# 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
|
||||||
|
# (AAR 2026-08-01, Befund 1 und 3). Deshalb steht zu jeder Regel ein Fall, in
|
||||||
|
# dem sie feuern MUSS, und einer, in dem sie schweigen MUSS - sonst laesst sich
|
||||||
|
# eine Regel, die immer feuert, von einer richtigen nicht unterscheiden.
|
||||||
|
#
|
||||||
|
# Erste Regeltests in diesem Stack ueberhaupt; die uebrigen 16 Regeln sind
|
||||||
|
# bewusst nicht mit abgedeckt (Nicht-Ziel von #0106).
|
||||||
|
rule_files:
|
||||||
|
- alerts.yml
|
||||||
|
|
||||||
|
evaluation_interval: 1m
|
||||||
|
|
||||||
|
tests:
|
||||||
|
# --- CveTargetsMissing ----------------------------------------------------
|
||||||
|
- interval: 1m
|
||||||
|
name: "Luecke in der Deckung feuert nach 6h"
|
||||||
|
input_series:
|
||||||
|
- series: 'cve_targets_missing'
|
||||||
|
values: '24+0x480' # 8h lang 24 fehlende Images
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 5h
|
||||||
|
alertname: CveTargetsMissing
|
||||||
|
exp_alerts: [] # noch in der Karenz - eine volle Runde darf dauern
|
||||||
|
- eval_time: 7h
|
||||||
|
alertname: CveTargetsMissing
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
severity: warning
|
||||||
|
room: security
|
||||||
|
exp_annotations:
|
||||||
|
summary: "24 laufende Images werden NICHT auf CVEs geprueft — die Schwachstellen-Sicht hat ein Loch, das sie selbst nicht zeigt. Details: https://selendis.axion1337.de/d/cve-overview"
|
||||||
|
|
||||||
|
- interval: 1m
|
||||||
|
name: "Volle Deckung schweigt"
|
||||||
|
input_series:
|
||||||
|
- series: 'cve_targets_missing'
|
||||||
|
values: '0+0x480'
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 7h
|
||||||
|
alertname: CveTargetsMissing
|
||||||
|
exp_alerts: []
|
||||||
|
|
||||||
|
# --- CveTargetsOrphaned ---------------------------------------------------
|
||||||
|
- interval: 1m
|
||||||
|
name: "Berichte ohne laufendes Image feuern"
|
||||||
|
input_series:
|
||||||
|
- series: 'cve_targets_orphaned'
|
||||||
|
values: '2+0x480'
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 7h
|
||||||
|
alertname: CveTargetsOrphaned
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
severity: info
|
||||||
|
room: security
|
||||||
|
exp_annotations:
|
||||||
|
summary: "2 CVE-Bericht(e) betreffen Images, die nirgends laufen — Befunde, auf die niemand handeln kann (Rauschen, das wie Signal aussieht)"
|
||||||
|
|
||||||
|
# --- CveTargetSourceStale (die wichtigste) --------------------------------
|
||||||
|
- interval: 1m
|
||||||
|
name: "Eine eingefrorene Quelle faellt auf"
|
||||||
|
input_series:
|
||||||
|
# Die Registry-Quelle altert, die anderen beiden bleiben frisch.
|
||||||
|
- series: 'cve_target_source_stale{quelle="registry"}'
|
||||||
|
values: '0+60x400' # waechst um 60s je Minute -> nach 180 min > 10800
|
||||||
|
- series: 'cve_target_source_stale{quelle="cluster"}'
|
||||||
|
values: '30+0x400'
|
||||||
|
- series: 'cve_target_source_stale{quelle="operating"}'
|
||||||
|
values: '30+0x400'
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 2h
|
||||||
|
alertname: CveTargetSourceStale
|
||||||
|
exp_alerts: [] # 7200s < 10800s - noch in Ordnung
|
||||||
|
- eval_time: 5h
|
||||||
|
alertname: CveTargetSourceStale
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
severity: warning
|
||||||
|
room: security
|
||||||
|
quelle: registry # ⚠️ NUR die eingefrorene, nicht alle drei
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Die Quelle 'registry' der CVE-Zielmenge ist seit 18000s nicht erneuert worden — die Zielmenge schrumpft still, und die Deckung sieht dadurch BESSER aus statt schlechter"
|
||||||
|
|
||||||
|
# --- CveTargetsNoneDesired ------------------------------------------------
|
||||||
|
# Der Fall, den die drei anderen NICHT sehen: alle Quellen antworten sauber,
|
||||||
|
# aber leer. missing = 0, stale frisch - und es wird nichts mehr geprueft.
|
||||||
|
- interval: 1m
|
||||||
|
name: "Leere Zielmenge trotz gesunder Quellen"
|
||||||
|
input_series:
|
||||||
|
- series: 'cve_targets_desired_total'
|
||||||
|
values: '0+0x120'
|
||||||
|
- series: 'cve_targets_missing'
|
||||||
|
values: '0+0x120'
|
||||||
|
- series: 'cve_target_source_stale{quelle="cluster"}'
|
||||||
|
values: '5+0x120'
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 20m
|
||||||
|
alertname: CveTargetsNoneDesired
|
||||||
|
exp_alerts: [] # Karenz 30m
|
||||||
|
- eval_time: 40m
|
||||||
|
alertname: CveTargetsNoneDesired
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
severity: critical
|
||||||
|
room: security
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Die CVE-Zielmenge ist LEER — es wird nichts mehr geprueft, obwohl keine Quelle einen Fehler meldet"
|
||||||
|
# Gegenprobe im selben Lauf: die anderen drei schweigen hier, obwohl die
|
||||||
|
# Lage schlecht ist. Genau deshalb braucht es die vierte Regel.
|
||||||
|
- eval_time: 40m
|
||||||
|
alertname: CveTargetsMissing
|
||||||
|
exp_alerts: []
|
||||||
|
- eval_time: 40m
|
||||||
|
alertname: CveTargetSourceStale
|
||||||
|
exp_alerts: []
|
||||||
|
|
||||||
|
# --- Gegenprobe: gesunder Zustand loest KEINEN der vier Alarme aus --------
|
||||||
|
- interval: 1m
|
||||||
|
name: "Alles in Ordnung bleibt still"
|
||||||
|
input_series:
|
||||||
|
- series: 'cve_targets_missing'
|
||||||
|
values: '0+0x480'
|
||||||
|
- series: 'cve_targets_orphaned'
|
||||||
|
values: '0+0x480'
|
||||||
|
- series: 'cve_targets_desired_total'
|
||||||
|
values: '65+0x480'
|
||||||
|
- series: 'cve_target_source_stale{quelle="cluster"}'
|
||||||
|
values: '120+0x480'
|
||||||
|
- series: 'cve_target_source_stale{quelle="operating"}'
|
||||||
|
values: '120+0x480'
|
||||||
|
- series: 'cve_target_source_stale{quelle="registry"}'
|
||||||
|
values: '900+0x480'
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 7h
|
||||||
|
alertname: CveTargetsMissing
|
||||||
|
exp_alerts: []
|
||||||
|
- eval_time: 7h
|
||||||
|
alertname: CveTargetsOrphaned
|
||||||
|
exp_alerts: []
|
||||||
|
- eval_time: 7h
|
||||||
|
alertname: CveTargetsNoneDesired
|
||||||
|
exp_alerts: []
|
||||||
|
- 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: []
|
||||||
|
|
||||||
|
# --- CveZielmengeEingebrochen (#0051, Vorfall 2026-08-23) ------------------
|
||||||
|
- interval: 1m
|
||||||
|
name: "Ein Einbruch der Zielmenge faellt auf"
|
||||||
|
input_series:
|
||||||
|
# Der echte Verlauf: 54 Ziele, dann faellt die Cluster-Quelle still aus.
|
||||||
|
- series: 'cve_targets_desired_total'
|
||||||
|
values: '54+0x120 12+0x120'
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 1h
|
||||||
|
alertname: CveZielmengeEingebrochen
|
||||||
|
exp_alerts: [] # noch bei 54
|
||||||
|
- eval_time: 2h5m
|
||||||
|
alertname: CveZielmengeEingebrochen
|
||||||
|
exp_alerts: [] # eingebrochen, aber noch in der Karenz
|
||||||
|
- eval_time: 2h30m
|
||||||
|
alertname: CveZielmengeEingebrochen
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
severity: warning
|
||||||
|
room: security
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Die CVE-Zielmenge ist auf 12 Ziele eingebrochen — ueber 40 % weniger als in den letzten 24 h. Faellt eine Quelle still aus, sieht das Dashboard danach BESSER aus, nicht schlechter."
|
||||||
|
# ⚠️ Gegenprobe zum Zusammenspiel: die vier aelteren Regeln schweigen in
|
||||||
|
# genau dieser Lage. Deshalb brauchte es eine fuenfte.
|
||||||
|
- eval_time: 2h30m
|
||||||
|
alertname: CveTargetsNoneDesired
|
||||||
|
exp_alerts: []
|
||||||
|
- eval_time: 2h30m
|
||||||
|
alertname: CveTargetsMissing
|
||||||
|
exp_alerts: []
|
||||||
|
|
||||||
|
- interval: 1m
|
||||||
|
name: "Ein kleiner Rueckbau bleibt still"
|
||||||
|
input_series:
|
||||||
|
- series: 'cve_targets_desired_total'
|
||||||
|
values: '54+0x60 44+0x180' # -19 %, unter der Schwelle
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 3h
|
||||||
|
alertname: CveZielmengeEingebrochen
|
||||||
|
exp_alerts: []
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Recording Rules fuer den Game-Host (management #0002).
|
||||||
|
#
|
||||||
|
# Materialisiert die Verknuepfung Container-UUID -> Klarname. cAdvisor kennt
|
||||||
|
# Gameserver nur unter ihrer UUID, weil Wings die Container so benennt und kein
|
||||||
|
# Namens-Label setzt. Relabeling kann das nicht aufloesen - es kann keine zweite
|
||||||
|
# Metrik nachschlagen. Diese Regeln schreiben deshalb alle 30 s Serien fort, die
|
||||||
|
# den Klarnamen bereits mitbringen, damit ihn nicht jedes Panel neu joinen muss.
|
||||||
|
#
|
||||||
|
# label_replace setzt zusaetzlich "display" auf den Klarnamen. Damit bedeutet
|
||||||
|
# display in JEDER Quelle dasselbe - in Loki, in den cAdvisor-Serien und hier:
|
||||||
|
# der Name, den Menschen lesen sollen.
|
||||||
|
#
|
||||||
|
# Uebernommen aus der Uebergabe des Game-Host-Betreibers vom 2026-08-20; dort
|
||||||
|
# gegen Prometheus 3.0.0 mit `promtool check rules` geprueft.
|
||||||
|
groups:
|
||||||
|
- name: gameserver
|
||||||
|
interval: 30s
|
||||||
|
rules:
|
||||||
|
- record: gameserver:cpu_cores
|
||||||
|
expr: |
|
||||||
|
label_replace(
|
||||||
|
sum by (uuid) (rate(container_cpu_usage_seconds_total{uuid!=""}[2m]))
|
||||||
|
* on (uuid) group_left(server_name, game) pterodactyl_server_info,
|
||||||
|
"display", "$1", "server_name", "(.*)")
|
||||||
|
|
||||||
|
- record: gameserver:memory_working_set_bytes
|
||||||
|
expr: |
|
||||||
|
label_replace(
|
||||||
|
sum by (uuid) (container_memory_working_set_bytes{uuid!=""})
|
||||||
|
* on (uuid) group_left(server_name, game) pterodactyl_server_info,
|
||||||
|
"display", "$1", "server_name", "(.*)")
|
||||||
|
|
||||||
|
- record: gameserver:memory_limit_bytes
|
||||||
|
expr: |
|
||||||
|
label_replace(
|
||||||
|
max by (uuid) (container_spec_memory_limit_bytes{uuid!=""} > 0)
|
||||||
|
* on (uuid) group_left(server_name, game) pterodactyl_server_info,
|
||||||
|
"display", "$1", "server_name", "(.*)")
|
||||||
|
|
||||||
|
- record: gameserver:network_receive_bytes_per_second
|
||||||
|
expr: |
|
||||||
|
label_replace(
|
||||||
|
sum by (uuid) (rate(container_network_receive_bytes_total{uuid!=""}[2m]))
|
||||||
|
* on (uuid) group_left(server_name, game) pterodactyl_server_info,
|
||||||
|
"display", "$1", "server_name", "(.*)")
|
||||||
|
|
||||||
|
- record: gameserver:network_transmit_bytes_per_second
|
||||||
|
expr: |
|
||||||
|
label_replace(
|
||||||
|
sum by (uuid) (rate(container_network_transmit_bytes_total{uuid!=""}[2m]))
|
||||||
|
* on (uuid) group_left(server_name, game) pterodactyl_server_info,
|
||||||
|
"display", "$1", "server_name", "(.*)")
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
global:
|
global:
|
||||||
scrape_interval: 15s
|
scrape_interval: 15s
|
||||||
|
|
||||||
|
rule_files:
|
||||||
|
- /etc/prometheus/alerts.yml
|
||||||
|
# Join Container-UUID -> Klarname, einmal vorweggenommen (#0002).
|
||||||
|
- /etc/prometheus/gameserver-rules.yml
|
||||||
|
|
||||||
|
alerting:
|
||||||
|
alertmanagers:
|
||||||
|
- static_configs:
|
||||||
|
- targets: ["alertmanager:9093"]
|
||||||
|
|
||||||
# Zusaetzlich zu diesen Scrape-Targets kommen Metriken per Remote-Write rein
|
# Zusaetzlich zu diesen Scrape-Targets kommen Metriken per Remote-Write rein
|
||||||
# (k3s-Cluster: flux, kube_state_metrics; Matrix-Server: synapse).
|
# (k3s-Cluster: flux, kube_state_metrics; Matrix-Server: synapse).
|
||||||
scrape_configs:
|
scrape_configs:
|
||||||
@@ -18,6 +28,14 @@ scrape_configs:
|
|||||||
static_configs:
|
static_configs:
|
||||||
- targets: ["cadvisor:8080"]
|
- targets: ["cadvisor:8080"]
|
||||||
|
|
||||||
|
# Alertmanager-Eigenmetriken. Ohne diesen Job sind ZUSTELLfehler unsichtbar:
|
||||||
|
# Prometheus kennt alertmanager zwar als Alarm-Ziel (oben unter alerting:),
|
||||||
|
# scrapt ihn aber nicht - eine still reissende Alarmkette waere also genau das,
|
||||||
|
# was niemand meldet. Liefert u.a. alertmanager_notifications_failed_total.
|
||||||
|
- job_name: "operating_alertmanager"
|
||||||
|
static_configs:
|
||||||
|
- targets: ["alertmanager:9093"]
|
||||||
|
|
||||||
- job_name: "operating_traefik"
|
- job_name: "operating_traefik"
|
||||||
metrics_path: "/metrics"
|
metrics_path: "/metrics"
|
||||||
static_configs:
|
static_configs:
|
||||||
@@ -30,12 +48,132 @@ scrape_configs:
|
|||||||
labels:
|
labels:
|
||||||
instance: "k3s-host"
|
instance: "k3s-host"
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Game-Host, privat ueber den vSwitch (management #0002).
|
||||||
|
#
|
||||||
|
# Bis 2026-08-20 zeigten die Targets auf 157.90.155.206 und waren seit dem
|
||||||
|
# ersten Scrape nie up. Ursache war nicht die Firewall, sondern: Die
|
||||||
|
# Exporter-Container veroeffentlichten ihre Ports gar nicht.
|
||||||
|
#
|
||||||
|
# Host-Seite deployt und verifiziert am 2026-08-20 (Uebergabe des Game-Host-
|
||||||
|
# Betreibers). Ports auf 10.0.0.4, NICHT auf 0.0.0.0 - letzteres umginge ufw.
|
||||||
|
# cadvisor liegt host-seitig auf 8081, weil coolify-proxy 0.0.0.0:8080 haelt.
|
||||||
|
#
|
||||||
|
# metric_relabel_configs gehoeren immer dem SCRAPENDEN Prometheus. Der
|
||||||
|
# Game-Host hatte sein Label-Konzept lokal; mit dem Rueckbau seines eigenen
|
||||||
|
# Prometheus wandert es hierher, sonst faellt es ersatzlos weg.
|
||||||
|
#
|
||||||
|
# instance = "gameserver" statt "pterodactyl": Damit tragen Metrik und Log
|
||||||
|
# denselben Schluessel - promtail setzt host="gameserver". Kostet keine
|
||||||
|
# Historie, die Targets sind am 2026-08-20 zum ersten Mal ueberhaupt up
|
||||||
|
# gegangen.
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
- job_name: "pterodactyl_host_node"
|
- job_name: "pterodactyl_host_node"
|
||||||
static_configs:
|
static_configs:
|
||||||
- targets: ["157.90.155.206:9100"]
|
- targets: ["10.0.0.4:9100"]
|
||||||
labels:
|
labels:
|
||||||
instance: "pterodactyl"
|
host: gameserver
|
||||||
|
relabel_configs:
|
||||||
|
- target_label: instance
|
||||||
|
replacement: gameserver
|
||||||
|
metric_relabel_configs:
|
||||||
|
# node-exporter meldet als nodename die UTS-Kennung SEINES Containers.
|
||||||
|
# Betrifft nur node_uname_info und repariert $nodename in "Node Exporter
|
||||||
|
# Full". Der Host setzt zusaetzlich hostname: gameserver - beides zusammen
|
||||||
|
# ist widerspruchsfrei.
|
||||||
|
- source_labels: [nodename]
|
||||||
|
regex: '.+'
|
||||||
|
target_label: nodename
|
||||||
|
replacement: gameserver
|
||||||
|
|
||||||
- job_name: "gameserver_cadvisor"
|
- job_name: "gameserver_cadvisor"
|
||||||
static_configs:
|
static_configs:
|
||||||
- targets: ["157.90.155.206:8080"]
|
- targets: ["10.0.0.4:8081"]
|
||||||
|
labels:
|
||||||
|
host: gameserver
|
||||||
|
relabel_configs:
|
||||||
|
- target_label: instance
|
||||||
|
replacement: gameserver
|
||||||
|
metric_relabel_configs:
|
||||||
|
# 1) Gameserver-Container heissen nur nach ihrer UUID. In ein eigenes
|
||||||
|
# Label kopieren, damit der Join auf pterodactyl_server_info moeglich
|
||||||
|
# wird - siehe gameserver-rules.yml.
|
||||||
|
- source_labels: [name]
|
||||||
|
regex: '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'
|
||||||
|
target_label: uuid
|
||||||
|
replacement: '$1'
|
||||||
|
|
||||||
|
# 2) "display" = der Name, den Menschen lesen sollen. Zwei Stufen: roher
|
||||||
|
# Container-Name, dann ohne die 24-stellige Coolify-Kennung.
|
||||||
|
# "homepage-pqr9de7xmm8s5cpm2yss69s3" -> "homepage"
|
||||||
|
- source_labels: [name]
|
||||||
|
regex: '(.+)'
|
||||||
|
target_label: display
|
||||||
|
replacement: '$1'
|
||||||
|
- source_labels: [name]
|
||||||
|
regex: '(.+)-[a-z0-9]{24}'
|
||||||
|
target_label: display
|
||||||
|
replacement: '$1'
|
||||||
|
|
||||||
|
# 3) Wings setzt an Gameserver-Containern gar keine Docker-Labels - ohne
|
||||||
|
# diese zwei Regeln blieben sie als einzige ohne app/stack.
|
||||||
|
- source_labels: [name]
|
||||||
|
regex: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
|
||||||
|
target_label: app
|
||||||
|
replacement: 'gameserver'
|
||||||
|
- source_labels: [name]
|
||||||
|
regex: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
|
||||||
|
target_label: stack
|
||||||
|
replacement: 'pterodactyl'
|
||||||
|
|
||||||
|
# 4) Sprechende Kurzlabels. Die Coolify-Regel steht jeweils HINTER der
|
||||||
|
# Compose-Regel und ueberschreibt sie: com.docker.compose.project
|
||||||
|
# liefert die Kennung, coolify.resourceName den sprechenden Namen.
|
||||||
|
- source_labels: [container_label_com_docker_compose_service]
|
||||||
|
regex: '(.+)'
|
||||||
|
target_label: app
|
||||||
|
replacement: '$1'
|
||||||
|
- source_labels: [container_label_coolify_serviceName]
|
||||||
|
regex: '(.+)'
|
||||||
|
target_label: app
|
||||||
|
replacement: '$1'
|
||||||
|
- source_labels: [container_label_com_docker_compose_project]
|
||||||
|
regex: '(.+)'
|
||||||
|
target_label: stack
|
||||||
|
replacement: '$1'
|
||||||
|
- source_labels: [container_label_coolify_resourceName]
|
||||||
|
regex: '(.+)'
|
||||||
|
target_label: stack
|
||||||
|
replacement: '$1'
|
||||||
|
|
||||||
|
# 5) Rund 20 container_label_* je Serie (Image-Hashes, Traefik-Router,
|
||||||
|
# Pfade) fliegen raus.
|
||||||
|
- regex: 'container_label_.*'
|
||||||
|
action: labeldrop
|
||||||
|
|
||||||
|
# 6) container_*-Serien ohne name sind systemd-cgroup-Slices, die
|
||||||
|
# node-exporter ohnehin abdeckt. machine_*-Metriken bleiben erhalten,
|
||||||
|
# deshalb muss __name__ hier mitgreifen.
|
||||||
|
- source_labels: [__name__, name]
|
||||||
|
regex: 'container_.+;'
|
||||||
|
action: drop
|
||||||
|
|
||||||
|
# Port 9810, NICHT 9531: Der bisherige Exporter (loens2) auf 9531 hat nie
|
||||||
|
# funktioniert - er rief die Client-API mit einem Application-Key auf und ueber
|
||||||
|
# http statt https und lieferte deshalb null pterodactyl_*-Metriken. Ersetzt
|
||||||
|
# durch einen Exporter, der die Panel-MariaDB read-only liest und gar keinen
|
||||||
|
# API-Key braucht.
|
||||||
|
- job_name: "pterodactyl_exporter"
|
||||||
|
static_configs:
|
||||||
|
- targets: ["10.0.0.4:9810"]
|
||||||
|
labels:
|
||||||
|
host: gameserver
|
||||||
|
relabel_configs:
|
||||||
|
- target_label: instance
|
||||||
|
replacement: gameserver
|
||||||
|
|
||||||
|
# CVE-Exporter (gitops#47)
|
||||||
|
- job_name: "cve_exporter"
|
||||||
|
static_configs:
|
||||||
|
- targets: ["cve-exporter:9101"]
|
||||||
|
|||||||
Reference in New Issue
Block a user