517 lines
15 KiB
Bash
Executable File
517 lines
15 KiB
Bash
Executable File
#!/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}"
|
|
|
|
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}
|
|
|
|
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}"
|
|
)
|
|
|
|
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}"
|
|
}
|
|
|
|
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: <one selected finding>
|
|
Why highest risk: <one sentence>
|
|
Next safe action: <one concrete manual validation or narrow remediation>
|
|
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_zap
|
|
;;
|
|
prepare)
|
|
prepare_security_scans
|
|
;;
|
|
trivy)
|
|
run_trivy
|
|
;;
|
|
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
|