#!/usr/bin/env bash set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" HOMELAB_STATE_DIR="${HOMELAB_STATE_DIR:-${XDG_DATA_HOME:-${HOME}/.local/share}/homelab}" SECURITY_REPORT_DIR="${SECURITY_REPORT_DIR:-${HOMELAB_STATE_DIR}/security-reports}" SECURITY_TARGETS_FILE="${SECURITY_TARGETS_FILE:-${REPO_ROOT}/security/targets.txt}" TRIVY_IMAGE="${TRIVY_IMAGE:-aquasec/trivy:latest}" ZAP_IMAGE="${ZAP_IMAGE:-ghcr.io/zaproxy/zaproxy:stable}" KUBE_BENCH_IMAGE="${KUBE_BENCH_IMAGE:-aquasec/kube-bench:latest}" NUCLEI_IMAGE="${NUCLEI_IMAGE:-projectdiscovery/nuclei:latest}" GITLEAKS_IMAGE="${GITLEAKS_IMAGE:-ghcr.io/gitleaks/gitleaks:latest}" truthy() { case "${1,,}" in 1 | true | yes | y | on | enabled) return 0 ;; *) return 1 ;; esac } usage() { cat <<'EOF' Usage: scripts/security-scan {all|prepare|trivy|zap|kube-bench|nuclei|host|web|secrets} Environment: SECURITY_REPORT_DIR Report output directory. SECURITY_TARGETS_FILE Target allowlist for web scans. SECURITY_ZAP_TARGET Single URL override for ZAP. SECURITY_NUCLEI_TARGET Single URL override for nuclei. SECURITY_WEB_TARGET Single URL override for web header checks. LAB_BACKSTAGE_BRAIN_ENABLED=true enables LLM-assisted Lynis triage. EOF } timestamp() { date -u +%Y%m%dT%H%M%SZ } ensure_report_dir() { mkdir -p "${SECURITY_REPORT_DIR}" } require_docker() { if ! command -v docker >/dev/null 2>&1; then echo "docker is required for containerized security scans." >&2 exit 1 fi } prepare_security_scans() { local image local -a images=( "${TRIVY_IMAGE}" "${ZAP_IMAGE}" "${KUBE_BENCH_IMAGE}" "${NUCLEI_IMAGE}" "${GITLEAKS_IMAGE}" ) ensure_report_dir require_docker if ! docker info >/dev/null 2>&1; then echo "docker is installed but not reachable by this user." >&2 exit 1 fi if [[ ! -s "${SECURITY_TARGETS_FILE}" ]]; then echo "Missing target file: ${SECURITY_TARGETS_FILE}" >&2 exit 1 fi if [[ ! -w "${SECURITY_REPORT_DIR}" ]]; then echo "Report directory is not writable: ${SECURITY_REPORT_DIR}" >&2 exit 1 fi echo "Preparing security scan images..." for image in "${images[@]}"; do echo "--> docker pull ${image}" docker pull "${image}" done echo "Security scan prerequisites are ready." echo "Report directory: ${SECURITY_REPORT_DIR}" } run_trivy() { local stamp stamp="$(timestamp)" ensure_report_dir echo "Running Trivy filesystem and IaC/config scans..." if command -v trivy >/dev/null 2>&1; then trivy fs --scanners vuln,secret,misconfig --format table --output "${SECURITY_REPORT_DIR}/trivy-fs-${stamp}.txt" "${REPO_ROOT}" trivy config --format table --output "${SECURITY_REPORT_DIR}/trivy-config-${stamp}.txt" "${REPO_ROOT}" else require_docker docker run --rm \ -v "${REPO_ROOT}:/repo:ro" \ -v "${SECURITY_REPORT_DIR}:/reports" \ "${TRIVY_IMAGE}" fs --scanners vuln,secret,misconfig --format table --output "/reports/trivy-fs-${stamp}.txt" /repo docker run --rm \ -v "${REPO_ROOT}:/repo:ro" \ -v "${SECURITY_REPORT_DIR}:/reports" \ "${TRIVY_IMAGE}" config --format table --output "/reports/trivy-config-${stamp}.txt" /repo fi echo "Trivy reports written to ${SECURITY_REPORT_DIR}" } run_gitleaks() { local stamp local report_file stamp="$(timestamp)" ensure_report_dir report_file="${SECURITY_REPORT_DIR}/gitleaks-${stamp}.json" echo "Running gitleaks secret scan against this repo..." if command -v gitleaks >/dev/null 2>&1; then gitleaks detect \ --source "${REPO_ROOT}" \ --report-format json \ --report-path "${report_file}" \ --redact else require_docker docker run --rm \ -v "${REPO_ROOT}:/repo:ro" \ -v "${SECURITY_REPORT_DIR}:/reports:rw" \ "${GITLEAKS_IMAGE}" detect \ --source /repo \ --report-format json \ --report-path "/reports/gitleaks-${stamp}.json" \ --redact fi echo "gitleaks report: ${report_file}" } scan_targets() { local target_override="$1" if [[ -n "${target_override}" ]]; then printf '%s\n' "${target_override}" return 0 fi if [[ ! -s "${SECURITY_TARGETS_FILE}" ]]; then echo "Missing target file: ${SECURITY_TARGETS_FILE}" >&2 exit 1 fi sed -e 's/#.*//' -e '/^[[:space:]]*$/d' "${SECURITY_TARGETS_FILE}" } safe_target_name() { printf '%s' "$1" | sed -E 's#^https?://##; s#[^A-Za-z0-9._-]+#_#g; s#_+$##' } run_zap() { local stamp local target local safe_name stamp="$(timestamp)" ensure_report_dir require_docker echo "Running OWASP ZAP baseline passive scans..." while IFS= read -r target; do [[ -n "${target}" ]] || continue safe_name="$(safe_target_name "${target}")" echo "--> ZAP baseline ${target}" docker run --rm \ -v "${SECURITY_REPORT_DIR}:/zap/wrk:rw" \ "${ZAP_IMAGE}" zap-baseline.py \ -t "${target}" \ -I \ -r "zap-${safe_name}-${stamp}.html" \ -J "zap-${safe_name}-${stamp}.json" done < <(scan_targets "${SECURITY_ZAP_TARGET:-}") echo "ZAP reports written to ${SECURITY_REPORT_DIR}" } run_nuclei() { local stamp local target local safe_name stamp="$(timestamp)" ensure_report_dir require_docker echo "Running nuclei low-noise HTTP exposure scans..." while IFS= read -r target; do [[ -n "${target}" ]] || continue safe_name="$(safe_target_name "${target}")" echo "--> nuclei ${target}" docker run --rm \ -v "${SECURITY_REPORT_DIR}:/reports:rw" \ "${NUCLEI_IMAGE}" \ -u "${target}" \ -severity low,medium,high,critical \ -rate-limit "${SECURITY_NUCLEI_RATE_LIMIT:-5}" \ -retries 1 \ -timeout 5 \ -o "/reports/nuclei-${safe_name}-${stamp}.txt" done < <(scan_targets "${SECURITY_NUCLEI_TARGET:-}") echo "nuclei reports written to ${SECURITY_REPORT_DIR}" } header_value() { local headers_file="$1" local header_name="$2" awk -v name="${header_name}" ' BEGIN { IGNORECASE = 1 } index($0, name ":") == 1 { sub(/^[^:]+:[[:space:]]*/, "") sub(/\r$/, "") print exit } ' "${headers_file}" } run_security_web() { local stamp local target local safe_name local headers_file local body_file local report_file local status local failures=0 local missing=0 local -a required_headers=( "strict-transport-security" "x-content-type-options" "x-frame-options" "referrer-policy" ) stamp="$(timestamp)" ensure_report_dir report_file="${SECURITY_REPORT_DIR}/security-web-${stamp}.txt" { echo "Security web quick check" echo "Generated: ${stamp}" echo } >"${report_file}" while IFS= read -r target; do [[ -n "${target}" ]] || continue safe_name="$(safe_target_name "${target}")" headers_file="${SECURITY_REPORT_DIR}/headers-${safe_name}-${stamp}.txt" body_file="${SECURITY_REPORT_DIR}/headers-${safe_name}-${stamp}.body" echo "== ${target}" | tee -a "${report_file}" if [[ "${target}" != https://* ]]; then echo "fail: target is not HTTPS" | tee -a "${report_file}" failures=$((failures + 1)) fi status="$( curl -k -sS -L \ --connect-timeout "${SECURITY_WEB_CONNECT_TIMEOUT:-8}" \ --max-time "${SECURITY_WEB_MAX_TIME:-20}" \ -D "${headers_file}" \ -o "${body_file}" \ -w '%{http_code}' \ "${target}" 2>>"${report_file}" || true )" if [[ ! "${status}" =~ ^2|3[0-9][0-9]$ ]]; then echo "fail: HTTP status ${status:-curl failed}" | tee -a "${report_file}" failures=$((failures + 1)) else echo "ok: HTTP status ${status}" | tee -a "${report_file}" fi missing=0 for header_name in "${required_headers[@]}"; do if [[ -n "$(header_value "${headers_file}" "${header_name}")" ]]; then echo "ok: ${header_name}" | tee -a "${report_file}" else echo "warn: missing ${header_name}" | tee -a "${report_file}" missing=$((missing + 1)) fi done if [[ -n "$(header_value "${headers_file}" "content-security-policy")" ]]; then echo "ok: content-security-policy" | tee -a "${report_file}" else echo "info: content-security-policy missing or intentionally deferred" | tee -a "${report_file}" fi if ((missing > 0)); then failures=$((failures + 1)) fi echo | tee -a "${report_file}" >/dev/null rm -f "${body_file}" done < <(scan_targets "${SECURITY_WEB_TARGET:-}") echo "Security web report: ${report_file}" if ((failures > 0)); then echo "security-web found ${failures} target(s) needing review." >&2 return 1 fi } run_kube_bench() { local stamp stamp="$(timestamp)" ensure_report_dir require_docker echo "Running kube-bench CIS checks against this kubeadm host..." docker run --rm --pid=host \ -v /etc:/etc:ro \ -v /var:/var:ro \ -v /usr/bin:/usr/local/mount-from-host/bin:ro \ "${KUBE_BENCH_IMAGE}" run --targets master,node | tee "${SECURITY_REPORT_DIR}/kube-bench-${stamp}.txt" } lynis_findings() { local report_file="$1" sed -n 's/^warning\[\]=/warning|/p; s/^suggestion\[\]=/suggestion|/p' "${report_file}" 2>/dev/null | awk -F'|' 'NF >= 2 { source = $1; finding = $2; test = $NF; print source "|" finding (NF > 2 ? " [" test "]" : "") }' } print_lynis_fallback_focus() { local findings_file="$1" local selected selected="$(awk -F'|' '$1 == "warning" { print; exit }' "${findings_file}")" if [[ -z "${selected}" ]]; then selected="$(awk -F'|' '$1 == "suggestion" { print; exit }' "${findings_file}")" fi if [[ -z "${selected}" ]]; then printf '\n== Highest-Risk Lynis Finding ==\n' printf 'No Lynis warnings or suggestions were found.\n' return 0 fi printf '\n== Highest-Risk Lynis Finding ==\n' printf 'Source: %s\n' "$(cut -d'|' -f1 <<<"${selected}")" printf 'Finding: %s\n' "$(cut -d'|' -f2- <<<"${selected}")" printf 'Why this one: LLM triage was unavailable, so Jeannie selected the first Lynis warning, falling back to the first suggestion.\n' printf 'Mode: report-only. Add an explicit narrow Jeannie hardening command before changing host state.\n' } print_lynis_llm_focus() { local findings_file="$1" local endpoint="${LAB_AI_GATEWAY_URL:-${LAB_OLLAMA_URL:-http://127.0.0.1:11434}}" local model="${LAB_AI_GATEWAY_MODEL:-qwen2.5:0.5b}" local timeout="${LAB_AI_GATEWAY_TIMEOUT_SECONDS:-20}" local index_dir="${LAB_AI_KNOWLEDGE_INDEX_DIR:-/data/homelab-ai/index}" local context="" if ! truthy "${LAB_BACKSTAGE_BRAIN_ENABLED:-false}"; then return 1 fi if ! command -v python3 >/dev/null 2>&1; then return 1 fi if [[ -s "${index_dir}/index.json" && -x "${REPO_ROOT}/scripts/query-homelab-ai-index" ]]; then context="$("${REPO_ROOT}/scripts/query-homelab-ai-index" --index-dir "${index_dir}" --context-only --limit 4 "Lynis host hardening Debian Docker kubeadm SSH homelab" 2>/dev/null || true)" fi LYNIS_FINDINGS="$(head -120 "${findings_file}")" \ LYNIS_CONTEXT="${context}" \ python3 - "${endpoint}" "${model}" "${timeout}" <<'PY' import json import os import sys import urllib.error import urllib.request endpoint, model, timeout = sys.argv[1:4] findings = os.environ.get("LYNIS_FINDINGS", "").strip() context = os.environ.get("LYNIS_CONTEXT", "").strip() if not findings: raise SystemExit(1) try: timeout_seconds = int(timeout) except ValueError: timeout_seconds = 20 try: with urllib.request.urlopen(f"{endpoint.rstrip('/')}/api/tags", timeout=3) as response: tags = json.loads(response.read().decode("utf-8")) available = {item.get("name", "") for item in tags.get("models", [])} if model not in available and f"{model}:latest" not in available: raise SystemExit(1) except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError): raise SystemExit(1) prompt = f"""You are Jeannie, a report-only homelab security triage helper. The host is a Debian personal homelab server running Docker, kubeadm, Gitea, Tailscale, local registry, and public edge routing. Do not recommend broad automatic fixes. Pick exactly ONE Lynis finding to focus on first. Rank by risk and operational value. Prefer findings that reduce real exposure without likely breaking SSH, Docker networking, Kubernetes, or remote access. Return exactly this format: Finding: Why highest risk: Next safe action: Automation stance: report-only; add an explicit Jeannie command only after review Homelab context: {context} Lynis findings: {findings} """ payload = { "model": model, "prompt": prompt, "stream": False, "options": { "temperature": 0.1, "num_predict": 220, }, } try: request = urllib.request.Request( f"{endpoint.rstrip('/')}/api/generate", data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(request, timeout=timeout_seconds) as response: result = json.loads(response.read().decode("utf-8")) except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError): raise SystemExit(1) answer = (result.get("response") or "").strip() if not answer: raise SystemExit(1) print("\n== Highest-Risk Lynis Finding ==") print(answer) PY } print_lynis_focus() { local report_file="$1" local findings_file local hardening_index hardening_index="$(awk -F= '$1 == "hardening_index" { print $2; exit }' "${report_file}" 2>/dev/null || true)" printf '\n== Lynis Host Audit ==\n' if [[ -n "${hardening_index}" ]]; then printf 'Hardening index: %s\n' "${hardening_index}" fi findings_file="$(mktemp)" lynis_findings "${report_file}" >"${findings_file}" if ! print_lynis_llm_focus "${findings_file}"; then print_lynis_fallback_focus "${findings_file}" fi rm -f "${findings_file}" } run_lynis_host() { local stamp local report_file local log_file stamp="$(timestamp)" ensure_report_dir report_file="${SECURITY_REPORT_DIR}/lynis-host-${stamp}.dat" log_file="${SECURITY_REPORT_DIR}/lynis-host-${stamp}.log" if ! command -v lynis >/dev/null 2>&1; then cat >&2 <<'EOF' lynis is not installed on this host. Install it on Debian with: sudo apt install lynis Jeannie does not install Lynis automatically because host hardening audits should remain explicit and report-only. EOF exit 1 fi echo "Running Lynis host audit in report-only mode..." sudo lynis audit system \ --no-colors \ --quiet \ --report-file "${report_file}" \ --log-file "${log_file}" >/dev/null print_lynis_focus "${report_file}" printf '\nFull Lynis report: %s\n' "${report_file}" printf 'Full Lynis log: %s\n' "${log_file}" } case "${1:-all}" in all) run_trivy run_gitleaks run_zap ;; prepare) prepare_security_scans ;; trivy) run_trivy ;; secrets) run_gitleaks ;; zap) run_zap ;; nuclei) run_nuclei ;; web) run_security_web ;; kube-bench) run_kube_bench ;; host) run_lynis_host ;; -h | --help | help) usage ;; *) usage >&2 exit 1 ;; esac