#!/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