Files
tessera-ctl/.gitea/scripts/zap-baseline.sh
T
schalli bf632d90a5 fix(quick-261009-p0m): Review-Befunde WR-01/02/04/05, IN-01/02/03/04 (Abbild-Bau, Semgrep im Container, Zeitbudget, ZAP-Haken)
- WR-01: prisma generate als eigener Schritt mit Pruefung im api-Abbild, Bau scheitert ohne Client; Startprobe als .gitea/scripts/image-start-check.sh versioniert
- WR-02: Semgrep im offiziellen Container, per Digest angepinnt (kein pipx/PyPI mehr)
- WR-04: Zeitbudget 1500 s, Limits je Werkzeug, Ergebniszeile sofort nach jedem Werkzeug
- WR-05: Authorization-Ersetzung der ZAP-Pruefung nur fuer das Ziel
- IN-01: yarn/npm per Platzhalter entfernt und Abwesenheit geprueft (api und web)
- IN-02: Kommentar zu doppelten Kopfzeilen in next.config.ts richtiggestellt
- IN-03: Berichtsordner Modus 700, Zugangsdaten nur als base64 (keine curl-Konfiguration)
- IN-04: flacher Klon wird bei gitleaks als unvollstaendig gemeldet

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 20:59:12 +02:00

199 lines
8.7 KiB
Bash
Executable File

#!/bin/sh
# zap-baseline.sh -- Grundpruefung von aussen vor einer Freigabe (quick-261009-p0m).
#
# Prueft den Testserver (alpha) wie ein Besucher ohne Konto, mit dem OWASP-ZAP-Abbild:
# Startseite, Anmeldeseite, Kopfzeilen, Cookies und ausgelieferte Dateien werden
# abgerufen und passiv ausgewertet. Es ist KEIN Angriffswerkzeug im Einsatz:
# - nur die klassische Spinne (Links folgen), kein AJAX-Spider (der wuerde das
# Anmeldeformular ausfuellen und absenden),
# - Formulare werden weder ausgefuellt noch abgesendet (Haken zap-hooks.py),
# - nur passive Regeln, kein aktiver Scan, also keine Anfragen mit Schadmustern.
# Laeuft von Hand auf dem Entwicklungsrechner, nicht in der Pipeline.
#
# Aufrufe (aus dem Repository-Wurzelverzeichnis):
# sh .gitea/scripts/zap-baseline.sh # Lauf gegen das Standardziel
# ZAP_TARGET=http://HOST:3000 sh .gitea/scripts/zap-baseline.sh
# sh .gitea/scripts/zap-baseline.sh --print-plan # nur anzeigen, ohne Netz
#
# Umgebungsvariablen:
# ZAP_TARGET Ziel-Adresse (Standard: https://alpha.tessera.ctl.de). Wer den
# vorgeschalteten Proxy umgehen will, gibt die direkte Adresse
# der Anwendung an (http://HOST:3000).
# ZAP_SPIDER_MINUTES Hoechstdauer der Spinne in Minuten (Standard: 3)
# ZAP_REPORT_DIR Berichtsordner (Standard: security-reports/zap-{UTC-Zeit});
# security-reports/ steht in .gitignore und .dockerignore
# ZAP_DOCKER_NETWORK optional: Docker-Netz fuer den Container (--network)
# ZAP_BASIC_AUTH_FILE optional: Datei AUSSERHALB des Repositorys mit einer Zeile
# benutzer:passwort. Nur noetig, falls das Ziel einmal vom
# Entwicklungsrechner aus eine Anmeldung (401) verlangt. Der
# Kopf geht ueber eine temporaere Umgebungsdatei (Modus 0600,
# wird beim Beenden entfernt) in den Container, nie auf eine
# Befehlszeile und in keine Ausgabe. Der Haken schickt ihn NUR an
# das Ziel (ZAP_TARGET), nie an andere Adressen. Sonderzeichen im
# Passwort (" oder \) sind erlaubt.
#
# Berichtsordner: Modus 700 (nur der ZAP-Benutzer mit Kennung 1000 schreibt, niemand sonst liest).
#
# Hinweis: Wird die Anmeldedatei benutzt, enthaelt der Bericht die gesendeten Kopfzeilen
# und damit die Zugangsdaten. Dann den Berichtsordner nicht weitergeben.
#
# Ergebnis: zap.html, zap.md, zap.json im Berichtsordner, dazu die Zeile
# ZAP-SUMMARY ziel=HOST hoch=N mittel=N niedrig=N info=N
# und je Meldungsart eine Zeile. Exit 0, wenn zap.json existiert, sonst 3 (damit der
# Freigabeschritt einen fehlenden Bericht bemerkt). Das Skript gibt kein Secret aus.
set -u
IMAGE='ghcr.io/zaproxy/zaproxy@sha256:7aaa659b0d43078febd82e29bad112285c370727e86ab8340444220e17d9f0d2'
TARGET="${ZAP_TARGET:-https://alpha.tessera.ctl.de}"
TARGET="${TARGET%/}"
MINUTES="${ZAP_SPIDER_MINUTES:-3}"
STAMP=$(date -u +%Y%m%d-%H%M%S)
REPORT_DIR="${ZAP_REPORT_DIR:-security-reports/zap-$STAMP}"
HOOK_IN_CONTAINER=/zap/hooks/zap-hooks.py
HERE=$(cd "$(dirname "$0")" && pwd)
HOOK_FILE="$HERE/zap-hooks.py"
# Host-Teil des Ziels fuer die Ausgabe (ohne Schema und Pfad)
HOST=${TARGET#*://}
HOST=${HOST%%/*}
ARGS="-t $TARGET -m $MINUTES -I -T 15 --autooff --hook=$HOOK_IN_CONTAINER -r zap.html -w zap.md -J zap.json"
if [ "${1:-}" = "--print-plan" ]; then
echo "ziel $TARGET"
echo "abbild $IMAGE"
echo "berichte $REPORT_DIR"
echo "zap-baseline.py $ARGS"
exit 0
fi
TMP_FILES=""
RUN_LOG=""
# Entfernt die temporaeren Dateien mit dem Kopf (Zugangsdaten).
cleanup() {
for f in $TMP_FILES; do rm -f "$f"; done
TMP_FILES=""
}
# Beim Beenden (auch bei Abbruch) zusaetzlich das Zwischenprotokoll entfernen.
cleanup_all() {
cleanup
[ -n "$RUN_LOG" ] && rm -f "$RUN_LOG"
}
trap cleanup_all EXIT HUP INT TERM PIPE
echo "ZAP-Grundpruefung gegen $TARGET (nur passiv, keine Formulare, keine Angriffe)"
# --- Vorabpruefung: Antwortet das Ziel, und verlangt es eine Anmeldung? ---------
CODE=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 "$TARGET/login" 2>/dev/null) || CODE=000
ENV_FILE=""
case "$CODE" in
200)
;;
401)
if [ -z "${ZAP_BASIC_AUTH_FILE:-}" ] || [ ! -r "${ZAP_BASIC_AUTH_FILE:-}" ]; then
echo "Das Ziel verlangt eine Anmeldung (Status 401)." >&2
echo "Legen Sie dafuer eine Datei ausserhalb des Repositorys an, mit einer Zeile" >&2
echo "benutzer:passwort, und rufen Sie das Skript mit ZAP_BASIC_AUTH_FILE=Pfad auf." >&2
exit 4
fi
# Die Zugangsdaten gehen nur als base64-Text weiter (enthaelt keine Anfuehrungszeichen,
# Rueckstriche oder Leerzeichen), also nie durch einen Parser, der Sonderzeichen deutet:
# weder curl-Konfiguration noch Befehlszeile. Passwoerter mit " oder \ sind unkritisch.
B64=$(head -n 1 "$ZAP_BASIC_AUTH_FILE" | tr -d '\r\n' | base64 | tr -d '\n')
HDR_FILE=$(mktemp) || exit 4
ENV_FILE=$(mktemp) || exit 4
TMP_FILES="$HDR_FILE $ENV_FILE"
chmod 600 "$HDR_FILE" "$ENV_FILE"
printf 'Authorization: Basic %s\n' "$B64" > "$HDR_FILE"
printf 'ZAP_BASIC_AUTH=Basic %s\n' "$B64" > "$ENV_FILE"
B64=""
CODE2=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 -H "@$HDR_FILE" "$TARGET/login" 2>/dev/null) || CODE2=000
if [ "$CODE2" != "200" ]; then
echo "Mit den hinterlegten Zugangsdaten antwortet das Ziel mit Status $CODE2, Abbruch." >&2
exit 4
fi
;;
*)
echo "Das Ziel antwortet auf /login nicht mit Status 200 (Status $CODE), Abbruch." >&2
echo "Pruefen Sie die Erreichbarkeit vom Entwicklungsrechner aus." >&2
exit 4
;;
esac
# --- Berichtsordner: der Container laeuft als Benutzer zap (Kennung 1000) ---------
# Nur dieser eine Benutzer darf schreiben (Modus 700), kein anderer Benutzer des Rechners
# darf lesen -- der Bericht kann die Zugangsdaten enthalten. Hat Ihr Konto die Kennung
# 1000 (der Normalfall), genuegt chmod; sonst uebergibt ein kurzer Container-Aufruf den
# Ordner an die Kennung 1000 und danach wieder an Sie zurueck.
mkdir -p "$REPORT_DIR" || exit 3
REPORT_DIR=$(cd "$REPORT_DIR" && pwd)
chmod 700 "$REPORT_DIR"
ME_UID=$(id -u)
ME_GID=$(id -g)
HANDOVER=no
if [ "$ME_UID" != "1000" ]; then
if docker run --rm --user 0:0 -v "$REPORT_DIR:/zap/wrk:rw" "$IMAGE" chown 1000:1000 /zap/wrk >/dev/null 2>&1; then
HANDOVER=yes
else
echo "Der Berichtsordner konnte nicht an den ZAP-Benutzer (Kennung 1000) uebergeben werden." >&2
exit 3
fi
fi
set -- docker run --rm
[ -n "${ZAP_DOCKER_NETWORK:-}" ] && set -- "$@" --network "$ZAP_DOCKER_NETWORK"
[ -n "$ENV_FILE" ] && set -- "$@" --env-file "$ENV_FILE"
set -- "$@" -v "$REPORT_DIR:/zap/wrk:rw" -v "$HOOK_FILE:$HOOK_IN_CONTAINER:ro" "$IMAGE" \
zap-baseline.py -t "$TARGET" -m "$MINUTES" -I -T 15 --autooff "--hook=$HOOK_IN_CONTAINER" \
-r zap.html -w zap.md -J zap.json
# Exit-Codes von ZAP (0 ok, 1 Fehler, 2 Warnung, 3 Fehlstart) sind hier nicht
# entscheidend: es zaehlt, ob der Bericht entstanden ist.
# Das Protokoll geht zuerst in eine eigene Datei: der Berichtsordner gehoert waehrend des
# Laufs eventuell der Kennung 1000, nicht Ihrem Konto.
RUN_LOG=$(mktemp) || exit 3
"$@" > "$RUN_LOG" 2>&1
# Die Umgebungsdatei mit dem Kopf wird nur fuer den Lauf gebraucht: sofort entfernen.
cleanup
if [ "$HANDOVER" = yes ]; then
# Ordner samt Inhalt zurueck an das eigene Konto, damit Sie den Bericht lesen koennen.
docker run --rm --user 0:0 -v "$REPORT_DIR:/zap/wrk:rw" "$IMAGE" chown -R "$ME_UID:$ME_GID" /zap/wrk >/dev/null 2>&1 ||
echo "Hinweis: Der Berichtsordner gehoert noch der Kennung 1000; mit sudo chown -R $ME_UID:$ME_GID uebernehmen." >&2
fi
cat "$RUN_LOG" > "$REPORT_DIR/zap-lauf.log" 2>/dev/null
rm -f "$RUN_LOG"
RUN_LOG=""
echo "ZAP-Lauf beendet (Protokoll: $REPORT_DIR/zap-lauf.log)"
if [ ! -s "$REPORT_DIR/zap.json" ]; then
echo "Es ist kein Bericht entstanden (zap.json fehlt)." >&2
tail -n 15 "$REPORT_DIR/zap-lauf.log" >&2
exit 3
fi
python3 -I - "$REPORT_DIR/zap.json" "$HOST" <<'PY'
import json, sys
path, host = sys.argv[1], sys.argv[2]
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
seen = {}
for site in data.get("site", []):
for alert in site.get("alerts", []):
key = (alert.get("pluginid"), alert.get("name"))
seen[key] = int(alert.get("riskcode", 0))
count = {3: 0, 2: 0, 1: 0, 0: 0}
for risk in seen.values():
count[risk] = count.get(risk, 0) + 1
print("ZAP-SUMMARY ziel=%s hoch=%d mittel=%d niedrig=%d info=%d"
% (host, count[3], count[2], count[1], count[0]))
word = {3: "hoch", 2: "mittel", 1: "niedrig", 0: "info"}
for (_pid, name), risk in sorted(seen.items(), key=lambda kv: (-kv[1], str(kv[0][1]))):
print("ZAP-MELDUNG %s %s" % (word.get(risk, str(risk)), name))
PY
echo "Berichte: $REPORT_DIR (zap.html, zap.md, zap.json)"
exit 0