From e1c33ff42e29ea246ce015774fd4ec35076990fa Mon Sep 17 00:00:00 2001 From: juvdiaz Date: Mon, 29 Jun 2026 14:42:58 -0600 Subject: [PATCH] Focus Lynis host audit on highest risk finding --- docs/jeannie.1.md | 9 ++- scripts/security-scan | 169 +++++++++++++++++++++++++++++++++++++----- security/README.md | 9 ++- 3 files changed, 163 insertions(+), 24 deletions(-) diff --git a/docs/jeannie.1.md b/docs/jeannie.1.md index 9d77dcf..c55103f 100644 --- a/docs/jeannie.1.md +++ b/docs/jeannie.1.md @@ -143,8 +143,8 @@ baseline passive scans for targets in `security/targets.txt`. : Run kube-bench CIS checks against the Debian kubeadm host. `security-host` -: Run a Lynis host audit on Debian and print only warnings and suggestions. -This command is report-only. +: Run a Lynis host audit on Debian and print one highest-risk finding to focus +on first. This command is report-only. `security-trivy` : Run only Trivy filesystem and IaC/config scans. @@ -227,6 +227,11 @@ smoke test. Defaults to `cloudflare.com`. : `security-host` requires Lynis to be installed on the Debian host. Install it explicitly with `sudo apt install lynis`. +`LAB_BACKSTAGE_BRAIN_ENABLED=true` +: Allows `security-host` to ask the local Ollama/RAG helper to choose the single +highest-risk Lynis finding. If the helper is disabled or unavailable, Jeannie +falls back to the first Lynis warning, then the first suggestion. + `LAB_AUTO_APPROVE=false` : Disable automatic OpenTofu approval for apply paths that support confirmation. diff --git a/scripts/security-scan b/scripts/security-scan index 2aff6c1..feeb257 100755 --- a/scripts/security-scan +++ b/scripts/security-scan @@ -10,6 +10,17 @@ 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} @@ -19,6 +30,7 @@ Environment: SECURITY_TARGETS_FILE Target allowlist for web scans. SECURITY_ZAP_TARGET Single URL override for ZAP. SECURITY_NUCLEI_TARGET Single URL override for nuclei. + LAB_BACKSTAGE_BRAIN_ENABLED=true enables LLM-assisted Lynis triage. EOF } @@ -182,34 +194,153 @@ run_kube_bench() { "${KUBE_BENCH_IMAGE}" run --targets master,node | tee "${SECURITY_REPORT_DIR}/kube-bench-${stamp}.txt" } -print_lynis_recommendations() { +lynis_findings() { local report_file="$1" - local warnings - local suggestions + + 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)" - warnings="$(sed -n 's/^warning\[\]=//p' "${report_file}" 2>/dev/null | awk -F'|' '{ print $1 (NF > 1 ? " [" $NF "]" : "") }')" - suggestions="$(sed -n 's/^suggestion\[\]=//p' "${report_file}" 2>/dev/null | awk -F'|' '{ print $1 (NF > 1 ? " [" $NF "]" : "") }')" - printf '\n== Lynis Host Recommendations ==\n' + printf '\n== Lynis Host Audit ==\n' if [[ -n "${hardening_index}" ]]; then printf 'Hardening index: %s\n' "${hardening_index}" fi - printf '\n-- Warnings\n' - if [[ -n "${warnings}" ]]; then - printf '%s\n' "${warnings}" | sed 's/^/- /' - else - printf 'none\n' - fi - - printf '\n-- Suggestions\n' - if [[ -n "${suggestions}" ]]; then - printf '%s\n' "${suggestions}" | sed 's/^/- /' - else - printf 'none\n' + 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() { @@ -242,7 +373,7 @@ EOF --report-file "${report_file}" \ --log-file "${log_file}" >/dev/null - print_lynis_recommendations "${report_file}" + print_lynis_focus "${report_file}" printf '\nFull Lynis report: %s\n' "${report_file}" printf 'Full Lynis log: %s\n' "${log_file}" } diff --git a/security/README.md b/security/README.md index f1a3562..4dab248 100644 --- a/security/README.md +++ b/security/README.md @@ -25,7 +25,7 @@ The focused commands are: - `security-zap`: OWASP ZAP baseline scan only. - `security-k8s`: kube-bench CIS checks against the kubeadm host. - `security-host`: Lynis host audit summary for Debian. This command is - report-only and prints warnings/suggestions. + report-only and prints one highest-risk finding to focus on first. - `security-trivy`: Trivy repo and IaC/config scans only. - `security-nuclei`: low-rate nuclei HTTP exposure scans only. @@ -44,5 +44,8 @@ services you own. `security-host` intentionally does not self-fix Lynis findings. Some hardening recommendations can break SSH access, Docker networking, kubeadm, or public edge -routing. Use Lynis output to choose one narrow remediation at a time, then add an -explicit Jeannie command for that specific change. +routing. When `LAB_BACKSTAGE_BRAIN_ENABLED=true`, Jeannie asks the local +Ollama/RAG helper to select exactly one finding; otherwise it falls back to the +first Lynis warning, then the first suggestion. The full Lynis report is still +saved so you can review the complete output before adding any explicit hardening +command.