152 lines
4.2 KiB
Bash
152 lines
4.2 KiB
Bash
|
|
#!/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
|