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).
| 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.
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.
weekly_scan.py)/home/kali/scanner/weekly_scan.py (stdlib-only Python, no pip deps)/home/kali/scanner/results/ - dated XML per subnet, plus
latest_snapshot.json (the diff baseline) and dated snapshot copies./home/kali/.config/scanner/gmail_app_password.txt
(chmod 600). Sends from wbrice@gmail.com to wbrice@pm.me.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
]
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.-Pn (assume-alive) is NOT used - hosts here answer discovery fine, so -Pn
would just waste time scanning ~240 empty addresses per /24.sudo python3 /home/kali/scanner/weekly_scan.py
# watch progress: results land in ~/scanner/results/ as each subnet finishes
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.
https://127.0.0.1:9392, login admin)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).gvm_report_email.py)/home/kali/scanner/gvm_report_email.py (uses python-gvm)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
_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.)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."
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?”
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 on10.0.0.14. Chasing what held139, 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 informationIt doesn’t.
netstat -bprints 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 the139line. The139listener’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-bto name). Read app-upward and you mis-pair the label onto the wrong socket every time.
Get-NetTCPConnectioncan’t fool you this way — it putsLocalPortandOwningProcesson 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.2is 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) and5357(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, block139/445/5357the same way — then rescan to confirm.
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:
sudo systemctl enable postgresql@18-main gvmd ospd-openvas notus-scanner gsad
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 editgotcha 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=alwaysthere is already active and there is nothing to uncomment. Confirm the merge withsystemctl cat gvmd(youroverride.confprints 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 postgresqlsaysactiveeven with every cluster down. It is a meta-unit wrapping the realpostgresql@VER-CLUSTERinstances. Check the instances, orpg_lsclusters, and want to see18 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.
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
| 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 |
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).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).