#!/usr/bin/env bash # nuclei-scan.sh — ProjectDiscovery nuclei scan against a target. # # Default template families : cves, vulnerabilities, exposures. # Fail-on-severity floor configurable via env (default HIGH). # # v1.0.9 W5 Day 21. # # Usage: # TARGET=https://staging.veza.fr bash scripts/security/nuclei-scan.sh # # Required env : # TARGET Full URL. # # Optional env : # REPORT_DIR Output dir (default ./security-reports). # TEMPLATES Comma-separated nuclei template directories. # Default : "cves,vulnerabilities,exposures". # FAIL_ON critical | high (default) | medium | low | info. # # Exit codes : # 0 — clean # 2 — findings at or above the FAIL_ON floor # 3 — runner error (target unreachable, nuclei missing, etc). set -euo pipefail TARGET=${TARGET:-?} REPORT_DIR=${REPORT_DIR:-./security-reports} TEMPLATES=${TEMPLATES:-cves,vulnerabilities,exposures} 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 } translate_severity_floor() { # nuclei -severity accepts a comma-list of severities to INCLUDE. case "$1" in critical) echo "critical" ;; high) echo "critical,high" ;; medium) echo "critical,high,medium" ;; low) echo "critical,high,medium,low" ;; info) echo "critical,high,medium,low,info" ;; *) fail "FAIL_ON must be critical|high|medium|low|info (got '$1')" 3 ;; esac } severity_regex() { # Pattern matched against the JSON line's info.severity. case "$1" in critical) echo "^critical$" ;; high) echo "^(critical|high)$" ;; medium) echo "^(critical|high|medium)$" ;; low) echo "^(critical|high|medium|low)$" ;; info) echo "^(critical|high|medium|low|info)$" ;; esac } if [ "$TARGET" = "?" ]; then fail "TARGET env var required (e.g. https://staging.veza.fr)" 3 fi # Prefer a host nuclei install ; fall back to docker. if command -v nuclei >/dev/null 2>&1; then RUNNER="nuclei" elif command -v docker >/dev/null 2>&1; then RUNNER="docker" else fail "neither nuclei nor docker found in PATH" 3 fi require date require jq mkdir -p "$REPORT_DIR" report_jsonl="$REPORT_DIR/nuclei-$(date +%Y%m%d-%H%M).jsonl" # Build the template flags (-t one per directory). template_args=() IFS=',' read -ra parts <<< "$TEMPLATES" for p in "${parts[@]}"; do template_args+=(-t "$p/") done log "Starting nuclei scan against $TARGET" log " templates : $TEMPLATES" log " report : $report_jsonl" log " runner : $RUNNER" set +e case "$RUNNER" in nuclei) nuclei -u "$TARGET" \ "${template_args[@]}" \ -severity "$(translate_severity_floor "$FAIL_ON")" \ -jsonl -o "$report_jsonl" \ -nc -duc \ -timeout 10 nuclei_exit=$? ;; docker) docker run --rm \ -v "$(realpath "$REPORT_DIR")":/reports:rw \ projectdiscovery/nuclei:latest \ -u "$TARGET" \ "${template_args[@]}" \ -severity "$(translate_severity_floor "$FAIL_ON")" \ -jsonl -o "/reports/$(basename "$report_jsonl")" \ -nc -duc \ -timeout 10 nuclei_exit=$? ;; esac set -e if [ ! -f "$report_jsonl" ]; then # nuclei doesn't write the file when no findings are produced — that's # the green path. Touch it so jq below doesn't choke. : > "$report_jsonl" fi # Count findings at or above the floor. floor_re=$(severity_regex "$FAIL_ON") hit_count=$(jq -r --arg re "$floor_re" \ 'select(.info.severity | test($re; "i"))' \ "$report_jsonl" 2>/dev/null | jq -s 'length' 2>/dev/null || echo 0) log "" log "=== nuclei summary ===" log " Target : $TARGET" log " Floor : $FAIL_ON" log " Findings ≥ floor : $hit_count" log " Report : $report_jsonl" log " nuclei exit : $nuclei_exit" log "======================" if [ "$hit_count" -gt 0 ]; then log "" log "Top findings ≥ floor :" jq -r --arg re "$floor_re" \ 'select(.info.severity | test($re; "i")) | " - [\(.info.severity | ascii_upcase)] \(.template-id) — \(.matched-at // .host)"' \ "$report_jsonl" >&2 || true exit 2 fi log "PASS: 0 findings at or above $FAIL_ON severity" exit 0