← Files OGENIC GOD TOOLKITARCHIVED FILE
scripts/netwalk_audit.py
42.8 KB · Oct 3, 2026 · 06:33 UTC
#!/usr/bin/env python3
"""Hardening audit for netwalk: vendor best practice, checked in code.
Before this existed, "is anything configured badly?" depended on whether the agent
remembered a paragraph of examples while it was busy reading interface counters. That
is not a check, it is a hope. This module is the checklist as data - every item has an
id, a severity, the evidence that decides it, a fix a technician can apply, and a
reference to where the rule comes from.
Two things make it safe to run against a real customer:
* **It reads the config export off the disk, not through the conversation.** The
exports already live in `<site>/configs/` because `netwalk_exec.py --out` put them
there. This script opens them locally and emits findings; the config text itself
never enters an agent's context. Excerpts attached to a finding are one line long
and pass through the same redactor the exec wrapper uses.
* **It never fails silently.** A check whose evidence is missing is reported as NOT
CHECKED, by name. A hardening report that quietly skipped half its checks is worse
than no report, because it reads like a clean bill of health.
Where the rules come from
-------------------------
The vendor's own hardening guidance, plus what actually goes wrong on sites. Each check
names the guidance it comes from, so a customer asking "says who?" gets an answer that
points at the people who built the box rather than at this file.
netwalk_audit.py guide [--vendor mikrotik] [--format md] # the checklist itself
netwalk_audit.py run --site S --record scan.json [--dry-run]
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import netwalk_common as C # noqa: E402
from netwalk_exec import redact_device_secrets # noqa: E402
VENDOR_GUIDE = {
"mikrotik": "MikroTik — Securing your router (wiki.mikrotik.com), and RouterOS defaults",
"cisco": "Cisco Guide to Harden IOS Devices",
"aruba": "ArubaOS-CX Hardening Guide",
"hp": "HPE ProCurve / Comware security configuration guide",
"fortinet": "Fortinet FortiOS Hardening Guide",
"linux": "Vendor distribution hardening guide (Debian/RHEL security)",
"windows": "Microsoft Windows Server security baseline",
"*": "Common network hardening practice",
}
# ---------------------------------------------------------------------------
# The catalogue. This IS the hardening guide - the printed guide is generated
# from it, so the document and the checks cannot drift apart.
#
# kind = config_present : the pattern matching is the problem
# kind = config_absent : the pattern NOT matching is the problem
# kind = manual : cannot be decided from a config dump; a human looks
# cmd : scope the search to one command's output block
# ---------------------------------------------------------------------------
CHECKS: list[dict] = [
# ------------------------------------------------------------- MikroTik
{
"id": "mt-telnet-enabled", "vendors": ["mikrotik"], "severity": "high",
"title": "Telnet is enabled on the router",
"why": "Telnet carries the admin username and password in clear text. Anyone on a "
"path between the engineer and the router reads them, and on a flat LAN that "
"is every host on the network.",
"fix": "Disable it: /ip service disable telnet. Use SSH, and restrict it with "
"address= to the management network.",
"kind": "config_present", "cmd": "/ip service print",
"pattern": r'(?m)^\s*\d+\s+(?!X[\s])(?:name=")?telnet\b',
},
{
"id": "mt-ftp-enabled", "vendors": ["mikrotik"], "severity": "medium",
"title": "The FTP service is enabled",
"why": "RouterOS FTP is a clear-text file service on the router itself. It is rarely "
"used deliberately and it accepts the same credentials as the admin account.",
"fix": "/ip service disable ftp",
"kind": "config_present", "cmd": "/ip service print",
"pattern": r'(?m)^\s*\d+\s+(?!X[\s])(?:name=")?ftp\b',
},
{
"id": "mt-www-plain", "vendors": ["mikrotik"], "severity": "medium",
"title": "The web interface answers over plain HTTP",
"why": "The RouterOS web login posts the password over an unencrypted connection. "
"www-ssl exists and costs nothing but a certificate.",
"fix": "/ip service disable www and use www-ssl, restricted with address=.",
"kind": "config_present", "cmd": "/ip service print",
"pattern": r'(?m)^\s*\d+\s+(?!X[\s])(?:name=")?www\b(?!-ssl)',
},
{
"id": "mt-api-plain", "vendors": ["mikrotik"], "severity": "medium",
"title": "The clear-text API service (8728) is enabled",
"why": "The RouterOS API on 8728 authenticates in clear text. If something automates "
"this router it should be doing it over api-ssl (8729).",
"fix": "/ip service disable api, and use api-ssl if an integration needs it.",
"kind": "config_present", "cmd": "/ip service print",
"pattern": r'(?m)^\s*\d+\s+(?!X[\s])(?:name=")?api\b(?!-ssl)',
},
{
"id": "mt-input-no-drop", "vendors": ["mikrotik"], "severity": "critical",
"title": "The firewall input chain has no drop rule at all",
"why": "RouterOS ends a chain with an implicit ACCEPT. With no catch-all drop in the "
"input chain, every management service the router runs answers on every "
"address it holds, including its public ones. This has been found in the "
"field: Winbox, the web interface, SNMP and IPsec all answering from the "
"internet on a router whose owner believed it was firewalled.",
"fix": "End the input chain with a catch-all: /ip firewall filter add chain=input "
"action=drop, after the rules that accept established/related traffic and "
"management from the trusted network. Add it last, and test from a console "
"session you cannot lock yourself out of.",
"kind": "config_absent", "cmd": "/ip firewall filter print",
"pattern": r"chain=input[^\n]*action=(drop|reject)",
},
{
"id": "mt-snmp-v1v2", "vendors": ["mikrotik"], "severity": "high",
"title": "SNMP v1/v2c is in use",
"why": "v1 and v2c authenticate with a community string sent in clear text, and it is "
"usually 'public'. Anyone who can reach the port can read the whole device "
"inventory, interface list and traffic counters.",
"fix": "Move to SNMPv3 with authPriv, or restrict the community to the monitoring "
"host's address and treat the string as a password.",
"kind": "config_present", "cmd": "/snmp community print",
"pattern": r"(?im)^\s*\d+\s+\S*\s*(public|private)\b|authentication-protocol=\s*$",
},
{
"id": "mt-romon-enabled", "vendors": ["mikrotik"], "severity": "medium",
"title": "RoMON is enabled",
"why": "RoMON gives layer-2 management access to every RoMON-speaking MikroTik in the "
"broadcast domain, bypassing IP firewalling entirely. Convenient for the "
"engineer, equally convenient for anyone who plugs into a switch port.",
"fix": "Disable it when it is not actively in use: /tool romon set enabled=no. If it "
"is needed, set a RoMON secret and restrict the ports it runs on.",
"kind": "config_present", "cmd": "/tool romon print",
"pattern": r"(?im)^\s*enabled:\s*yes",
},
{
"id": "mt-mac-server-all", "vendors": ["mikrotik"], "severity": "medium",
"title": "MAC-Telnet / MAC-Winbox is available on all interfaces",
"why": "MAC server access needs no IP address and ignores IP firewall rules. Left on "
"'all', an unauthenticated layer-2 neighbour - including one on the WAN side "
"of a bridged link - can reach the login prompt.",
"fix": "Restrict to a management interface list: /tool mac-server set "
"allowed-interface-list=MGMT, same for mac-winbox.",
"kind": "config_present", "cmd": "/tool mac-server print",
"pattern": r"(?im)allowed-interface-list:\s*(all|\*all)",
},
{
"id": "mt-discovery-all", "vendors": ["mikrotik"], "severity": "low",
"title": "Neighbour discovery runs on every interface, including the WAN",
"why": "MNDP/CDP/LLDP announcements on an internet-facing interface tell anyone "
"listening the model, the RouterOS version and the identity of the device.",
"fix": "/ip neighbor discovery-settings set discover-interface-list=LAN",
"kind": "config_present", "cmd": "/ip neighbor discovery-settings print",
"pattern": r"(?im)discover-interface-list:\s*(all|\*all)",
},
{
"id": "mt-bandwidth-server", "vendors": ["mikrotik"], "severity": "low",
"title": "The bandwidth-test server is enabled",
"why": "It is a service anyone with credentials can point at the router to saturate "
"its CPU. It is on by default and almost never used in production.",
"fix": "/tool bandwidth-server set enabled=no",
"kind": "config_present", "cmd": "/tool bandwidth-server print",
"pattern": r"(?im)^\s*enabled:\s*yes",
},
{
"id": "mt-socks-upnp", "vendors": ["mikrotik"], "severity": "medium",
"title": "SOCKS proxy or UPnP is enabled",
"why": "An enabled SOCKS proxy turns the router into an open relay if it is not "
"access-listed. UPnP lets any host on the LAN open a hole through the NAT "
"without asking anybody.",
"fix": "/ip socks set enabled=no and /ip upnp set enabled=no unless something "
"genuinely needs them, in which case restrict them.",
"kind": "config_present", "cmd": "/ip socks print",
"pattern": r"(?im)^\s*enabled:\s*yes",
},
{
"id": "mt-dns-remote", "vendors": ["mikrotik"], "severity": "high",
"confidence": "suspected",
"title": "The router answers DNS queries from anywhere",
"why": "allow-remote-requests=yes with no input firewall makes the router an open "
"resolver: usable for DNS amplification attacks against third parties, which "
"is how a small site ends up on a blocklist. This setting alone does not "
"prove exposure: it says the resolver will answer, not that anything can "
"reach it. CHECK /ip firewall raw AND filter for a drop of port 53 from the "
"WAN before reporting this, and downgrade or drop the finding if one exists - "
"at a real site the raw table already dropped UDP/53 and only TCP/53 was "
"reachable, which is a different, smaller finding.",
"fix": "Keep allow-remote-requests=yes only if the LAN uses the router for DNS, and "
"make sure the input chain drops UDP/TCP 53 from the WAN.",
"kind": "config_present", "cmd": "/ip dns print",
"pattern": r"(?im)allow-remote-requests:\s*yes",
},
{
"id": "mt-no-ntp", "vendors": ["mikrotik"], "severity": "low", "category": "config-hygiene",
"title": "No NTP client is configured",
"why": "Without synchronised time every log line and every certificate check is "
"unreliable, and correlating an incident across two devices becomes guesswork.",
"fix": "/system ntp client set enabled=yes servers=<two reachable servers>",
"kind": "config_absent", "cmd": "/system ntp client print",
"pattern": r"(?im)^\s*enabled:\s*yes",
},
{
"id": "mt-no-remote-logging", "vendors": ["mikrotik"], "severity": "medium",
"category": "config-hygiene",
"title": "Logs are not sent anywhere off the device",
"why": "RouterOS keeps its log in RAM by default. A reboot - or anyone who caused one "
"- erases the only record of what happened.",
"fix": "/system logging action add name=remote target=remote remote=<syslog host>, "
"then point the topics that matter at it.",
"kind": "config_absent", "cmd": "/system logging action print",
"pattern": r"(?im)\bremote\b",
},
{
"id": "mt-default-admin", "vendors": ["mikrotik"], "severity": "high",
"title": "The default 'admin' account still exists",
"why": "Every brute-force script starts with admin. A named account per engineer also "
"means the log says who did something, not just that someone did.",
"fix": "Create a named full-access user, verify you can log in with it, then remove or "
"disable admin.",
"kind": "config_present", "cmd": "/user print",
"pattern": r'(?m)^\s*\d+\s+(?:X\s+)?(?:name=")?admin\b',
},
# ---------------------------------------------------------------- Cisco
{
"id": "cis-no-password-encryption", "vendors": ["cisco"], "severity": "medium",
"title": "service password-encryption is not enabled",
"why": "Without it, passwords in the running-config are stored and displayed in clear "
"text - and a config gets copied into tickets, backups and emails.",
"fix": "conf t; service password-encryption. Note this is obfuscation (type 7), not "
"encryption; it stops shoulder-surfing, not an attacker with the file.",
"kind": "config_absent", "cmd": "show running-config",
"pattern": r"(?m)^service password-encryption",
},
{
"id": "cis-enable-password", "vendors": ["cisco"], "severity": "high",
"title": "'enable password' is used instead of 'enable secret'",
"why": "enable password is stored reversibly. enable secret is hashed. Any config that "
"leaks hands over privileged access with the first one.",
"fix": "conf t; enable secret <strong value>; no enable password",
"kind": "config_present", "cmd": "show running-config",
"pattern": r"(?m)^enable password\b",
},
{
"id": "cis-vty-telnet", "vendors": ["cisco"], "severity": "high",
"title": "The VTY lines accept telnet",
"why": "transport input telnet or all means the device accepts clear-text admin "
"sessions, no matter how good the password is.",
"fix": "conf t; line vty 0 15; transport input ssh",
"kind": "config_present", "cmd": "show running-config",
"pattern": r"(?m)^\s*transport input (telnet|all)\b",
},
{
"id": "cis-http-server", "vendors": ["cisco"], "severity": "medium",
"title": "The HTTP management server is enabled",
"why": "The IOS HTTP server has a long CVE history and authenticates in clear text. "
"Very few sites use it deliberately.",
"fix": "conf t; no ip http server. Keep ip http secure-server only if something needs it.",
"kind": "config_present", "cmd": "show running-config",
"pattern": r"(?m)^ip http server\b",
},
{
"id": "cis-snmp-default-community", "vendors": ["cisco"], "severity": "high",
"title": "A default or guessable SNMP community is configured",
"why": "'public' and 'private' are the first two strings any scanner tries. RW private "
"is not a read risk, it is a device takeover.",
"fix": "Remove them, move to SNMPv3, and access-list whatever remains to the "
"monitoring host.",
"kind": "config_present", "cmd": "show running-config",
"pattern": r"(?mi)^snmp-server community\s+(public|private)\b",
},
{
"id": "cis-no-aaa", "vendors": ["cisco"], "severity": "medium",
"title": "AAA is not enabled",
"why": "Without aaa new-model the device falls back to line passwords, which are "
"shared, unattributable and survive staff changes.",
"fix": "conf t; aaa new-model, with local fallback configured before you commit it.",
"kind": "config_absent", "cmd": "show running-config",
"pattern": r"(?m)^aaa new-model",
},
{
"id": "cis-no-logging-host", "vendors": ["cisco"], "severity": "medium",
"category": "config-hygiene",
"title": "No syslog destination is configured",
"why": "The local buffer is small and disappears on reload. Nothing to correlate after "
"an incident.",
"fix": "conf t; logging host <syslog>; service timestamps log datetime msec localtime",
"kind": "config_absent", "cmd": "show running-config",
"pattern": r"(?m)^logging (host )?\d+\.\d+\.\d+\.\d+",
},
{
"id": "cis-no-bpduguard", "vendors": ["cisco"], "severity": "medium",
"category": "availability",
"title": "BPDU guard is not enabled by default on access ports",
"why": "One person plugging a cheap switch or a looped patch lead into a wall port can "
"take the spanning tree - and the site - down. BPDU guard turns that into one "
"dead port instead.",
"fix": "conf t; spanning-tree portfast bpduguard default (and portfast on access ports)",
"kind": "config_absent", "cmd": "show running-config",
"pattern": r"(?m)^spanning-tree portfast (edge )?bpduguard default",
},
{
"id": "cis-no-dhcp-snooping", "vendors": ["cisco"], "severity": "low",
"title": "DHCP snooping is not configured",
"why": "Without it any host can answer DHCP and become the default gateway for the "
"VLAN. It is also the table Dynamic ARP Inspection depends on.",
"fix": "conf t; ip dhcp snooping; ip dhcp snooping vlan <list>; trust the uplinks.",
"kind": "config_absent", "cmd": "show running-config",
"pattern": r"(?m)^ip dhcp snooping",
},
{
"id": "cis-vty-no-timeout", "vendors": ["cisco"], "severity": "low",
"title": "A VTY line has its idle timeout disabled",
"why": "exec-timeout 0 0 leaves an authenticated session open forever - including the "
"one on the laptop somebody left in a meeting room.",
"fix": "conf t; line vty 0 15; exec-timeout 10 0",
"kind": "config_present", "cmd": "show running-config",
"pattern": r"(?m)^\s*exec-timeout 0 0\b",
},
# ---------------------------------------------------------------- Linux
{
"id": "lnx-ssh-root-login", "vendors": ["linux"], "severity": "high",
"title": "sshd permits direct root login",
"why": "root is the one account name an attacker never has to guess, and a root "
"session leaves no record of which person was at the keyboard.",
"fix": "PermitRootLogin no in /etc/ssh/sshd_config, after confirming an admin account "
"with sudo works.",
"kind": "config_present", "cmd": "cat /etc/ssh/sshd_config",
"pattern": r"(?mi)^\s*PermitRootLogin\s+(yes|without-password|prohibit-password)",
},
{
"id": "lnx-ssh-password-auth", "vendors": ["linux"], "severity": "medium",
"title": "sshd accepts password authentication",
"why": "Password auth is what makes a box worth brute-forcing. Key auth removes the "
"entire category.",
"fix": "PasswordAuthentication no, once every account that needs access has a key "
"installed and tested.",
"kind": "config_present", "cmd": "cat /etc/ssh/sshd_config",
"pattern": r"(?mi)^\s*PasswordAuthentication\s+yes",
},
{
"id": "lnx-empty-firewall", "vendors": ["linux"], "severity": "medium",
"title": "The host firewall has no rules",
"why": "Every listening socket on the box is reachable from every host that can route "
"to it. On a flat LAN that is everything, including the guest Wi-Fi.",
"fix": "Apply a default-deny inbound policy with explicit allows, via nftables, ufw or "
"firewalld - whichever the distribution already uses.",
"kind": "config_absent", "cmd": "iptables -S",
"pattern": r"(?m)^-A\s+INPUT",
},
{
"id": "lnx-clear-text-services", "vendors": ["linux"], "severity": "high",
"title": "A clear-text service is listening",
"why": "telnet, rsh, ftp and tftp hand credentials and data to anyone on the path. "
"Their encrypted replacements have existed for thirty years.",
"fix": "Disable the unit and remove the package. Use ssh/sftp instead.",
"kind": "config_present", "cmd": "ss -tulpn",
"pattern": r"(?m):(23|21|512|513|514|69)\s",
},
{
"id": "lnx-nopasswd-sudo", "vendors": ["linux"], "severity": "medium",
"title": "sudo is configured with NOPASSWD",
"why": "Any process that lands as that user is already root. It converts a low-value "
"foothold into a full compromise with no extra step.",
"fix": "Remove NOPASSWD, or narrow it to the single command that genuinely needs it.",
"kind": "config_present", "cmd": "cat /etc/sudoers",
"pattern": r"(?m)NOPASSWD",
},
# ------------------------------------------------------------- Fortinet
{
"id": "fg-admin-http", "vendors": ["fortinet"], "severity": "high",
"title": "HTTP or telnet administrative access is allowed on an interface",
"why": "allowaccess including http or telnet means the management interface "
"authenticates in clear text on that interface.",
"fix": "config system interface; set allowaccess ping https ssh - and drop http and "
"telnet everywhere, especially on the WAN interface.",
"kind": "config_present", "cmd": "show system interface",
"pattern": r"(?m)set allowaccess[^\n]*\b(http|telnet)\b",
},
{
"id": "fg-no-password-policy", "vendors": ["fortinet"], "severity": "low",
"title": "No administrator password policy is set",
"why": "Without one, an admin account can keep a four-character password indefinitely.",
"fix": "config system password-policy; set status enable with a minimum length and "
"an expiry that people will actually follow.",
"kind": "config_absent", "cmd": "show system password-policy",
"pattern": r"(?m)set status enable",
},
# ------------------------------------------------------------ Aruba / HP
{
"id": "arb-telnet-enabled", "vendors": ["aruba", "hp"], "severity": "high",
"title": "The telnet server is enabled on the switch",
"why": "Clear-text admin access to a switch that sees every VLAN on the site.",
"fix": "no telnet-server (ArubaOS-CX) / no telnet-server (ProCurve), and confirm SSH "
"works before you disable it.",
"kind": "config_present", "cmd": "show running-config",
"pattern": r"(?m)^\s*telnet-server\b(?!\s+vrf\s+none)",
},
{
"id": "arb-snmp-default-community", "vendors": ["aruba", "hp"], "severity": "high",
"title": "A default SNMP community is configured on the switch",
"why": "public/private on a switch exposes the full port, VLAN and MAC picture of the "
"site to anyone who can reach the management address.",
"fix": "Remove the default community, move to SNMPv3, restrict by source address.",
"kind": "config_present", "cmd": "show running-config",
"pattern": r"(?mi)^\s*snmp-server (community|vrf \S+ community)\s+\"?(public|private)\b",
},
# ------------------------------------------------------ manual, everywhere
{
"id": "man-firmware-eol", "vendors": ["*"], "severity": "medium",
"category": "lifecycle", "kind": "manual",
"title": "Firmware version against vendor support and known CVEs",
"why": "netwalk records the version but carries no CVE or end-of-support database, and "
"guessing at one would be worse than saying nothing. This has to be looked up.",
"fix": "Check each recorded os_version against the vendor's security advisories and "
"end-of-support dates, and raise a finding per device that is behind.",
},
{
"id": "man-physical-access", "vendors": ["*"], "severity": "medium",
"category": "security", "kind": "manual",
"title": "Physical access to network equipment",
"why": "A switch in an unlocked cupboard or under a reception desk defeats every "
"control in this list. No command reveals it.",
"fix": "Walk the site: are the racks locked, are unused wall ports patched through, is "
"the router reachable by a visitor?",
},
{
"id": "man-backup-restore", "vendors": ["*"], "severity": "medium",
"category": "availability", "kind": "manual",
"title": "Configuration backups exist and have been restored at least once",
"why": "An export sitting on the device is not a backup, and a backup nobody has ever "
"restored is a hypothesis.",
"fix": "Confirm where configs are backed up, how often, and when a restore was last "
"actually tested.",
},
{
"id": "man-account-review", "vendors": ["*"], "severity": "medium", "kind": "manual",
"title": "Who has an account, and should they still",
"why": "netwalk can list accounts but cannot know that the contractor from two years "
"ago has left. Shared accounts also look perfectly healthy in a config.",
"fix": "Go through the account list with the owner: name each one, remove the ones "
"nobody claims, and replace shared logins with named ones.",
},
]
# ---------------------------------------------------------------------------
# Record checks: decided from structured fields, not from text. These carry
# confidence "confirmed" because they read a value, not a pattern in a dump.
# ---------------------------------------------------------------------------
WEAK_WIFI = {"open", "wep", "wpa-personal"}
RISKY_PORTS = {
23: ("telnet", "high"), 21: ("ftp", "medium"), 445: ("smb", "high"),
3389: ("rdp", "high"), 5900: ("vnc", "high"), 6379: ("redis", "high"),
27017: ("mongodb", "high"), 1433: ("mssql", "medium"), 3306: ("mysql", "medium"),
5432: ("postgresql", "medium"), 9200: ("elasticsearch", "high"),
10000: ("webmin", "high"), 8291: ("winbox", "medium"), 8728: ("mikrotik-api", "medium"),
7547: ("tr-069", "high"), 873: ("rsync", "medium"), 111: ("rpcbind", "medium"),
}
def _sortable_ip(a: str):
import ipaddress as _ip # noqa: PLC0415
try:
x = _ip.ip_address(a)
return (0, x.version, x.packed)
except ValueError:
return (1, 0, a.encode())
def rc_mgmt_on_wan(rec: dict) -> list[dict]:
out = []
for d in rec.get("devices") or []:
wan = (d.get("mgmt_exposure") or {}).get("reachable_from_wan") or []
if wan:
out.append(_finding(
f"rec-mgmt-on-wan@{d.get('host_id')}", "critical", "security", d.get("host_id"),
"Management services answer on a public address",
f"{', '.join(wan)} answered on a WAN-facing address of this device. Anything "
f"reachable from the internet is under continuous automated attack; a "
f"management service there is a matter of when, not whether.",
[{"source": "mgmt_exposure.reachable_from_wan", "excerpt": ", ".join(wan)}],
"Restrict management to the inside interfaces and the management network, and "
"put a catch-all drop at the end of the input chain. If remote access is "
"genuinely needed, put it behind a VPN rather than on a public port.",
"confirmed", public_safe=False,
refs={"vendor": VENDOR_GUIDE.get(d.get("vendor"), VENDOR_GUIDE["*"])}))
return out
def rc_weak_wifi(rec: dict) -> list[dict]:
out = []
for d in rec.get("devices") or []:
for w in d.get("wireless_networks") or []:
sec = (w.get("security") or "unknown").lower()
if sec in WEAK_WIFI:
sev = "critical" if sec in ("open", "wep") else "medium"
out.append(_finding(
f"rec-weak-wifi@{d.get('host_id')}:{w.get('ssid')}", sev, "security",
d.get("host_id"),
f"SSID '{w.get('ssid')}' uses {sec} security",
{"open": "An open SSID puts every client's traffic in the air unencrypted "
"and gives anyone in range a place on the network.",
"wep": "WEP is broken. Recovering the key takes minutes with tooling that "
"has been public since 2007.",
"wpa-personal": "WPA1/TKIP is deprecated and should not still be in "
"service; it also caps the whole SSID at legacy rates."
}[sec],
[{"source": "wireless_networks[].security", "excerpt": f"{w.get('ssid')}: {sec}"}],
"Move the SSID to WPA2-Enterprise where there is a directory to "
"authenticate against, or WPA2/3-Personal with a long passphrase where "
"there is not. If the open SSID is a deliberate guest network, put it on "
"its own VLAN with client isolation and a rate limit.",
"confirmed", public_safe=False,
refs={"vendor": "Wi-Fi Alliance / vendor WLAN hardening guidance"}))
if w.get("guest") and w.get("client_isolation") is False:
out.append(_finding(
f"rec-guest-no-isolation@{d.get('host_id')}:{w.get('ssid')}", "medium",
"security", d.get("host_id"),
f"Guest SSID '{w.get('ssid')}' has no client isolation",
"Guests can reach each other directly. One compromised laptop in the "
"waiting room can attack every other guest device on the same SSID.",
[{"source": "wireless_networks[].client_isolation", "excerpt": "false"}],
"Enable client/station isolation on the guest SSID and confirm the guest "
"VLAN cannot route to the internal networks.",
"confirmed", public_safe=False,
refs={"vendor": "Vendor WLAN hardening guidance"}))
return out
def rc_risky_open_ports(rec: dict) -> list[dict]:
"""Findings straight out of the authorised sweep - what is listening, and why it matters.
A host normally appears in more than one sweep: the host sweep probes a handful of
ports to decide it is alive, and a port scan afterwards looks properly. Emitting one
finding per sweep put the same address in a customer report twice, at two severities,
each listing a different subset of the same problem. Ports are merged per address, and
the ranges that produced them are named in the evidence.
"""
merged: dict[str, dict] = {}
for s in rec.get("sweeps") or []:
for h in s.get("hosts") or []:
e = merged.setdefault(h["ip"], {"ports": set(), "ranges": []})
e["ports"].update(h.get("open_ports") or [])
if s.get("range") and s["range"] not in e["ranges"]:
e["ranges"].append(s["range"])
out = []
for ip in sorted(merged, key=lambda a: _sortable_ip(a)):
e = merged[ip]
risky = [(p, RISKY_PORTS[p]) for p in sorted(e["ports"]) if p in RISKY_PORTS]
if not risky:
continue
worst = "high" if any(sev == "high" for _, (_, sev) in risky) else "medium"
listed = ", ".join(f"{p}/tcp {name}" for p, (name, _) in risky)
s = {"range": ", ".join(e["ranges"])}
h = {"ip": ip}
if True:
out.append(_finding(
f"rec-risky-open-port@{ip}", worst, "security", None,
f"{h['ip']} is listening on {len(risky)} service(s) that should not be open "
f"on a general network",
f"The authorised sweep of {s.get('range')} found {listed} answering on "
f"{h['ip']}. Clear-text admin protocols and databases on a user-accessible "
f"VLAN are the shortest path from a compromised laptop to everything else.",
[{"source": f"sweep {s.get('range')}", "excerpt": f"{h['ip']}: {listed}"}],
"Confirm what this host is, then either restrict the service to a management "
"network, put it behind authentication it does not currently have, or turn it "
"off. Databases in particular have no business listening beyond their "
"application server.",
"confirmed", public_safe=False,
refs={"vendor": VENDOR_GUIDE["*"]}))
return out
def rc_unidentified_hosts(rec: dict) -> list[dict]:
unknown = []
for s in rec.get("sweeps") or []:
unknown += [h["ip"] for h in (s.get("hosts") or []) if not h.get("in_record")]
if not unknown:
return []
return [_finding(
"rec-unidentified-host", "medium", "documentation", None,
f"{len(unknown)} address(es) on the network could not be identified",
"These answered a sweep of an authorised range but are not in the device inventory "
"and the site owner has not named them. An unidentified device is not necessarily a "
"problem, but nobody being able to say what it is, is.",
[{"source": "sweeps[].hosts", "excerpt": ", ".join(sorted(set(unknown))[:12])}],
"Trace each address to a physical port and a person. Until that is done they are on "
"the network with nobody accountable for them.",
"confirmed", public_safe=False,
refs={"vendor": VENDOR_GUIDE["*"]})]
RECORD_CHECKS = [rc_mgmt_on_wan, rc_weak_wifi, rc_risky_open_ports, rc_unidentified_hosts]
# ---------------------------------------------------------------------------
# Machinery
# ---------------------------------------------------------------------------
BLOCK_RE = re.compile(r"^### (.+)$", re.MULTILINE)
def now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def die(msg: str, code: int = 1):
print(f"netwalk_audit: {msg}", file=sys.stderr)
raise SystemExit(code)
def split_blocks(text: str) -> dict[str, str] | None:
"""`### cmd` sections written by `netwalk_exec.py run --out`. None if unmarked."""
marks = list(BLOCK_RE.finditer(text))
if not marks:
return None
out = {}
for i, m in enumerate(marks):
end = marks[i + 1].start() if i + 1 < len(marks) else len(text)
out[m.group(1).strip()] = text[m.end():end]
return out
def scope(text: str, cmd: str | None) -> str | None:
"""The slice of `text` a check should look at. None = the evidence is not present."""
if not cmd:
return text
blocks = split_blocks(text)
if blocks is None:
return text # an unmarked dump (a bare /export) genuinely contains everything
for name, body in blocks.items():
if name.startswith(cmd) or cmd in name:
return body
return None
def config_text(site: str, dev: dict) -> tuple[str, list[str]]:
"""Read this device's exports off the disk. The text never leaves this process."""
cfgdir = C.site_dir(site) / "configs"
seen, parts, names = set(), [], []
candidates = []
if dev.get("config_export_path"):
candidates.append(cfgdir / Path(dev["config_export_path"]).name)
host = dev.get("host_id") or ""
if host and cfgdir.is_dir():
candidates += sorted(cfgdir.glob(f"{host}*"))
for p in candidates:
if p in seen or not p.is_file():
continue
seen.add(p)
try:
parts.append(p.read_text(encoding="utf-8", errors="replace"))
names.append(p.name)
except OSError:
continue
return "\n".join(parts), names
def excerpt_for(text: str, pattern: str) -> str:
"""One redacted line of evidence. Never more - this ends up in a document."""
m = re.search(pattern, text)
if not m:
return ""
start = text.rfind("\n", 0, m.start()) + 1
end = text.find("\n", m.end())
line = text[start:end if end != -1 else len(text)].strip()
line, _ = redact_device_secrets(line)
return line[:160]
def _finding(cid, severity, category, host_id, title, detail, evidence, rec_fix,
confidence, public_safe=False, refs=None) -> dict:
f = {
"id": cid, "severity": severity, "category": category, "title": title,
"detail": detail, "evidence": [{**e, "observed_at": now_iso()} for e in evidence],
"confidence": confidence, "recommendation": rec_fix, "public_safe": public_safe,
}
if host_id:
f["host_id"] = host_id
if refs:
f["references"] = [v for v in (refs.get("vendor"),) if v]
return f
def checks_for(vendor: str | None) -> list[dict]:
v = (vendor or "unknown").lower()
return [c for c in CHECKS if "*" in c["vendors"] or v in c["vendors"]]
def run_checks(site: str, rec: dict) -> tuple[list[dict], list[str]]:
findings: list[dict] = []
not_checked: list[str] = []
for dev in rec.get("devices") or []:
if not dev.get("reachable"):
continue
vendor = (dev.get("vendor") or "").lower()
host = dev.get("host_id") or "?"
text, files = config_text(site, dev)
applicable = [c for c in checks_for(vendor) if c.get("kind") != "manual"]
if not text:
if applicable:
not_checked.append(f"{host}: no config export on disk, so {len(applicable)} "
f"{vendor or 'vendor'} check(s) could not run")
continue
for c in applicable:
body = scope(text, c.get("cmd"))
if body is None:
not_checked.append(f"{host}: {c['id']} needs the output of "
f"`{c['cmd']}`, which is not in {', '.join(files)}")
continue
hit = re.search(c["pattern"], body)
bad = bool(hit) if c["kind"] == "config_present" else not hit
if not bad:
continue
ev = [{"source": c.get("cmd") or (files[0] if files else "config export"),
"excerpt": excerpt_for(body, c["pattern"]) if c["kind"] == "config_present"
else f"no line matching /{c['pattern']}/ in this output"}]
findings.append(_finding(
f"{c['id']}@{host}", c["severity"], c.get("category", "security"), host,
c["title"], c["why"], ev, c["fix"],
c.get("confidence") or
("confirmed" if c["kind"] == "config_present" else "suspected"),
public_safe=False,
refs={"vendor": VENDOR_GUIDE.get(vendor, VENDOR_GUIDE["*"])}))
for fn in RECORD_CHECKS:
findings += fn(rec)
not_checked += [f"needs a human, not a command: {c['title']}"
for c in CHECKS if c.get("kind") == "manual"]
return findings, not_checked
# ------------------------------------------------------------------ commands
def cmd_guide(args) -> int:
sel = [c for c in CHECKS
if not args.vendor or "*" in c["vendors"] or args.vendor.lower() in c["vendors"]]
if args.format == "md":
print("# netwalk hardening checklist\n")
by_vendor: dict[str, list] = {}
for c in sel:
for v in c["vendors"]:
by_vendor.setdefault(v, []).append(c)
for v in sorted(by_vendor):
print(f"\n## {v}\n\n_Baseline: {VENDOR_GUIDE.get(v, VENDOR_GUIDE['*'])}_\n")
print("| id | severity | check | how it is decided |")
print("|---|---|---|---|")
for c in by_vendor[v]:
how = {"config_present": f"`{c.get('cmd') or 'config'}` matches",
"config_absent": f"`{c.get('cmd') or 'config'}` does NOT match",
"manual": "**a human looks — not automated**"}[c["kind"]]
print(f"| `{c['id']}` | {c['severity']} | {c['title']} | {how} |")
return 0
print(f"{len(sel)} check(s){' for ' + args.vendor if args.vendor else ''}\n")
for c in sel:
auto = "MANUAL" if c["kind"] == "manual" else c["kind"]
print(f"[{c['severity']:>8}] {c['id']:<28} {c['title']}")
print(f" vendors: {', '.join(c['vendors'])} decided by: {auto}"
f"{' via ' + c['cmd'] if c.get('cmd') else ''}")
print(f" why : {c['why']}")
print(f" fix : {c['fix']}")
for v in c["vendors"]:
print(f" ref : {VENDOR_GUIDE.get(v, VENDOR_GUIDE['*'])}")
print()
return 0
def cmd_run(args) -> int:
rec_path = Path(args.record).expanduser()
try:
rec = json.loads(rec_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
die(f"cannot read the scan record {rec_path}: {e}", 3)
findings, not_checked = run_checks(args.site, rec)
order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
findings.sort(key=lambda f: (order.get(f["severity"], 9), f.get("host_id") or ""))
print(f"{len(findings)} finding(s) from {len(CHECKS)} catalogue check(s)\n")
for f in findings:
print(f" [{f['severity']:>8}] {f.get('host_id') or '-':<14} {f['title']}")
if not_checked:
print(f"\nNOT CHECKED ({len(not_checked)}) - say so in the report rather than letting "
f"it read as a clean result:")
for n in not_checked:
print(f" - {n}")
if args.dry_run:
print("\n--dry-run: the record was not modified")
return 0
existing = rec.setdefault("findings", [])
have = {f.get("id") for f in existing}
added = [f for f in findings if f["id"] not in have]
existing += added
cov = rec.setdefault("coverage", {}).setdefault("not_covered", [])
for n in not_checked:
note = f"hardening check not run - {n}"
if note not in cov:
cov.append(note)
rec_path.write_text(json.dumps(rec, indent=2) + "\n", encoding="utf-8")
print(f"\n{len(added)} finding(s) added to {rec_path} "
f"({len(findings) - len(added)} already present)")
return 0
def main() -> int:
ap = argparse.ArgumentParser(prog="netwalk_audit.py", description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
g = sub.add_parser("guide", help="print the hardening checklist itself")
g.add_argument("--vendor")
g.add_argument("--format", choices=["text", "md"], default="text")
g.set_defaults(func=cmd_guide)
r = sub.add_parser("run", help="check a scanned site against the catalogue")
r.add_argument("--site", required=True)
r.add_argument("--record", required=True)
r.add_argument("--dry-run", action="store_true", help="print findings, change nothing")
r.set_defaults(func=cmd_run)
args = ap.parse_args()
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: b117221135048d2b017960c003124cf809a5509368b74c8fa8d358024a1c48af