From 07875ba6bb6b7138689d64a6c0d57781c5656d7c Mon Sep 17 00:00:00 2001 From: Thore Cimbal Date: Fri, 21 Aug 2026 12:00:00 +0000 Subject: [PATCH] cve: a target that leaves the set loses its report (#0106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, an image dropped from the desired set keeps reporting: the exporter reads every json in the results directory and takes the target from Trivy's own ArtifactName. That is precisely what the security room showed on 2026-08-20, when it carried HIGH findings for threadnet-web:v0.3.0, an image that runs nowhere. Deletion only ever runs against a list that was successfully read. The guard at the top of the round already skips everything when the list is missing or empty, so a restart during a Prometheus outage cannot clear the estate. The round became a function so the test can load the real one. The first version of that test rebuilt the loop instead, and a rebuilt test proves the rebuild — it stayed green while the shipped file set its paths unconditionally and ignored the environment entirely. Loading it exposed that within one run. Two of my own errors are fixed here as well. The driver read `runde || sleep A && sleep B`, which groups left to right, so a missing list would have slept the wait AND the full day — exactly what the short wait exists to prevent. And the paths were hardcoded where the exporter already took them from the environment. Removing the guard as a deliberate sabotage turns the dangerous case red: untouched reports drop from two to zero. --- monitoring/cve/scan-loop.sh | 64 ++++++++++++++++++++--- monitoring/cve/test_scan_loop.sh | 89 ++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 7 deletions(-) create mode 100644 monitoring/cve/test_scan_loop.sh diff --git a/monitoring/cve/scan-loop.sh b/monitoring/cve/scan-loop.sh index 47658f9..a8d0a5d 100644 --- a/monitoring/cve/scan-loop.sh +++ b/monitoring/cve/scan-loop.sh @@ -15,36 +15,86 @@ # nicht verhindern; der Exporter alarmiert ueber trivy_last_scan_timestamp, # wenn ein Report veraltet. set -u -RESULTS=/results +# 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"; } -while true; do +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" - sleep "$WARTEZEIT" - continue + 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" - trivy image --scanners vuln --format json --output "$RESULTS/$safe.json.tmp" "$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" - echo "runde fertig: ${anzahl} Ziele, schlafe ${INTERVAL}s" - sleep "$INTERVAL" + # 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 diff --git a/monitoring/cve/test_scan_loop.sh b/monitoring/cve/test_scan_loop.sh new file mode 100644 index 0000000..c2761aa --- /dev/null +++ b/monitoring/cve/test_scan_loop.sh @@ -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"