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"