#!/usr/bin/env bash # zap-baseline-scan.sh — OWASP ZAP baseline scan against a target. # # Wraps the canonical ZAP container invocation, parses the report, # and exits non-zero when any HIGH-severity finding is reported. # Intended to run from the W5 pre-flight pentest workflow + as a # manual operator command on staging. # # v1.0.9 W5 Day 21. # # Usage: # TARGET=https://staging.veza.fr bash scripts/security/zap-baseline-scan.sh # # Required env : # TARGET Full URL of the target (https://staging.veza.fr). # # Optional env : # REPORT_DIR Where to drop the report (default ./security-reports). # CONFIG_FILE Optional ZAP context file (.context). # FAIL_ON severity floor : HIGH (default) | MEDIUM | LOW. # # Exit codes : # 0 — scan complete, no findings at or above the FAIL_ON floor. # 2 — scan complete but found at least one finding at or above floor. # 3 — scan failed to run (docker missing, target unreachable, etc). set -euo pipefail TARGET=${TARGET:-?} REPORT_DIR=${REPORT_DIR:-./security-reports} CONFIG_FILE=${CONFIG_FILE:-} FAIL_ON=${FAIL_ON:-HIGH} log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >&2; } fail() { log "FAIL: $*"; exit "${2:-3}"; } require() { command -v "$1" >/dev/null 2>&1 || fail "required tool missing: $1" 3 } require docker require date require jq if [ "$TARGET" = "?" ]; then fail "TARGET env var required (e.g. https://staging.veza.fr)" 3 fi mkdir -p "$REPORT_DIR" report_html="$REPORT_DIR/zap-baseline-$(date +%Y%m%d-%H%M).html" report_json="$REPORT_DIR/zap-baseline-$(date +%Y%m%d-%H%M).json" log "Starting ZAP baseline scan against $TARGET" log " report HTML : $report_html" log " report JSON : $report_json" # `zap-baseline.py` is the recommended entrypoint for the CI/quick scan # workflow ; it walks the target, runs the passive scan rules, and # emits a report. -I so it doesn't error on temporary dependency # resolution issues ; -m 5 = 5 minutes spider budget. docker_args=( --rm -v "$(realpath "$REPORT_DIR")":/zap/wrk:rw -t ghcr.io/zaproxy/zaproxy:stable zap-baseline.py -t "$TARGET" -I -m 5 -r "$(basename "$report_html")" -J "$(basename "$report_json")" ) if [ -n "$CONFIG_FILE" ]; then docker_args+=(-c "$CONFIG_FILE") fi # zap-baseline.py exits 1 when any rule triggers WARN, 2 on FAIL ; we # don't want to fail the script on warnings, only on findings at the # requested floor. Capture exit + parse the JSON ourselves. set +e docker run "${docker_args[@]}" zap_exit=$? set -e if [ ! -f "$report_json" ]; then fail "ZAP did not produce $report_json (zap_exit=$zap_exit)" 3 fi # Parse the JSON for findings at the requested severity floor. # ZAP's risk codes : 0=Info, 1=Low, 2=Medium, 3=High. case "$FAIL_ON" in HIGH) floor=3 ;; MEDIUM) floor=2 ;; LOW) floor=1 ;; *) fail "FAIL_ON must be HIGH | MEDIUM | LOW (got '$FAIL_ON')" 3 ;; esac high_count=$(jq -r --argjson floor "$floor" \ '[.site[]?.alerts[]? | select((.riskcode | tonumber) >= $floor)] | length' \ "$report_json") log "" log "=== ZAP baseline summary ===" log " Target : $TARGET" log " ZAP exit : $zap_exit" log " Floor : $FAIL_ON (riskcode >= $floor)" log " Findings ≥ floor : $high_count" log " HTML report : $report_html" log "=============================" if [ "$high_count" -gt 0 ]; then log "" log "Top findings ≥ floor :" jq -r --argjson floor "$floor" \ '.site[]?.alerts[]? | select((.riskcode | tonumber) >= $floor) | " - [\(.risk)] \(.alert) — \(.instances | length) occurrence(s)"' \ "$report_json" >&2 || true exit 2 fi log "PASS: 0 findings at or above $FAIL_ON severity" exit 0