← Back to Wiki
Security

Build a Homelab Vulnerability Scanning Pipeline

"We should have some vulnerability scanning" usually means three different things: scanning hosts on your LAN, scanning the container images your fleet actually runs, and scanning your public-facing web apps. Here's a free, complementary stack that covers all three.

1. Network/host scanning — Greenbone Community Edition

Greenbone Community Edition (OpenVAS-based) deploys via an official Docker Compose bundle in a few commands.

Gotcha: the default config only binds to localhost. The stock compose file publishes the web UI as 127.0.0.1-only — deliberately LAN-inaccessible out of the box. Opening it up to your LAN is a real security-relevant change (an admin UI becoming reachable from more places), so treat it as one — don't just flip the binding without thinking about who else can now reach it.
Be patient on first boot. Scan configs won't exist for roughly an hour after first start — the scanner is busy importing the full CVE feed (going back to 1999) plus supplementary vulnerability data. Check the container logs before assuming the deployment is broken.

Once configs appear, a full authenticated scan of your whole subnet is straightforward via the GMP protocol (either the web UI or the CLI tools). A "Full and fast" scan of a typical home subnet takes a few hours — schedule it monthly rather than weekly given the runtime.

2. Container image scanning — Trivy

Trivy scans container images for known CVEs by name/tag, no need to connect to each host's Docker daemon individually. Run it as a native binary so it can scan arbitrary images without extra setup, and write a small script that scans every image your fleet actually runs.

Gotcha: don't parse Trivy's human-readable table output for totals. It's fragile in two ways — a target with zero findings omits the "Total:" line entirely rather than printing zero (a naive grep misreports it as a scan failure), and some images have more than one scanned artifact in the same report (an OS layer plus a separately-tracked binary), so reading only the first table row silently drops real findings. Scan with --format json and sum with jq instead:
trivy image --severity HIGH,CRITICAL --quiet --format json "$img" > "$img.json"
total=$(jq '[.Results[]?.Vulnerabilities // [] | length] | add // 0' "$img.json")

3. Web app scanning — OWASP ZAP

OWASP ZAP's baseline mode (spider + passive analysis only, no active attack traffic) is safe to run against live production sites, including anything security-sensitive. Run it one-shot per target from its official Docker image.

Gotcha: don't build deeply nested shell commands inline over SSH. Multiple layers of quoting (an outer SSH command containing a nested bash -c containing its own quoted arguments) is a reliable way to have a variable silently fail to expand where you expect. Write a real script file with normal, non-nested quoting and copy it over instead.
Gotcha: the ZAP container writes reports as a non-root user. If your output directory is root-owned at the default permissions, the scan itself completes fine but report generation fails silently with an access-denied error. chmod 777 the output directory before running (or bind-mount with a matching non-root UID).

Putting it on a schedule

One-off manual runs are a good start, but the real value is recurring scans that actually get looked at:

Feeding results into your monitoring stack

If your monitoring system supports "local checks" (a small script on the monitored host that prints a status line the monitoring agent picks up as a real service with thresholds — Checkmk works this way), this is a clean way to surface scan trends without building a whole separate dashboard: have each scanner write a small summary file, and a local check script read it and print WARN/CRIT based on a threshold you pick.

General lesson from running this in practice: an "interim fix" that gets superseded by a permanent one (e.g. a temporary swap file added under memory pressure, later made unnecessary by a RAM upgrade) still needs an explicit cleanup step, or it just becomes the next incident — nothing removes it automatically just because it's no longer needed.