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
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +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 ports 22
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.64 54:60:09:11:22:33 Google (Chromecast)
192.168.1.71 d8:3a:dd:aa:bb:cc Mac15,7, AirPlay
...

changes since last scan: 1 appeared, 1 port-opened
Expand Down Expand Up @@ -70,6 +72,8 @@ Findings are recorded alongside scans, so a repeat audit marks what is `[NEW]` s
| `upnp-control-open` | medium | The router answered an unauthenticated control request - so would it for anything else on the LAN |
| `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.

**A port number is not evidence of a protocol either.** Port 23 being open does not prove telnet is behind it. So for services that greet you unprompted - FTP, Telnet, VNC - netdiff will not name the protocol until it has heard the greeting. RTSP and MQTT say nothing until spoken to, so there the evidence line states plainly that the identification is by port assignment, and the `verify` command lets you settle it.

**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.
Expand Down Expand Up @@ -106,6 +110,7 @@ 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 --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 inventory # every device ever seen, first and last sighting
netdiff history # diff the two most recent scans
Expand Down Expand Up @@ -154,15 +159,17 @@ 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, 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 - 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.

## Development

```bash
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, and the one end-to-end test stands up a throwaway HTTP server on loopback. The database is a temp file.
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_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.

Expand Down
2 changes: 1 addition & 1 deletion netdiff/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ def rule_mapping(mapping, devices):
)

if mapping.internal_port in device.ports:
label = device.hostname or device.vendor or device.ip
label = device.hostname or device.vendor or device.services or device.ip
return finding(
"internet-exposed-service",
device.ip,
Expand Down
26 changes: 23 additions & 3 deletions netdiff/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import urllib.request

from . import audit as audit_rules
from . import oui, store, upnp
from . import mdns, oui, store, upnp
from .diff import diff, summarise
from .scan import discover, grab_banner

Expand All @@ -33,13 +33,26 @@ def send_webhook(url: str, payload: dict, timeout: float = 10) -> str:
return ""


def device_label(hostname: str, vendor: str, services: str) -> str:
"""What to call a device, best evidence first.

A hostname is what a device was named; its mDNS announcement is what it says
it *is*. Both are worth printing, so the second one goes in parentheses
beside the first - unless it is the only thing we have, in which case it is
the label, and "unknown" is left for devices that told us nothing at all.
"""
main = hostname or vendor or services or "unknown"
return f"{main} ({services})" if services and services != main else main


def cmd_scan(args) -> int:
ports = () if args.no_ports else tuple(args.ports)
devices = discover(
args.subnet,
ports=ports,
lookup_vendor=oui.lookup,
resolve_names=not args.no_resolve,
services={} if args.no_mdns else mdns.discover(),
)
conn = store.connect(args.db)
# Read the prior scan before inserting this one, or the "previous" scan
Expand Down Expand Up @@ -72,7 +85,7 @@ def cmd_scan(args) -> int:
else:
print(f"scan {scan_id}: {len(devices)} device(s) on {args.subnet}")
for device in devices:
label = device.hostname or device.vendor or "unknown"
label = device_label(device.hostname, device.vendor, device.services)
open_ports = (
f" ports {','.join(str(p) for p in device.ports)}"
if device.ports
Expand Down Expand Up @@ -165,6 +178,7 @@ def placeholders(text):
ports=tuple(args.ports),
lookup_vendor=oui.lookup,
resolve_names=not args.no_resolve,
services={} if args.no_mdns else mdns.discover(),
)
banners = {
(device.ip, port): grab_banner(device.ip, port)
Expand Down Expand Up @@ -235,7 +249,7 @@ def cmd_inventory(args) -> int:
return 0
print(f"{len(rows)} device(s) ever seen\n")
for row in rows:
label = row["hostname"] or row["vendor"] or "unknown"
label = device_label(row["hostname"], row["vendor"], row["services"])
print(f"{row['ip']:<15} {row['mac']} {label}")
print(
f" first {row['first_seen']} last {row['last_seen']} seen {row['times_seen']}x"
Expand Down Expand Up @@ -272,6 +286,9 @@ def build_parser() -> argparse.ArgumentParser:
scan.add_argument("--ports", type=int, nargs="*", default=list(DEFAULT_PORTS))
scan.add_argument("--no-ports", action="store_true", help="skip the port scan")
scan.add_argument("--no-resolve", action="store_true", help="skip reverse DNS")
scan.add_argument(
"--no-mdns", action="store_true", help="skip asking devices what they are"
)
scan.add_argument("--webhook", help="POST a JSON alert here when anything changed")
scan.add_argument(
"--fail-on-change",
Expand All @@ -292,6 +309,9 @@ def build_parser() -> argparse.ArgumentParser:
aud.add_argument("subnet", nargs="?", help="CIDR to audit, e.g. 192.168.1.0/24")
aud.add_argument("--ports", type=int, nargs="*", default=list(DEFAULT_PORTS))
aud.add_argument("--no-resolve", action="store_true", help="skip reverse DNS")
aud.add_argument(
"--no-mdns", action="store_true", help="skip asking devices what they are"
)
aud.add_argument(
"--no-upnp", action="store_true", help="skip the router port-forward check"
)
Expand Down
10 changes: 9 additions & 1 deletion netdiff/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,15 @@ def severity(self) -> int:
return SEVERITY.get(self.kind, 0)

def __str__(self) -> str:
label = self.device.hostname or self.device.vendor or self.device.mac
# Display only. `services` is deliberately not compared anywhere in this
# module: one missed mDNS reply would otherwise report a change every
# other scan, and a change that is not real is worse than none.
label = (
self.device.hostname
or self.device.vendor
or self.device.services
or self.device.mac
)
suffix = f" ({self.detail})" if self.detail else ""
return f"[{self.kind}] {label} {self.device.ip} {self.device.mac}{suffix}"

Expand Down
Loading
Loading