Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 33 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ Then `netdiff audit` asks the question those tools do not: **which of these is r
```console
$ netdiff scan 192.168.1.0/24
scan 7: 12 device(s) on 192.168.1.0/24
192.168.1.1 00:1d:c9:0a:1b:2c router.local ports 53,80,443
192.168.1.23 b8:27:eb:aa:bb:cc Raspberry Pi (SSH, Web interface) ports 22
192.168.1.1 00:1d:c9:0a:1b:2c router.local Linux, macOS or BSD? (TTL 64) ports 53,80,443
192.168.1.23 b8:27:eb:aa:bb:cc Raspberry Pi (SSH, Web interface) Linux, macOS or BSD? (TTL 64) ports 22
192.168.1.64 54:60:09:11:22:33 Google (Chromecast)
192.168.1.71 d8:3a:dd:aa:bb:cc Mac15,7, AirPlay
192.168.1.71 d8:3a:dd:aa:bb:cc Mac15,7, AirPlay Windows? (TTL 128)
...

changes since last scan: 1 appeared, 1 port-opened
Expand All @@ -28,13 +28,17 @@ Most people assume NAT is a firewall - nothing outside can reach in unless they

```console
$ netdiff audit 192.168.1.0/24
audit 12: 192.168.1.0/24 - 1 critical, 2 high, 1 medium, 1 info
audit 12: 192.168.1.0/24 - 1 critical, 3 high, 2 medium, 3 info

critical nas.local (192.168.1.23:8080) is reachable from the internet on port 8080 [NEW]
high Telnet on port 23 sends usernames, passwords and every keystroke of the
session in cleartext
high port 8080 asks for a password over unencrypted HTTP
high 192.168.1.23 Telnet on port 23 sends usernames, passwords and every
keystroke of the session in cleartext
high 192.168.1.23 port 8080 asks for a password over unencrypted HTTP
high 192.168.1.40 SMBv1 file sharing is enabled on port 445
medium the router lets any device on the LAN open its firewall
medium 192.168.1.23 SSH on port 22 still offers 3 deprecated algorithm(s)
info 192.168.1.1 port 80 identifies itself as lighttpd 1.4.59
info 192.168.1.1 port 443 uses a certificate that vouches for itself
info 7 open port(s) observed, and not reported as problems

-v adds the evidence each line rests on, why it matters, how to fix it,
Expand All @@ -45,7 +49,7 @@ A report nobody finishes reading teaches nothing, so depth is something you ask

```console
$ netdiff audit 192.168.1.0/24 -v
audit 12: 192.168.1.0/24 - 1 critical, 2 high, 1 medium, 1 info
audit 12: 192.168.1.0/24 - 1 critical, 3 high, 2 medium, 3 info

CRITICAL
nas.local (192.168.1.23:8080) is reachable from the internet on port 8080 [NEW]
Expand Down Expand Up @@ -87,7 +91,13 @@ Findings are recorded alongside scans, so a repeat audit marks what is `[NEW]` s
| `plaintext-protocol` | high | Telnet, FTP, RTSP, MQTT or VNC - protocols with no encryption by design, confirmed by what the service said |
| `http-auth-plaintext` | high | A device sent an auth challenge over cleartext HTTP |
| `ssh-v1` | high | SSH protocol 1, deprecated since 2006 |
| `smb-v1` | high | A file server accepted the 1996 SMB dialect - the one EternalBlue and WannaCry travelled over |
| `upnp-control-open` | medium | The router answered an unauthenticated control request - so would it for anything else on the LAN |
| `ssh-weak-algorithms` | medium | An SSH server still offers RC4, CBC ciphers, MD5 integrity or SHA-1 key exchange |
| `tls-cert-expired` | medium | A certificate is past its own notAfter date |
| `service-version` | info | The software a service names when asked, quoted rather than guessed |
| `tls-cert-untrusted` | info | Explicitly **not** a problem. A self-signed certificate - the normal case on a LAN |
| `dns-recursion-open` | info | Explicitly **not** a problem, usually. A device resolves internet names for anyone who asks |
| `open-ports-noted` | info | Explicitly **not** a problem. See below. |

**And a vendor is not a device type.** "Espressif" covers a smart plug, a doorbell and someone's weekend project equally, so a MAC lookup alone leaves the most useful column nearly empty. Rather than guess a device type from its open ports - which is how the tool netdiff replaced arrived at "Managed Web Server" for a printer - netdiff asks the network the question every phone on it asks continuously, and reads the answer. Chromecasts, printers, Sonos, HomeKit gear and Apple devices all announce their services over multicast DNS, unprompted, to anyone on the segment. `Mac15,7` in the output is the device's own word for itself, not an inference. A device that announces nothing is left blank, because not knowing is the normal case.
Expand All @@ -96,12 +106,16 @@ Findings are recorded alongside scans, so a repeat audit marks what is `[NEW]` s

**An open port is not a vulnerability.** It is what a working device looks like. Tools that list every open port under a heading like "vulnerabilities found" are counting furniture and calling it a fire, and they train you to ignore the report. netdiff counts open ports and says out loud that they are not findings. A port becomes interesting when the protocol behind it is unencrypted, when it is reachable from outside the network, or when the software behind it is known-broken - and those are the rules above.

There is no CVE matching here. Home-LAN banners rarely carry a precise enough version to map to a CVE honestly, and guessing produces a scary list that means nothing.
There is no CVE matching here. Home-LAN banners rarely carry a precise enough version to map to a CVE honestly, and guessing produces a scary list that means nothing. netdiff prints the version a service names and stops there - the question worth asking is "is this still supported", which no vulnerability database answers.

**Four questions a port number cannot answer.** Port 445 being open says nothing about which SMB dialect a server accepts, so netdiff offers it the 1996 dialect alone and reports what comes back. Port 443 being open says nothing about the certificate behind it, so the certificate is read and judged on its own dates and names. An SSH server announces the algorithms it will negotiate with before authentication happens at all, so those are read from the handshake rather than by a failed login. And a device either resolves a name it has no authority over or it does not. Each is one exchange, sends nothing a server stores, and yields evidence rather than an inference.

### Read-only, and it means it

The audit **never sends credentials, never writes to a scanned host, and never changes router configuration.** It reads banners that services volunteer to anyone who connects, and it calls exactly one UPnP method - `GetGenericPortMappingEntry`. There is deliberately no `AddPortMapping` code path in the source.

The depth probes hold the same line. The TLS handshake is completed and abandoned. The SMB negotiate offers a dialect and reads the answer - no tree connect, no share enumeration, no null session. The SSH probe swaps version strings and reads the algorithm list the server sends unprompted, which is the alternative to the usual trick of grabbing it with a failed login. The DNS query asks for `example.com`, a name IANA reserves for exactly this. None of them authenticate, and none of them leave a record beyond a connection.

This rules out checks that would otherwise be easy. Anonymous-FTP detection needs a login attempt, so it is not here. A failed SSH auth against every host on every scan - a common trick for grabbing SSH banners - lands you in the target's auth log and in fail2ban, so that is not here either.

One trust boundary is worth naming: SSDP replies are unauthenticated UDP, so anything on your network can forge one and choose the URL netdiff fetches next. netdiff only follows a `LOCATION` whose host is a literal private address inside the subnet being audited, and caps every response it reads.
Expand Down Expand Up @@ -129,9 +143,11 @@ Python 3.9+. Nothing else - `pip show netdiff` lists no dependencies, and CI ass

```bash
netdiff scan 192.168.1.0/24 # scan, record, report changes
netdiff scan 192.168.1.0/24 --ports top100 # nmap's 100 most common ports, not the default 10
netdiff scan 192.168.1.0/24 --no-ports # discovery only, no TCP connections
netdiff scan 192.168.1.0/24 --no-mdns # skip asking devices what they are
netdiff audit 192.168.1.0/24 # what this network exposes, and why it matters
netdiff audit --ports top100 3000 5432 # a set, plus whatever else you run
netdiff inventory # every device ever seen, first and last sighting
netdiff history # diff the two most recent scans

Expand Down Expand Up @@ -168,7 +184,9 @@ History lives in `~/.netdiff/history.db` (override with `--db`). It is a plain S
- **Randomised MACs.** Phones and laptops rotate their MAC per network by default. Those devices appear as new hardware whenever they rotate; netdiff labels them `randomised` rather than pretending to know better. If you want stable identity for a device, disable private addressing for your network on that device.
- **Same broadcast segment only.** ARP does not cross routers, so this sees your subnet and nothing beyond it. That is a property of the approach, not a bug to fix.
- **A device asleep during a scan is indistinguishable from one that left.** Expect `vanished`/`appeared` churn from phones. Longer intervals produce less noise.
- **`port-opened` means a TCP handshake completed**, nothing about what is listening. `netdiff audit` adds banner reading, but there is still no OS fingerprinting and no CVE matching, on purpose - shallow version-guessing cannot compete with real scanners and only produces false confidence.
- **`port-opened` means a TCP handshake completed**, nothing about what is listening. `netdiff audit` adds banner reading, certificate reading and protocol handshakes on top of that, but there is still no CVE matching, on purpose.
- **The OS hint is a hint.** It is the TTL of one ping reply, which narrows a device to a family and nothing more - and only when the TTL is one of the three common starting values. Anything else is printed as the bare number, because 32 is not "nearly 64". Real fingerprinting needs crafted packets and a raw socket, which needs root. A device that drops ICMP has no hint at all, which is common.
- **`--ports top100` is 100 ports, not 65535.** It is nmap's frequency ranking, which is a good answer to "what is worth a timeout" and a bad answer to "what is definitely closed". A service on an unusual port is invisible to both the default set and this one.
- **No UPnP gateway means no UPnP findings, not a clean bill of health.** A router with UPnP disabled is a good result, and it is also the common case now. Port forwards you configured by hand do not appear in the UPnP table at all - check your router's admin page for those.
- **The audit sees the LAN's exposure, not the internet's view of it.** It reads the forwarding table the router admits to. The only way to know what is actually reachable is to test from outside, which is why every exposure finding hands you that command.
- **The bundled vendor table is small.** It covers common home-network hardware. For full coverage, download the IEEE registry and point `NETDIFF_OUI` at the CSV:
Expand All @@ -179,7 +197,7 @@ History lives in `~/.netdiff/history.db` (override with `--db`). It is a plain S

## Scope

Only scan networks you are responsible for. netdiff is deliberately read-only - it sends empty UDP datagrams, completes TCP handshakes, reads banners services volunteer, asks the standard DNS-SD question over multicast and reads the replies, and asks the router to list its own port forwards. It never writes to a host, never authenticates, and never changes router configuration. Even so, scanning equipment you do not own is your problem, not the tool's.
Only scan networks you are responsible for. netdiff is deliberately read-only. In full, what it sends: empty UDP datagrams to provoke ARP, one ICMP echo per device, TCP handshakes, `HEAD /` to HTTP ports, a TLS ClientHello, an SMB negotiate offering one dialect, an SSH version string, a DNS query for `example.com`, the standard DNS-SD question over multicast, and one UPnP request asking the router to list its own port forwards. It never writes to a host, never authenticates, and never changes router configuration. Even so, scanning equipment you do not own is your problem, not the tool's.

## Development

Expand All @@ -189,12 +207,16 @@ pip install pytest && pytest -q

The tests never touch the network. ARP parsing runs against captured `arp -an` and `ip neigh` output, UPnP parsing against captured router XML, mDNS parsing against hand-built packets, and the one end-to-end test stands up a throwaway HTTP server on loopback. The database is a temp file.

`test_probe.py` covers the protocol parsers. Its two SMB fixtures are real replies captured from Samba - one configured to allow SMBv1 and one to refuse it - because the refusal is the shape that matters: a parser that only handles the happy path reports every modern server as running SMBv1. The certificate is a throwaway generated by `openssl`, and the parser has to arrive at the same dates `openssl x509 -noout -dates` prints for it.

`test_mdns.py` builds its packets with its own helpers rather than with the encoder in `mdns.py`, because a decoder tested only against its own encoder agrees with itself however wrong both are. Half of that file is malformed input - a name pointing at itself, a record claiming to be longer than the packet carrying it - because anything able to send a UDP datagram can send those.

`test_diff.py` covers change detection. `test_audit.py` covers the rules, and roughly half of it asserts that something is *not* reported - an open port, an HTTP 200, a missing security header, a connection error. Those are the important half: the failure mode for a tool like this is not missing a finding, it is inventing one.

Every audit rule is a pure function - evidence in, a `Finding` or `None` out - and nothing in `audit.py` opens a socket. That is what makes the security logic testable at all. `Finding.evidence` has no default value, so a finding cannot be constructed without the observation that proves it.

The split holds across three modules: `scan.py` finds what is here, `probe.py` asks protocols the questions a port number cannot answer, and `audit.py` decides what any of it means. `probe.py` keeps its parsers separate from its sockets for the same reason - the fiddly half is bytes in, a value out.

## License

MIT
Loading
Loading