docs: Gate 3 for #0106 — eight files, and the five calls I trust least

Gitea's package API wants a token, so the tag timestamps come from the registry
itself: manifest, then config blob, then the created field, anonymously, about
ninety requests a round. The constraint of no new credentials survives.

The derivation is cached rather than run per scrape; at a fifteen second scrape
interval it would otherwise make some twenty-one thousand registry requests a
day.

Two assertions carry the design. A source that fails must leave its own share
empty while the others keep delivering, and when every source fails the target
file is not overwritten at all. Deleting reports follows the same rule: no
target file, no deletion, or a restart during a Prometheus outage would clear
the whole estate.

The shakiest call is written down as such: cAdvisor only sees containers that
run, so a service that happens to be down is missing from the desired set and
coverage still reads a hundred percent. That is the hole ADR-0026 warns about,
and Gate 4 has to check it against docker compose config before criterion 2
counts as met.
This commit is contained in:
Thore Cimbal
2026-08-21 12:00:00 +00:00
parent 2693a2d2f8
commit a0cd02afd9
3 changed files with 140 additions and 4 deletions
+1 -1
View File
@@ -86,7 +86,7 @@ Bedeutung der Meilensteine: siehe [roadmap.md](roadmap.md).
| Design | Gate | Title |
|---|---|---|
| [2026-08-21-cve-ziele-ableiten](docs/design/2026-08-21-cve-ziele-ableiten.md) | gate-2 | Design: Die CVE-Zielmenge ableiten statt pflegen (#0106) |
| [2026-08-21-cve-ziele-ableiten](docs/design/2026-08-21-cve-ziele-ableiten.md) | gate-3 | Design: Die CVE-Zielmenge ableiten statt pflegen (#0106) |
## ADRs (26)
+136 -2
View File
@@ -1,6 +1,6 @@
---
type: design
status: gate-2
status: gate-3
date: 2026-08-21
size: L
related:
@@ -233,4 +233,138 @@ hinaus — es ist die allgemeine Form dessen, was hier schiefging. Als
Ziele ableitet, muss das **Ausbleiben der Herleitung** alarmieren, weil eine
leere Soll-Menge sonst wie vollständige Deckung aussieht.
> **STOP — Freigabe für Gate 2.**
> **Gate 2 freigegeben durch sorb, 2026-08-21.**
## Gate 3 — Program Design
### Dateien
Alle im Repo `threadnet-operating`, alle unter `monitoring/`.
| Pfad | Art | Was |
|---|---|---|
| `cve/targets.py` | **neu** | Herleitung der Soll-Menge; stdlib, importierbar |
| `cve/cve-exporter.py` | geändert | ruft die Herleitung, schreibt `targets.txt`, gibt die fünf neuen Metriken aus |
| `cve/scan-loop.sh` | geändert | liest `targets.txt` statt `images.txt`; löscht Berichte ausgefallener Ziele |
| `cve/images.txt` | **gelöscht** | Kriterium 1 |
| `docker-compose.yml` | geändert | Volume `cve_targets` (Exporter rw, Scanner ro); Umgebung für Quellen |
| `prometheus/alerts.yml` | geändert | drei Regeln in Gruppe `axion-cve` |
| `grafana/dashboards/security/cve-overview.json` | geändert | zwei Panels: Deckung, Frische je Quelle |
| `cve/test_targets.py` | **neu** | Zusicherungen, gegen aufgezeichnete Antworten |
### Signaturen — `cve/targets.py`
```python
NormalisiertesImage = str # ohne docker.io/ und library/, mit Tag
def normalisiere(ref: str) -> NormalisiertesImage: ...
def promql(basis_url: str, ausdruck: str, timeout: float = 10.0) -> list[dict]: ...
def ziele_cluster(basis_url: str) -> set[NormalisiertesImage]: ...
def ziele_operating(basis_url: str) -> set[NormalisiertesImage]: ... # job=operating_cadvisor
def registry_token(registry: str, scope: str) -> str: ...
def registry_repos(registry: str) -> list[str]: ...
def registry_tags(registry: str, repo: str) -> list[str]: ...
def tag_erstellt(registry: str, repo: str, tag: str) -> datetime | None: ...
def ziele_registry(registry: str, je_repo: int = 3) -> set[NormalisiertesImage]: ...
class Herleitung(NamedTuple):
ziele: set[NormalisiertesImage]
je_quelle: dict[str, set[NormalisiertesImage]] # cluster | operating | registry
fehler: dict[str, str] # Quelle -> Meldung
stand: dict[str, float] # Quelle -> unix-ts des Erfolgs
def herleiten(cfg: dict) -> Herleitung: ...
```
### Aufrufweg des Hauptflusses
```
cve-exporter: collect() alle 15 s (Scrape)
└─ ziel_cache.hole() gibt zwischengespeicherte Herleitung
└─ targets.herleiten(cfg) hoechstens alle SOLL_INTERVALL (Vorgabe 1 h)
├─ ziele_cluster() → promql("count by (image) (kube_pod_container_info)")
├─ ziele_operating() → promql('...{job="operating_cadvisor",image!=""}')
└─ ziele_registry() → registry_repos → registry_tags → tag_erstellt* → juengste 3
├─ schreibe targets.txt nur bei Erfolg mindestens einer Quelle
└─ metriken: cve_target_desired, _coverage_ratio, _missing, _orphaned, _source_stale
cve-scan: scan-loop.sh alle 24 h
├─ liest /targets/targets.txt fehlt sie → Runde aussetzen, NICHTS loeschen
├─ je Ziel: trivy image → /results/<safe>.json
└─ loescht /results/*.json ohne Ziel in targets.txt
```
⚠️ **Die Herleitung läuft nicht je Scrape.** Bei 15 s Scrape-Intervall wären das
rund 21 000 Registry-Anfragen am Tag. Zwischenspeicher mit eigenem Intervall,
die Metriken werden aus dem Zwischenspeicher bedient.
### Was die Tests zusichern
**Normalisierung**`docker.io/library/postgres:17-alpine`, `library/postgres:17-alpine`
und `postgres:17-alpine` ergeben denselben Wert; `rohana.axion1337.de/sorb/x:v1`
und `ghcr.io/a/b:v1` bleiben unangetastet; ein Ref ohne Tag wird verworfen, nicht
zu `:latest` ergänzt (sonst entstünde ein Ziel, das es nicht gibt).
**Quellentrennung** — aus einer Antwort mit `operating_cadvisor` **und**
`gameserver_cadvisor` erscheint kein Ziel der zweiten Menge (Kriterium 6).
**Registry-Auswahl** — aus Tags mit Zeitstempeln werden die jüngsten drei
gewählt, **nach Zeit, nicht nach Namen**: `v0.10.0` neuer als `v0.9.0` muss
gewinnen. `sha-*` und `latest*` fallen vorher raus.
**Ausfallverhalten (der Kern)** — fällt eine Quelle aus, ist ihr Anteil leer,
`fehler` trägt die Meldung, `stand` bleibt auf dem alten Wert stehen, **und die
übrigen Quellen liefern weiter**. Fallen *alle* aus, ist `ziele` leer und
`targets.txt` wird **nicht** überschrieben.
**Deckungsrechnung** — bei Soll = Ist ist `_missing` 0 und `ratio` 1,0; bei einem
fehlenden Ziel ist `_missing` 1; bei einem überzähligen Bericht ist `_orphaned` 1.
Gegenprobe mit einem absichtlich falschen Paar, damit die Rechnung nachweislich
anschlagen kann (AAR-Lehre: eine Prüfung schuldet den Nachweis, dass sie rot
werden kann).
**Berichts-Löschung** — ein Bericht, dessen Ziel nicht mehr in `targets.txt`
steht, wird gelöscht; bei **fehlender** `targets.txt` wird nichts gelöscht.
### Grenzen — DO NOT CHANGE
- `alertmanager/matrix-alerts.py`, `alertmanager/alertmanager.yml` — der
Meldeweg ist Nicht-Ziel (ADR-0003).
- Die bestehenden fünf Regeln `TrivyCriticalVulns`, `TrivyHighVulns`,
`TrivyScanStale`, `TrivyReportUnreadable`, `TrivyNoReports` — es kommen
Regeln **dazu**, keine wird umformuliert.
- Das Metrik-Schema aus #0078 (`trivy_vuln_info`, `trivy_vuln_count`,
`trivy_vuln_first_seen_timestamp`, `trivy_last_scan_timestamp`) — nur
Ergänzungen.
- Die `first_seen`-Zustandsdatei und ihre Prune-Logik.
- Alles im Cluster-Repo `gitops`. Dieses Vorhaben fasst nur
`threadnet-operating` an.
- `game-operating`, `gameserver`, Homelab.
### Wackeligste Annahmen
1. ⚠️ **Dass `container_last_seen` den Betriebs-Stack vollständig zeigt.**
Gemessen wurden 12 Images — aber cAdvisor sieht nur, was **läuft**. Ein
Dienst, der gerade aus ist, fehlt in der Soll-Menge, und die Deckung sähe
trotzdem nach 100 % aus. Genau die Lücke, vor der ADR-0026 warnt. Gate 4
muss das gegen `docker compose config` gegenprüfen, bevor Kriterium 2 als
erfüllt gilt.
2. ⚠️ **Dass „die jüngsten drei je Repo" das Richtige treffen.** Bei
`threadnet-web` (30 Tags) sind das `v0.6.0`, `v0.5.4`, `v0.5.3` — plausibel.
Bei `axion-backup` mit 2 Tags sind es beide, auch wenn `v1` niemand mehr
betreibt. Die Regel ist grob; sie ist nur besser als eine Liste.
3. **Dass zwei Anfragen je Tag genügen.** Bei einem Multi-Arch-Index sind es
drei (Index → Manifest → Config). Gemessen an `v0.6.0`, `v0.5.4`, `v0.1.0`;
nicht an allen 36.
4. **Dass der Exporter der richtige Ort bleibt.** Er bekommt eine zweite
Aufgabe und damit einen zweiten Grund auszufallen. Fällt er aus, fehlen
Metriken **und** die Zielmenge veraltet — beides sichtbar, aber gekoppelt.
5. **Dass die Löschung von Berichten nicht zu scharf ist.** Ein Ziel, das für
eine Runde aus der Soll-Menge fällt (etwa weil ein Pod gerade neu startet),
verliert seinen Bericht und damit seine `first_seen`-Historie. Ob das eine
Karenz braucht, entscheidet Gate 4 an der Messung.
> **STOP — Freigabe für Gate 3.**
+3 -1
View File
@@ -14,7 +14,8 @@ related:
| gate | commit | approval | status | note |
|---|---|---|---|---|
| 2 | | | OPEN | Herleitung im Exporter statt drittem Dienst; ADR-0026 vorgeschlagen. |
| 3 | | | OPEN | Dateien, Signaturen, Zusicherungen, Grenzen; fuenf wackelige Annahmen benannt. |
| 2 | 2693a2d | sorb | DONE | Herleitung im Exporter statt drittem Dienst; ADR-0026 vorgeschlagen. |
| 1 | e10cd5d | sorb | DONE | Sechs Kriterien; Registry-Reichweite = Option C (Bestand + letzte drei je Repo). |
## Ladder
@@ -26,6 +27,7 @@ related:
| eine Quelle fuer die laufenden Images, statt eine zu bauen | `kube_pod_container_info` (39) und `container_last_seen{job="operating_cadvisor"}` (12) liegen in **derselben** Prometheus, die der Scanner ueber das vorhandene Compose-Netz erreicht | reused: kein neuer Dienst, keine Zugangsdaten, kein neuer Netzweg | |
| einen Weg, game-operating auszuschliessen | `job="gameserver_cadvisor"` (20 Images) trennt sauber von `operating_cadvisor` | reused: ein Beschriftungsvergleich statt einer Ausschlussliste | |
| ob die Registry ohne neue Zugangsdaten lesbar ist | anonymer Token-Tanz genuegt: `/v2/token` → Katalog und Tag-Listen; 6 Repos, 36 Tags | reused: die Standard-Registry-Authentifizierung, die der Scanner ohnehin schon fuer das Ziehen macht | |
| einen anonymen Weg an die Tag-Zeitstempel, bevor ein Token eingeplant wird | Giteas `/api/v1/packages` verlangt einen Token — die Registry-API dagegen nicht: Manifest → Config-Blob liefert `created`, anonym, ~90 Anfragen je Runde | reused: die Registry-Authentifizierung, die der Scanner ohnehin macht — Randbedingung "keine neuen Zugangsdaten" bleibt gewahrt | |
| einen Ort fuer die Soll-Menge, bevor ein Dienst dafuer gebaut wird | der `cve-exporter` braucht sie fuer die Deckungsmetrik ohnehin und ist ein laufender stdlib-Python-Prozess im selben Netz | reused: bestehender Prozess statt drittem Container — kein `jq`, kein `pip`, keine zweite Wahrheit | |
| eine AAR zu genau diesem Stack, bevor Randbedingungen geraten werden | AAR 2026-08-01: `up -d` aktiviert Configs nicht (im Container pruefen), und `TrivyScanStale` kann ein nie gescanntes Image nicht melden | reused: beide Funde als Randbedingung uebernommen statt neu zu entdecken — Befund 3 bestimmt, dass die Deckung aus der Soll-Menge kommen muss | |
| ob die rohana-Berichte ueberhaupt frisch sind | alle 29 Berichte gleichmaessig 4,1 h alt — der Scanner zieht aus der Registry erfolgreich | reused: die bestehende Scan-Schleife bleibt, nur ihre Zielbeschaffung aendert sich | |