Home Vuln Scanning Automation

Home Vulnerability Scanning Automation

Automated weekly security scanning of the home network, in two layers: an nmap sweep (port/service change detection) and a Greenbone/OpenVAS vulnerability scan (CVE-graded findings, Tenable-style). Both email a report. Built 2026-07-25/26. The design rule: let each tool do what it’s good at, keep the reporting decoupled from the scanning so a long scan can’t break the email.

Runs on the dedicated Kali VM (dual-homed: wired eth0 on the main LAN 10.0.0.29, USB WiFi wlan0 on the IoT/guest “VIRUS” net 192.168.2.39).

The two layers at a glance

Layer Tool What it answers Cadence Delivery
1 nmap “What ports/hosts changed since last week?” Fri 9 PM Email (diff report)
2 Greenbone/OpenVAS “What CVE-level vulnerabilities exist?” Sat 3 AM scan, Sat 10 AM email Email (Medium+ findings)

Scans cover both subnets - 10.0.0.0/24 (main) and 192.168.2.0/24 (VIRUS/IoT) - with the Kali box’s own IPs (10.0.0.29, 192.168.2.39) excluded from both.

Cron schedule (root’s crontab: sudo crontab -e)

# Weekly nmap scan for open ports and new services - Friday 9pm
0 21 * * 5 /usr/bin/python3 /home/kali/scanner/weekly_scan.py >> /home/kali/scanner/scan.log 2>&1
# Weekly vuln-scan report email - Saturdays 10am
0 10 * * 6 /usr/bin/python3 /home/kali/scanner/gvm_report_email.py >> /home/kali/scanner/vuln-report.log 2>&1

Both run as root (root crontab) so they have nmap privileges, GVM socket access, and can read the credential files. Staggered nights so the two scans never compete for the network. The Greenbone scan itself is scheduled inside Greenbone (Sat 3 AM, see below) - the 10 AM cron job only pulls and emails the finished report.


Layer 1 - nmap sweep + diff (weekly_scan.py)

  • Path: /home/kali/scanner/weekly_scan.py (stdlib-only Python, no pip deps)
  • What it does: scans both subnets, parses results, diffs against last week’s snapshot, and emails an HTML report that leads with what changed (new hosts, newly-opened ports, disappeared ports), followed by the full inventory. Known devices are labelled by name from a lookup table in the config.
  • Data/history: /home/kali/scanner/results/ - dated XML per subnet, plus latest_snapshot.json (the diff baseline) and dated snapshot copies.
  • Email: Gmail SMTP, app password in /home/kali/.config/scanner/gmail_app_password.txt (chmod 600). Sends from wbrice@gmail.com to wbrice@pm.me.

Per-subnet scan config (the key tuning)

NMAP_TIMING = 3
EXCLUDE_HOSTS = ["10.0.0.29", "192.168.2.39"]   # the scanner's own IPs
HOST_TIMEOUT = "20m"   # cap per-host so nothing hangs the run
MAX_RETRIES = "2"      # give up faster on silent ports
SUBNETS = [
    {"cidr": "10.0.0.0/24",    "all_ports": True},   # wired, full 65535-port scan
    {"cidr": "192.168.2.0/24", "all_ports": False},  # WiFi IoT, top-1000 only
]

Why these settings (hard-won during the build)

  • Self-exclude was the big fix. The scanner scanning its own IP (10.0.0.29) triggered pathological loopback behavior - the all-ports scan hammered localhost and ran for hours. Diagnosed via strace (every packet targeting 10.0.0.29). Excluding the scanner’s own IPs was the single fix that made the scan finish.
  • --host-timeout 20m + --max-retries 2 cap any single unresponsive host so one silent IoT device can’t hang the whole run waiting out 65k port timeouts.
  • Per-subnet ports: all-ports over WiFi against ~19 silent IoT devices took hours. Dropping VIRUS to top-1000 (plenty for IoT - they only expose a handful of ports) cut that to minutes. Wired main LAN keeps full all-ports coverage (cheap over wire).
  • -Pn (assume-alive) is NOT used - hosts here answer discovery fine, so -Pn would just waste time scanning ~240 empty addresses per /24.

Manual test / run

sudo python3 /home/kali/scanner/weekly_scan.py
# watch progress: results land in ~/scanner/results/ as each subnet finishes

Layer 2 - Greenbone/OpenVAS vuln scan

Two halves: Greenbone runs the scan on its own schedule (durable, daemon-managed, survives reboots), and a lightweight script pulls the finished report and emails it. This decoupling means a multi-hour scan can never break the reporting, and the pull script only ever reads FINISHED reports so it can’t send incomplete data.

Greenbone config (all in the web UI at https://127.0.0.1:9392, login admin)

  • Target “Home network - Both Subnets”: hosts 10.0.0.0/24, 192.168.2.0/24, exclude 10.0.0.29, 192.168.2.39, Port List “All IANA assigned TCP”, Alive Test “Use Scan Config Default”, unauthenticated (no SSH creds).
  • Task “Weekly Scan of home network”: the target above, Scan Config “Full and fast”, Scanner “OpenVAS Default”, auto-delete oldest reports keeping newest 5.
  • Schedule “Weekly Scan”: Saturday 3:00 AM, US/Arizona, weekly recurrence. (Staggered after Friday’s nmap night.)

The pull-and-email script (gvm_report_email.py)

  • Path: /home/kali/scanner/gvm_report_email.py (uses python-gvm)
  • What it does: connects to gvmd over its Unix socket via GMP, authenticates, finds the most recent Done report for the task, filters to Medium+ (CVSS ≥ 4.0), and emails an HTML table (severity, CVSS, host, port, vuln name) with summary counts. If no finished report exists yet, it logs that and sends nothing - never emails incomplete data.
  • Config:
    GVMD_SOCKET = "/run/gvmd/gvmd.sock"
    GMP_USERNAME = "admin"
    GMP_PASSWORD_FILE = "/home/kali/.config/scanner/gvm_admin_password.txt"  # chmod 600
    TASK_NAME = "Weekly Scan of home network"
    MIN_SEVERITY = 4.0   # Medium and above; filters off Low/Log noise
    
  • Socket access: the socket is owned _gvm:_gvm mode srw-rw----, so the runner must be root or in the _gvm group. Cron runs it as root, which works. (For manual runs as kali, the user was added with sudo usermod -aG _gvm kali - takes effect on next login.)

Manual test / run

sudo python3 /home/kali/scanner/gvm_report_email.py
# Before any scan has run, correct output is:
#   "No FINISHED report found for task ... Sending nothing."

Triaging findings (so a Medium isn’t a rabbit hole)

The scanner reports attack surface, not just holes — a lot of “findings” are this service is reachable and will tell strangers about itself, not this service is exploitable. The triage question is usually “should this be reachable from the network?”, not “is this process legitimate?”

DCE/RPC and MSRPC Services Enumeration Reporting (Medium) — the recurring one

What it is: TCP 135, the Windows RPC Endpoint Mapper (RpcEptMapper/RpcSs, hosted inside svchost.exe), is reachable and will enumerate its registered RPC services to any unauthenticated caller. It is information disclosure / reconnaissance surface, not an exploit. It shows up on essentially every Windows host.

The svchost that owns 135 is core Windows, not malware and not a “diagnostic.” RPC is foundational — DCOM, WMI, the service control manager, scheduled tasks, and printing all ride on it. Never disable the RpcSs service (it breaks logon and half the OS). Confirm the process is the genuine one, not a masquerade, by its Path:

tasklist /svc /fi "PID eq <pid>"                       # expect RpcEptMapper / RpcSs
Get-NetTCPConnection -LocalPort 135 | Select OwningProcess
Get-Process -Id <pid> | Format-List Path,Company,SessionId
#   legit: Path = C:\WINDOWS\system32\svchost.exe, Company = Microsoft, SessionId = 0
#   malware masquerading as "svchost" almost always runs from a WRONG path

Do NOT identify the owner with netstat -anob — it will lie to you on these ports. Worked example, 2026-08-22 on 10.0.0.14. Chasing what held 139, the output looked like MEGAsync owned it:

  TCP    10.0.0.14:65476        66.203.125.12:443      ESTABLISHED
 [MEGAsync.exe]
  TCP    10.5.0.2:139           0.0.0.0:0              LISTENING
 Can not obtain ownership information

It doesn’t. netstat -b prints the owning program on a separate line UNDER each connection, and it’s read top-down: [MEGAsync.exe] belongs to the 443 line above it (MEGAsync’s real outbound HTTPS sync) — not the 139 line. The 139 listener’s own label is literally “Can not obtain ownership information”, the fingerprint of a System/kernel-owned port (139/445 are owned by PID 4, which has no userland exe for -b to name). Read app-upward and you mis-pair the label onto the wrong socket every time.

Get-NetTCPConnection can’t fool you this way — it puts LocalPort and OwningProcess on the same row, no pairing to misjudge:

Get-NetTCPConnection -LocalPort 139 -State Listen | Select LocalAddress,LocalPort,OwningProcess
#   OwningProcess 4  = System (kernel SMB) — correct. Not MEGAsync.

And the decisive proof was observation, not any tool: 139 was blocked, and the Quicken file kept syncing (MEGA rides 443, not SMB) — so 139 was provably never MEGA’s. (10.5.0.2 is the NordLynx VPN tunnel IP; SMB just binds every interface, VPN included.)

The fix is exposure, not existence. If the box doesn’t need to answer RPC to other machines (standalone VM, not domain-joined, no file/print sharing or remote WMI), block it at the Windows Firewall — the finding clears and RPC keeps working locally:

New-NetFirewallRule -DisplayName "Block inbound RPC EPM 135" -Direction Inbound -Protocol TCP -LocalPort 135 -Action Block

If something does use RPC to it, accept the finding as a documented exception in GVM instead.

Config is not outcome: a firewall rule existing doesn’t prove the port is unreachable — the rescan showing the finding gone is the proof. Always re-scan to confirm, don’t trust the rule.

Done + confirmed 2026-08-22: Windows 10-purple (10.0.0.14) flagged this Medium (had been showing for weeks, ignored as a 5.0). svchost PID 628 verified as genuine RpcSs (real system32 path, Microsoft, session 0) — normal. Blocked inbound at the Windows Firewall, then verified two ways: GVM rescan dropped the finding, and nmap -Pn 10.0.0.14 showed 135/tcp gone (open at 12:36, absent at 12:45). That VM runs the paid internet-sharing apps, which don’t need inbound RPC, so it cost nothing.

Closing a flagged Medium ≠ locked down. The same nmap that confirmed 135 was gone still showed 139/445 (SMB) and 5357 (WSD/wsdapi) open — and SMB is bigger attack surface than the RPC mapper we just closed (EternalBlue/MS17-010 lineage). GVM rated only the 135 Medium — it didn’t flag SMB as a vuln (patched/signed, no known CVE match), so it never hit the email. This is the whole reason for the two-layer design: the nmap sweep (Layer 1) shows open ports; GVM (Layer 2) rates known vulns. Watch the nmap diff too, or you’ll close a 5.0 and leave a wider door open beside it. If a box doesn’t need to serve files/printers or be discovered, block 139/445/5357 the same way — then rescan to confirm.


Build gotchas (things that bit us, so future-me doesn’t re-learn them)

  • PostgreSQL version mismatch. gvm-setup failed: “The default PostgreSQL version (16) is not 18 that is required by libgvmd.” Greenbone needs PG18 on port 5432. Fix (only safe because the clusters held nothing else):

    pg_lsclusters                          # see what's where
    sudo pg_dropcluster --stop 16 main     # drop the empty PG16 cluster on 5432
    sudo pg_createcluster 18 main --start  # create PG18 on the now-free 5432
    sudo gvm-setup                         # re-run; now clears the PG check
    
  • The feed sync has two phases, both slow. First it downloads the NVTs (~95k), then gvmd processes them into the DB and builds the default scan configs. The web UI shows “Feed is currently syncing… Scans are not available” and the Task Wizard errors with “default Scan Config is not available” until the processing (especially GVMD_DATA + the SCAP/CVE year-by-year ingest) finishes. Watch with sudo tail -f /var/log/gvm/gvmd.log. Just wait - first run can be 1-3+ hours.

  • sudo gvmd --get-scanners returns nothing / role "root" does not exist. That’s because running gvmd directly as root uses the wrong DB role (_gvm, not root). Harmless - it only affects that manual command, not the gvmd service.

  • The socket only exists while gvmd is running. After a reboot with GVM not auto-started, the pull script fails with “Socket /run/gvmd/gvmd.sock does not exist.” Surviving a reboot hands-off takes TWO things — enabling alone is not enough, learned the hard way on both 2026-08-14 and again after the 2026-08-21 kernel update:

    1. Enable every unit so they start at boot:
      sudo systemctl enable postgresql@18-main gvmd ospd-openvas notus-scanner gsad
      
    2. Fix the boot ORDER — this is the part that was missing. The shipped gvmd.service orders against the postgresql.service meta-unit (After=…postgresql.service), which completes early and does not mean the real cluster is accepting connections. So on boot gvmd starts, can’t reach the DB, dies — and even though the unit has Restart=always, it burns through systemd’s default start-limit before postgres is ready and then stays dead. That is the empty Greenbone page after a reboot. Make gvmd wait for the actual instance:
      sudo systemctl edit gvmd
      
      # top (editable) block only:
      [Unit]
      After=postgresql@18-main.service
      Wants=postgresql@18-main.service
      
      sudo systemctl daemon-reload
      

      systemctl edit gotcha that cost real confusion 2026-08-21: you type your lines ONLY in the empty top block. The commented unit printed below “Edits below this comment will be discarded” is a read-only mirror of the current unit, not the live config — so a line like # Restart=always there is already active and there is nothing to uncomment. Confirm the merge with systemctl cat gvmd (your override.conf prints on top of the vendor unit) — not by editing the mirror.

    Verify by OUTCOME, never by is-enabled (that only proves the symlink exists — config, not outcome). Reboot, then:

    systemctl is-active postgresql@18-main gvmd ospd-openvas   # all three "active"
    sudo gvmd --get-scanners                                   # lists scanners = genuinely working
    

    Confirmed self-starting on a cold boot 2026-08-21 with the drop-in in place: all three active, admin page loaded on its own, no gvm-start, no manual enable.

    If ospd-openvas ever fails to come up while gvmd is fine, it likely lost the same kind of race against redis — give it the same drop-in treatment (sudo systemctl edit ospd-openvas, After=redis-server.service / Wants=redis-server.service).

  • postgresql@18-main was missing from that list until 2026-08-14, and it broke a scan night. All four GVM services started correctly at boot — against a database that was not running. gvmd then failed with:

    PQconnectStart to 'gvmd' failed: connection to server on socket
    "/var/run/postgresql/.s.PGSQL.5432" failed: No such file or directory
    

    systemd reported only “Job for gvmd.service failed because a timeout was exceeded” and “Can’t open PID file”, neither of which names the cause. The real error is always in /var/log/gvm/gvmd.log, not the journal.

    pg_createcluster 18 main --start starts a cluster but does not enable it, so the PG16→PG18 fix above left it running-but-not-persistent. It survived until the first boot where nothing started postgres by hand.

    systemctl is-active postgresql says active even with every cluster down. It is a meta-unit wrapping the real postgresql@VER-CLUSTER instances. Check the instances, or pg_lsclusters, and want to see 18 main 5432 online:

    pg_lsclusters                              # Status must read "online"
    sudo pg_ctlcluster 18 main start           # if it is down
    
  • A PG17 cluster now exists on 5433 (arrived via a Kali update, first seen 2026-08-14). It is down and out of the way — Greenbone still uses 18 on 5432 — but do not assume 18 is the only cluster present when reading pg_lsclusters.

Health checks

sudo gvm-check-setup     # full stack health; want "installation is OK"
sudo gvm-start           # bring GVM up (if not auto-started)
ls -la /run/gvmd/gvmd.sock   # socket present == gvmd running
pg_lsclusters            # PG18 main should be on port 5432, online
ip route | grep 192.168.2    # confirm WiFi NIC route to VIRUS survived reboot

Files & locations

What Where
nmap scan script /home/kali/scanner/weekly_scan.py
nmap results + snapshots /home/kali/scanner/results/
nmap run log /home/kali/scanner/scan.log
Greenbone pull-email script /home/kali/scanner/gvm_report_email.py
Greenbone pull log /home/kali/scanner/vuln-report.log
Gmail app password (chmod 600) /home/kali/.config/scanner/gmail_app_password.txt
GVM admin password (chmod 600) /home/kali/.config/scanner/gvm_admin_password.txt
GVM socket /run/gvmd/gvmd.sock
Greenbone web UI https://127.0.0.1:9392

Known limitations / to-do

  • Unauthenticated scans only. Greenbone scans from the outside (network-facing vulns). Adding SSH credentials per host would enable authenticated scans (installed package versions, patch levels, local config) - much deeper. Worth doing for the important boxes (Plex/NAS) as a future enhancement.
  • No week-over-week delta on the vuln side yet. The nmap report diffs against last week; the Greenbone email currently shows the full Medium+ list each time, not “what’s new since last scan.” Could add later.
  • Dual-NIC = the Kali box bridges both networks. ip_forward stays off so it scans both but doesn’t route between them. Still, this box now touches both trust tiers, so its own hardening matters (FIDO2, updates, minimal exposed surface).

First real full-network run

Scan: Sat Aug 1, 2026, 3 AM. Report email: Sat Aug 1, 10 AM. The single-host Pi-hole test already validated the whole pull/parse/filter/email pipeline against real gvmd 26.24 data, so the first full run should just work - expect Medium+ items to surface across the ~30 hosts (a single host rarely shows any; a whole network usually does).