#!/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. # # 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="" cleanup() { for f in $TMP_FILES; do rm -f "$f"; done } trap cleanup 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 CRED=$(head -n 1 "$ZAP_BASIC_AUTH_FILE" | tr -d '\r\n') CURL_CFG=$(mktemp) || exit 4 ENV_FILE=$(mktemp) || exit 4 TMP_FILES="$CURL_CFG $ENV_FILE" chmod 600 "$CURL_CFG" "$ENV_FILE" printf 'user = "%s"\n' "$CRED" > "$CURL_CFG" CODE2=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 -K "$CURL_CFG" "$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 printf 'ZAP_BASIC_AUTH=Basic %s\n' "$(printf '%s' "$CRED" | base64 | tr -d '\n')" > "$ENV_FILE" CRED="" ;; *) 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) --------- mkdir -p "$REPORT_DIR" || exit 3 REPORT_DIR=$(cd "$REPORT_DIR" && pwd) chmod 777 "$REPORT_DIR" 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. "$@" > "$REPORT_DIR/zap-lauf.log" 2>&1 # Die Umgebungsdatei mit dem Kopf wird nur fuer den Lauf gebraucht: sofort entfernen. cleanup TMP_FILES="" 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