Focus Lynis host audit on highest risk finding

This commit is contained in:
juvdiaz 2026-06-29 14:42:58 -06:00
parent eaf74b50eb
commit e1c33ff42e
3 changed files with 163 additions and 24 deletions

View File

@ -143,8 +143,8 @@ baseline passive scans for targets in `security/targets.txt`.
: Run kube-bench CIS checks against the Debian kubeadm host. : Run kube-bench CIS checks against the Debian kubeadm host.
`security-host` `security-host`
: Run a Lynis host audit on Debian and print only warnings and suggestions. : Run a Lynis host audit on Debian and print one highest-risk finding to focus
This command is report-only. on first. This command is report-only.
`security-trivy` `security-trivy`
: Run only Trivy filesystem and IaC/config scans. : 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 : `security-host` requires Lynis to be installed on the Debian host. Install it
explicitly with `sudo apt install lynis`. 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` `LAB_AUTO_APPROVE=false`
: Disable automatic OpenTofu approval for apply paths that support confirmation. : Disable automatic OpenTofu approval for apply paths that support confirmation.

View File

@ -10,6 +10,17 @@ ZAP_IMAGE="${ZAP_IMAGE:-ghcr.io/zaproxy/zaproxy:stable}"
KUBE_BENCH_IMAGE="${KUBE_BENCH_IMAGE:-aquasec/kube-bench:latest}" KUBE_BENCH_IMAGE="${KUBE_BENCH_IMAGE:-aquasec/kube-bench:latest}"
NUCLEI_IMAGE="${NUCLEI_IMAGE:-projectdiscovery/nuclei:latest}" NUCLEI_IMAGE="${NUCLEI_IMAGE:-projectdiscovery/nuclei:latest}"
truthy() {
case "${1,,}" in
1 | true | yes | y | on | enabled)
return 0
;;
*)
return 1
;;
esac
}
usage() { usage() {
cat <<'EOF' cat <<'EOF'
Usage: scripts/security-scan {all|prepare|trivy|zap|kube-bench|nuclei|host} 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_TARGETS_FILE Target allowlist for web scans.
SECURITY_ZAP_TARGET Single URL override for ZAP. SECURITY_ZAP_TARGET Single URL override for ZAP.
SECURITY_NUCLEI_TARGET Single URL override for nuclei. SECURITY_NUCLEI_TARGET Single URL override for nuclei.
LAB_BACKSTAGE_BRAIN_ENABLED=true enables LLM-assisted Lynis triage.
EOF EOF
} }
@ -182,34 +194,153 @@ run_kube_bench() {
"${KUBE_BENCH_IMAGE}" run --targets master,node | tee "${SECURITY_REPORT_DIR}/kube-bench-${stamp}.txt" "${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 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: <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 local hardening_index
hardening_index="$(awk -F= '$1 == "hardening_index" { print $2; exit }' "${report_file}" 2>/dev/null || true)" 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 if [[ -n "${hardening_index}" ]]; then
printf 'Hardening index: %s\n' "${hardening_index}" printf 'Hardening index: %s\n' "${hardening_index}"
fi fi
printf '\n-- Warnings\n' findings_file="$(mktemp)"
if [[ -n "${warnings}" ]]; then lynis_findings "${report_file}" >"${findings_file}"
printf '%s\n' "${warnings}" | sed 's/^/- /' if ! print_lynis_llm_focus "${findings_file}"; then
else print_lynis_fallback_focus "${findings_file}"
printf 'none\n'
fi
printf '\n-- Suggestions\n'
if [[ -n "${suggestions}" ]]; then
printf '%s\n' "${suggestions}" | sed 's/^/- /'
else
printf 'none\n'
fi fi
rm -f "${findings_file}"
} }
run_lynis_host() { run_lynis_host() {
@ -242,7 +373,7 @@ EOF
--report-file "${report_file}" \ --report-file "${report_file}" \
--log-file "${log_file}" >/dev/null --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 '\nFull Lynis report: %s\n' "${report_file}"
printf 'Full Lynis log: %s\n' "${log_file}" printf 'Full Lynis log: %s\n' "${log_file}"
} }

View File

@ -25,7 +25,7 @@ The focused commands are:
- `security-zap`: OWASP ZAP baseline scan only. - `security-zap`: OWASP ZAP baseline scan only.
- `security-k8s`: kube-bench CIS checks against the kubeadm host. - `security-k8s`: kube-bench CIS checks against the kubeadm host.
- `security-host`: Lynis host audit summary for Debian. This command is - `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-trivy`: Trivy repo and IaC/config scans only.
- `security-nuclei`: low-rate nuclei HTTP exposure 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 `security-host` intentionally does not self-fix Lynis findings. Some hardening
recommendations can break SSH access, Docker networking, kubeadm, or public edge 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 routing. When `LAB_BACKSTAGE_BRAIN_ENABLED=true`, Jeannie asks the local
explicit Jeannie command for that specific change. 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.