From 341c0fcc355c7b0a9a65db88ea5adb097aa087de Mon Sep 17 00:00:00 2001 From: Gabriel Lluch Date: Sat, 1 Aug 2026 17:39:48 -0700 Subject: [PATCH 1/2] feat(glossary): explain the words, not just the findings --- README.md | 31 +++ netdiff/cli.py | 40 +++- netdiff/glossary.py | 441 +++++++++++++++++++++++++++++++++++++++++ tests/test_glossary.py | 63 ++++++ 4 files changed, 573 insertions(+), 2 deletions(-) create mode 100644 netdiff/glossary.py create mode 100644 tests/test_glossary.py diff --git a/README.md b/README.md index 6f96baa..52571ff 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,35 @@ There is no CVE matching here. Home-LAN banners rarely carry a precise enough ve **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. +### The words, not just the findings + +`netdiff audit --explain RULE` explains a finding. `netdiff glossary` explains the vocabulary a finding is written in, which is the other half of the same job - "the router answered an unauthenticated control request" only lands on someone who already knows what UPnP is, and the people this tool helps most are exactly the ones who do not. + +```console +$ netdiff glossary +30 terms. `netdiff glossary TERM` for any of them. + + arp how a device finds the hardware address behind an IP + captive-portal a network that intercepts your traffic until you agree to something + open-port a port where something accepted a connection - not a vulnerability + self-signed a certificate that vouches for itself - normal on a home network + ... + +$ netdiff glossary self-signed +self-signed - self-signed certificate + + what Subject and issuer are the same name: nothing external attests to it. This + is the ordinary case for a router or a NAS, which has no way to obtain a + certificate a browser would trust for a private address. The traffic is + genuinely encrypted; what is missing is identity, so the padlock says the + connection is private without saying who it is private with. It is why the + device shows a warning, and it is not a warning worth chasing. Do not turn + TLS off to make it go away. + see also certificate, certificate-authority, tls +``` + +One rule keeps it from becoming an encyclopaedia of networking: **a term earns an entry only if netdiff itself prints it.** Anything the tool never says is somebody else's glossary. + ### 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. @@ -150,6 +179,8 @@ netdiff audit 192.168.1.0/24 # what this network exposes, and why it 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 +netdiff glossary # every word the output uses, one line each +netdiff glossary upnp # ...and the whole entry for one of them # alerting: POST a JSON payload anywhere when something changed netdiff scan 192.168.1.0/24 --webhook https://ntfy.sh/my-topic diff --git a/netdiff/cli.py b/netdiff/cli.py index c5df7d8..88088a8 100644 --- a/netdiff/cli.py +++ b/netdiff/cli.py @@ -1,4 +1,4 @@ -"""Command line interface: scan, audit, inventory, history.""" +"""Command line interface: scan, audit, glossary, inventory, history.""" from __future__ import annotations @@ -13,7 +13,7 @@ from pathlib import Path from . import audit as audit_rules -from . import mdns, oui, probe, report, store, upnp +from . import glossary, mdns, oui, probe, report, store, upnp from .diff import diff, summarise from .scan import ( DEFAULT_PORTS, @@ -286,6 +286,31 @@ def placeholders(text): return 1 if args.fail_on_finding and serious else 0 +def cmd_glossary(args) -> int: + """The vocabulary, either all of it in one line each or one of it in full.""" + if not args.term: + print( + f"{len(glossary.TERMS)} terms. `netdiff glossary TERM` for any of them.\n" + ) + width = max(len(slug) for slug in glossary.TERMS) + for slug, entry in sorted(glossary.TERMS.items()): + print(f" {slug:<{width}} {entry['short']}") + print("\nFor the findings rather than the words: netdiff audit --explain RULE") + return 0 + + entry = glossary.TERMS.get(args.term.lower()) + if entry is None: + known = ", ".join(sorted(glossary.TERMS)) + print(f"unknown term {args.term!r}\nknown terms: {known}", file=sys.stderr) + return 2 + + print(f"{args.term.lower()} - {entry['name']}\n") + print_field("what", entry["long"]) + if entry["see"]: + print_field("see also", ", ".join(entry["see"])) + return 0 + + def cmd_inventory(args) -> int: conn = store.connect(args.db) rows = store.inventory(conn) @@ -406,6 +431,17 @@ def build_parser() -> argparse.ArgumentParser: aud.add_argument("--json", action="store_true") aud.set_defaults(func=cmd_audit) + gloss = sub.add_parser( + "glossary", + help="the words the rest of the output is written in", + description=( + "Every term netdiff itself prints, explained. Run it with no argument " + "for the list, or with a term for the whole entry." + ), + ) + gloss.add_argument("term", nargs="?", help="a term to explain, e.g. upnp") + gloss.set_defaults(func=cmd_glossary) + inv = sub.add_parser("inventory", help="every device ever seen") inv.add_argument("--json", action="store_true") inv.set_defaults(func=cmd_inventory) diff --git a/netdiff/glossary.py b/netdiff/glossary.py new file mode 100644 index 0000000..0fa8ac9 --- /dev/null +++ b/netdiff/glossary.py @@ -0,0 +1,441 @@ +"""The vocabulary the rest of the output is written in. + +`audit --explain RULE` explains a finding. This explains the words a finding is +made of, which is the other half of the same job: "the router answered an +unauthenticated control request" only lands on someone who already knows what +UPnP is, and the people most helped by this tool are exactly the ones who do not. + +One rule keeps this from turning into an encyclopaedia of networking: **a term +earns an entry only if netdiff itself says it.** Every slug below appears in a +finding, a change line, a column heading or a limitation in the README. Anything +netdiff never prints is somebody else's glossary. + +`long` is written for someone who has just read the word for the first time and +wants the sentence they were reading to make sense - not for someone studying +for an exam. Where a term is commonly misunderstood in a direction that matters +here, saying so is the most useful thing the entry can do. +""" + +from __future__ import annotations + +TERMS = { + "arp": { + "name": "ARP - Address Resolution Protocol", + "short": "how a device finds the hardware address behind an IP", + "long": ( + "Before one device on your network can send anything to another, it has " + "to know that device's MAC address - the IP address alone is not enough " + "to put a packet on the wire. ARP is the shout that asks: 'who has " + "192.168.1.42?', broadcast to everyone on the segment, answered by " + "whoever holds it. Every operating system caches the answers, which is " + "why netdiff can find your devices without root: it provokes the " + "questions, then reads your own machine's cache of the answers. ARP has " + "no authentication at all - any device can answer for any address, which " + "is a design decision from 1982 that everything since has had to live " + "with." + ), + "see": ("mac-address", "subnet"), + }, + "mac-address": { + "name": "MAC address", + "short": "the hardware address of a network interface", + "long": ( + "Six bytes, written as `a4:83:e7:1c:2d:9f`, belonging to a network " + "interface rather than to a network. An IP address is where a device is; " + "a MAC address is which device it is. That is why netdiff tracks devices " + "by MAC and not by IP - a phone that gets a new DHCP lease is the same " + "phone, and reporting it as one device leaving and another arriving " + "would bury the events that matter. The first three bytes are assigned " + "to a manufacturer, which is how a vendor name can be shown without " + "asking anyone." + ), + "see": ("arp", "randomised-mac", "vendor"), + }, + "randomised-mac": { + "name": "randomised MAC", + "short": "a per-network MAC address, invented by the device to avoid tracking", + "long": ( + "Phones and laptops now make up a fresh MAC address for each wifi " + "network they join, and change it periodically after that. It stops " + "shops and networks from recognising the same device across visits, " + "which is a good thing that happens to break device tracking on your own " + "network too. netdiff labels these `randomised` rather than pretending " + "to know whose they are: the second byte of the address has a bit set " + "that says 'this address was made up locally'. If you want a device to " + "keep a stable identity at home, turn private addressing off for that " + "one network on that device." + ), + "see": ("mac-address",), + }, + "vendor": { + "name": "vendor (OUI lookup)", + "short": "the manufacturer a MAC address was assigned to", + "long": ( + "The first three bytes of a MAC address are an Organisationally Unique " + "Identifier, bought from the IEEE by whoever made the interface. Looking " + "it up gives you a company, offline, with no lookup sent anywhere. What " + "it does not give you is a device type: 'Espressif' covers a smart plug, " + "a doorbell and someone's weekend project equally, and the company that " + "made the wifi chip is often not the company whose logo is on the box." + ), + "see": ("mac-address",), + }, + "subnet": { + "name": "subnet", + "short": "the range of addresses that share one local network", + "long": ( + "The group of IP addresses that can talk to each other directly, without " + "going through a router. `192.168.1.0/24` is a subnet holding 254 usable " + "addresses. It matters here because ARP does not cross routers: netdiff " + "sees one subnet, completely, and nothing at all beyond it. That is a " + "property of how it works rather than a limitation to be fixed." + ), + "see": ("cidr", "arp"), + }, + "cidr": { + "name": "CIDR notation", + "short": "the `/24` that says how much of an address is the network part", + "long": ( + "`192.168.1.0/24` means the first 24 bits identify the network and the " + "remaining 8 identify a device on it - so 256 addresses, of which 254 " + "are usable. A smaller number means a bigger network: `/16` is 65,536 " + "addresses. Home networks are almost always /24. netdiff works this out " + "for you from your own interface rather than assuming, because 'almost " + "always' is exactly the kind of nearly-true it refuses elsewhere." + ), + "see": ("subnet",), + }, + "port": { + "name": "port", + "short": "a numbered door on a device, one per service", + "long": ( + "One IP address, 65,535 possible TCP ports. A device runs a web server " + "on port 80 and a file server on port 445 and both are reachable at the " + "same address, because the port number says which one you meant. Common " + "numbers are conventions, not rules - a web server can listen on 8443 " + "and something else entirely can listen on 80, which is why netdiff will " + "not name a protocol from a port number alone." + ), + "see": ("open-port", "banner", "port-forward"), + }, + "open-port": { + "name": "open port", + "short": "a port where something accepted a connection - not a vulnerability", + "long": ( + "'Open' means a TCP handshake completed: something is listening and it " + "said hello back. That is what a working device looks like. A printer " + "with no open ports is a broken printer. 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. A port " + "becomes interesting when the protocol behind it has no encryption, when " + "it is reachable from outside your network, or when the software behind " + "it is known-broken." + ), + "see": ("port", "port-forward", "plaintext"), + }, + "banner": { + "name": "banner", + "short": "what a service volunteers about itself when you connect", + "long": ( + "Most plaintext protocols greet you before they authenticate you - " + "connect to an FTP server and it announces its name and version before " + "asking who you are. Reading that greeting is banner grabbing, and it " + "sends nothing, stores nothing and needs no credentials. HTTP is the " + "exception: it says nothing until asked, so netdiff sends `HEAD /`, the " + "smallest possible request. A banner is what the service tells everyone " + "who connects, which is the whole reason it is fair to read." + ), + "see": ("port", "open-port"), + }, + "plaintext": { + "name": "plaintext protocol", + "short": "a protocol with no encryption, by design rather than by mistake", + "long": ( + "Telnet, FTP, VNC, MQTT and RTSP carry everything readable: passwords, " + "keystrokes, camera streams. Anyone who can see the traffic sees the " + "contents - another device on the same wifi, a guest, anything on the " + "network that has been compromised. This is a property of the protocol " + "and not a setting on the device, which is why the fix is always to stop " + "using it rather than to tune it. Their encrypted equivalents exist: SSH " + "for Telnet, SFTP for FTP, MQTT over TLS on 8883." + ), + "see": ("telnet", "ssh", "tls"), + }, + "nat": { + "name": "NAT - Network Address Translation", + "short": "why every device at home shares one public address", + "long": ( + "Your router has one address on the internet and hands out private ones " + "inside. When a device here opens a connection outward, the router " + "rewrites it to come from itself, and remembers enough to send the reply " + "back. The side effect is a firewall you did not configure: an " + "unsolicited connection arriving from outside has no entry in that table " + "and nowhere to go, so it is dropped. NAT was never designed as security " + "- it is a consequence of running out of addresses - and a port forward " + "is the hole punched straight through it." + ), + "see": ("port-forward", "upnp"), + }, + "port-forward": { + "name": "port forward", + "short": "a rule sending a port on your public address to one device inside", + "long": ( + "'Anything arriving from the internet on port 8080, send it to " + "192.168.1.42 port 80.' It is the deliberate exception to NAT, and it " + "means the device behind it is reachable by anyone who scans your public " + "address - which the whole internet does continuously, as a background " + "hum. Forwards get created by hand in a router's admin page, and also by " + "devices asking for them over UPnP without telling you. A forward " + "pointing at an address nothing currently holds is worse than useless: " + "DHCP will eventually give that address to something else, which " + "inherits the hole." + ), + "see": ("nat", "upnp", "dhcp"), + }, + "upnp": { + "name": "UPnP - Universal Plug and Play", + "short": "how a device asks the router to open a port for it, unauthenticated", + "long": ( + "A protocol that lets devices on the LAN discover the router and ask it " + "to forward a port to them. There is no password and no prompt: the " + "router does it because it was asked. Games consoles are the usual " + "reason it is left on, and it is on by default on nearly every home " + "router. The cost is that every other device has the same privilege - a " + "smart bulb, a TV, a compromised laptop, a page open in a browser - and " + "you are not told. netdiff only ever asks the router to list the " + "forwards it already has; there is deliberately no code path in it that " + "creates one." + ), + "see": ("port-forward", "nat", "ssdp"), + }, + "ssdp": { + "name": "SSDP - Simple Service Discovery Protocol", + "short": "the multicast shout UPnP devices answer to announce themselves", + "long": ( + "How a device finds the UPnP router on a network: send a multicast " + "question, listen for replies naming a URL to fetch next. The replies " + "are unauthenticated UDP, so anything on the network can forge one and " + "choose the URL you fetch. That is why netdiff only follows a location " + "whose host is a literal private address inside the subnet being " + "audited, re-checks it at every hop, and refuses redirects outright." + ), + "see": ("upnp", "mdns"), + }, + "mdns": { + "name": "mDNS / Bonjour / DNS-SD", + "short": "how devices announce what they are to everyone on the network", + "long": ( + "DNS without a server: ask a question over multicast and whichever " + "device owns the answer replies to the whole segment. It is how your " + "phone finds a printer or a Chromecast, and it means most devices " + "announce their model and their services unprompted, continuously, to " + "anyone listening. netdiff asks the same question every phone on your " + "network already asks, and prints the device's own word for itself - " + "`Mac15,7` is a claim the device made, not an inference. A device that " + "announces nothing is left blank, because not knowing is the normal case." + ), + "see": ("dns", "ssdp"), + }, + "dns": { + "name": "DNS - Domain Name System", + "short": "turning a name into an address, and a place your traffic can be steered", + "long": ( + "Every connection to a name starts with a question to a resolver: what " + "address is `example.com`? Whoever answers that question decides where " + "you go. On a network you do not control, the resolver is chosen for you " + "by DHCP, which makes it the easiest place to watch what you look up, or " + "to send you somewhere else. A resolver that fabricates answers for names " + "that do not exist is doing both." + ), + "see": ("resolver", "dns-recursion", "captive-portal"), + }, + "resolver": { + "name": "resolver", + "short": "the server your machine asks to turn names into addresses", + "long": ( + "Usually your router, which passes the question on to your ISP; often a " + "public one like 1.1.1.1 or 9.9.9.9 if you have set one. Which resolver " + "you use is handed to you by the network over DHCP unless you override " + "it, so on someone else's wifi you are using theirs by default. It sees " + "every name you look up, in order, with timestamps." + ), + "see": ("dns", "dns-recursion"), + }, + "dns-recursion": { + "name": "recursion (open resolver)", + "short": "a server that will look up names it has no authority over", + "long": ( + "Answering 'what is the address of example.com' when you are not " + "example.com means going and asking on the caller's behalf - that is " + "recursion, and it is what a resolver is for. On a home network it is " + "usually just the router doing its job, and not a problem while it is " + "only reachable from inside. It becomes one when the same device is " + "reachable from the internet: a small forged query produces a large " + "reply sent to whoever the attacker named, which is the classic " + "amplifier for denial-of-service attacks." + ), + "see": ("dns", "resolver", "port-forward"), + }, + "captive-portal": { + "name": "captive portal", + "short": "a network that intercepts your traffic until you agree to something", + "long": ( + "The hotel or cafe page demanding a room number or a tick-box before " + "anything works. Mechanically it is interception: the network answers " + "DNS with its own address and redirects your web requests to itself. " + "That is unremarkable while it is only the portal, and worth knowing " + "about because the same machinery does not always switch off after you " + "have agreed. A network that still rewrites your DNS after you are " + "logged in is doing something else." + ), + "see": ("dns", "tls", "client-isolation"), + }, + "tls": { + "name": "TLS (the S in HTTPS)", + "short": "encryption plus identity - and the identity half is the part that fails", + "long": ( + "TLS does two separate jobs: it encrypts the connection so nobody in " + "between can read it, and it proves the other end is who it claims to " + "be, using a certificate. The first job almost always works. The second " + "is the one that fails in interesting ways, and every browser warning " + "you have ever clicked through is about the second job, not the first. " + "Encrypted-but-unverified still beats plaintext every time - the point " + "is knowing which of the two you have." + ), + "see": ("certificate", "self-signed", "certificate-authority"), + }, + "certificate": { + "name": "certificate", + "short": "a document saying 'this key belongs to this name', signed by someone", + "long": ( + "Presented by a server at the start of a TLS connection. It carries the " + "name it claims to be, the dates it is valid between, and a signature " + "from whoever vouches for it. Your browser trusts the connection if it " + "trusts the signer, the name matches what you typed, and today falls " + "between the dates. All three can fail independently, and the warning " + "rarely says which." + ), + "see": ("tls", "self-signed", "certificate-authority"), + }, + "certificate-authority": { + "name": "certificate authority (CA)", + "short": "an organisation your machine already trusts to vouch for others", + "long": ( + "Your operating system ships with a list of a few hundred of them. A " + "certificate signed by one on that list is accepted silently; anything " + "else produces a warning. Anyone who can add a CA to your machine can " + "sign certificates for any name in the world and your browser will show " + "a padlock - which is exactly how corporate traffic inspection works, " + "with consent, and how it works without consent when someone else " + "installs one." + ), + "see": ("certificate", "self-signed", "tls"), + }, + "self-signed": { + "name": "self-signed certificate", + "short": "a certificate that vouches for itself - normal on a home network", + "long": ( + "Subject and issuer are the same name: nothing external attests to it. " + "This is the ordinary case for a router or a NAS, which has no way to " + "obtain a certificate a browser would trust for a private address. The " + "traffic is genuinely encrypted; what is missing is identity, so the " + "padlock says the connection is private without saying who it is private " + "with. It is why the device shows a warning, and it is not a warning " + "worth chasing. Do not turn TLS off to make it go away." + ), + "see": ("certificate", "certificate-authority", "tls"), + }, + "smb": { + "name": "SMB / CIFS", + "short": "Windows file sharing, on port 445 - and its 1996 version is still around", + "long": ( + "The protocol behind shared folders and network drives. SMBv1, from " + "1996, cannot verify who it is talking to, so a device on the same " + "network can sit in the middle of a file transfer unnoticed. It is the " + "protocol EternalBlue and WannaCry travelled over, and worms built on it " + "are still circulating, because the devices still answering it are the " + "ones nobody updates. Microsoft stopped installing it by default in " + "2017. Every client made in the last decade speaks SMB2 or SMB3." + ), + "see": ("port", "plaintext"), + }, + "ssh": { + "name": "SSH - Secure Shell", + "short": "an encrypted remote login, on port 22", + "long": ( + "The right way to get a command line on another machine, and the " + "replacement for Telnet. It negotiates its cryptography with each client " + "and announces the algorithms it is willing to use before authentication " + "happens at all - which is why those can be read without ever attempting " + "a login. A server still offering RC4, CBC ciphers or SHA-1 key exchange " + "is not broken, but it is a reliable sign of firmware nobody has updated " + "in years, which is usually the more useful thing to learn from it." + ), + "see": ("telnet", "plaintext", "banner"), + }, + "telnet": { + "name": "Telnet", + "short": "a remote login with no encryption whatsoever, on port 23", + "long": ( + "Everything typed and everything shown crosses the network readable, " + "including the password at the start. It has been superseded by SSH " + "since the 1990s. Finding it open on a device today says less about the " + "risk of that one port than about the age of the firmware behind it." + ), + "see": ("plaintext", "ssh"), + }, + "ttl": { + "name": "TTL - Time To Live", + "short": "a hop counter whose starting value hints at the operating system", + "long": ( + "Every packet carries a number that each router decrements, so a packet " + "cannot loop forever. On one local segment there are no routers in the " + "way, so what arrives is what the sender started with - and different " + "operating systems start with different values: 64 for Linux, macOS and " + "BSD, 128 for Windows, 255 for a lot of network gear. That narrows a " + "device to a family and nothing more, and only when the value is an " + "exact match. Real fingerprinting needs crafted packets and root." + ), + "see": ("open-port",), + }, + "dhcp": { + "name": "DHCP", + "short": "how a device is handed an address, a gateway and a resolver on joining", + "long": ( + "Join a network and it gives you an IP address on a lease, tells you " + "which address is the router, and tells you which resolver to use. All " + "three are choices the network makes for you, which is why joining a " + "network you do not control is a decision and not a formality. Leases " + "expire and addresses are handed out again, which is what makes a port " + "forward pointed at an absent device dangerous rather than merely stale." + ), + "see": ("resolver", "port-forward", "mac-address"), + }, + "client-isolation": { + "name": "client isolation (AP isolation)", + "short": "a wifi setting stopping devices on the same network from reaching each other", + "long": ( + "With it on, every device can reach the internet and nothing else - not " + "even the machine at the next table. Guest networks and decent public " + "wifi turn it on; plenty of public wifi does not. It cuts both ways, " + "which is the point: when it is off, other people's devices are " + "reachable from yours, and yours is reachable from theirs." + ), + "see": ("subnet", "captive-portal", "vpn"), + }, + "vpn": { + "name": "VPN", + "short": "a tunnel that moves your trust from the local network to somewhere else", + "long": ( + "Everything you send is encrypted to a server elsewhere before it " + "touches the local network, so a hostile network sees encrypted traffic " + "to one address and nothing about its contents. It is the general answer " + "to 'this wifi cannot be trusted', and the right answer to 'I need to " + "reach something at home from outside' - a VPN back to your own network " + "replaces a port forward, and does not leave a hole open for everyone " + "else. What it does not do is protect you from the device you are typing " + "on." + ), + "see": ("client-isolation", "port-forward", "captive-portal"), + }, +} diff --git a/tests/test_glossary.py b/tests/test_glossary.py new file mode 100644 index 0000000..4b9a7ae --- /dev/null +++ b/tests/test_glossary.py @@ -0,0 +1,63 @@ +"""The glossary is almost entirely prose, and prose is not testable. + +What *is* testable is the structure holding it: a see-also link that points at +nothing, an entry missing a field, a one-line summary that is three lines long. +Those are the things that can be wrong rather than merely badly worded, so they +are what these tests pin. The rest is editing, not engineering. +""" + +from netdiff import cli +from netdiff.glossary import TERMS + +FIELDS = ("name", "short", "long", "see") + + +def test_every_see_also_points_at_a_term_that_exists(): + """The one thing in this module that can be broken silently. + + A dead link is invisible until someone types the term it names and is told + it does not exist - by the command whose whole job is explaining things. + """ + for slug, entry in TERMS.items(): + for target in entry["see"]: + assert target in TERMS, f"{slug} points at {target!r}, which is not a term" + + +def test_every_entry_is_well_formed(): + """`short` is printed in a column beside the slug, so it has to fit on a line.""" + for slug, entry in TERMS.items(): + assert set(entry) == set(FIELDS), f"{slug} has the wrong fields" + assert entry["name"] and entry["long"], f"{slug} is missing text" + assert slug == slug.lower() and " " not in slug, f"{slug} is not a slug" + short = entry["short"] + assert short and "\n" not in short, f"{slug} has no usable summary" + assert len(short) <= 80, f"{slug} summary is {len(short)} chars, too long" + + +def test_the_list_names_every_term(capsys): + assert cli.main(["glossary"]) == 0 + out = capsys.readouterr().out + for slug in TERMS: + assert slug in out + + +def test_one_term_prints_its_whole_entry(capsys): + assert cli.main(["glossary", "upnp"]) == 0 + out = capsys.readouterr().out + assert TERMS["upnp"]["name"] in out + # Wrapped across lines by print_field, so a distinctive phrase rather than + # the paragraph - checking the whole string would only test textwrap. + assert "no password and no prompt" in out.replace("\n", " ").replace(" ", " ") + assert "port-forward" in out, "see-also links are how you keep reading" + + +def test_a_term_is_found_however_it_was_typed(capsys): + assert cli.main(["glossary", "UPnP"]) == 0 + assert TERMS["upnp"]["name"] in capsys.readouterr().out + + +def test_an_unknown_term_says_what_is_known(capsys): + """Same shape as `audit --explain` on an unknown rule: exit 2, list, stderr.""" + assert cli.main(["glossary", "quantum"]) == 2 + err = capsys.readouterr().err + assert "quantum" in err and "arp" in err From 384002851c912bc18975bedf515e779efee186c7 Mon Sep 17 00:00:00 2001 From: Gabriel Lluch Date: Sat, 1 Aug 2026 17:39:48 -0700 Subject: [PATCH 2/2] fix(store): keep two forwards that differ only in protocol, and drop a truncated TXT string --- netdiff/mdns.py | 11 ++++++++++- netdiff/store.py | 10 +++++++++- tests/test_mdns.py | 12 ++++++++++++ tests/test_scan_and_store.py | 30 ++++++++++++++++++++++++++++-- 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/netdiff/mdns.py b/netdiff/mdns.py index 49574af..2d016c7 100644 --- a/netdiff/mdns.py +++ b/netdiff/mdns.py @@ -196,11 +196,20 @@ def parse_records(data: bytes): def _decode_txt(rdata: bytes) -> dict: - """TXT rdata is a run of length-prefixed key=value strings.""" + """TXT rdata is a run of length-prefixed key=value strings. + + A string claiming to be longer than the rdata holding it stops the walk, for + the same reason `parse_records` stops on an over-long `rdlen`: the slice + would silently come back short, and `model=Mac15,7` truncated to `model=Mac` + is a wrong answer rather than a missing one. Whatever was read before the bad + length is kept - it was well-formed. + """ out = {} i = 0 while i < len(rdata): length = rdata[i] + if i + 1 + length > len(rdata): + break chunk = rdata[i + 1 : i + 1 + length].decode("utf-8", "replace") key, _, value = chunk.partition("=") if key: diff --git a/netdiff/store.py b/netdiff/store.py index 99c440b..0095ec7 100644 --- a/netdiff/store.py +++ b/netdiff/store.py @@ -38,7 +38,15 @@ device TEXT NOT NULL, title TEXT NOT NULL, evidence TEXT NOT NULL, - PRIMARY KEY (scan_id, rule, device, title) + -- Evidence is in the key because rule/device/title alone are not unique: a + -- TCP and a UDP forward on the same external port produce the identical + -- title, and the two collapsed into one row. The evidence is `str(Mapping)`, + -- which carries the protocol, so it is what separates them. + -- + -- ponytail: a database written before this change keeps its old primary key + -- - ALTER TABLE cannot change one, and the only consequence is the same + -- duplicate being dropped as before. Not worth a table rebuild for. + PRIMARY KEY (scan_id, rule, device, title, evidence) ); """ diff --git a/tests/test_mdns.py b/tests/test_mdns.py index d7e3db4..c74ba39 100644 --- a/tests/test_mdns.py +++ b/tests/test_mdns.py @@ -186,6 +186,18 @@ def test_an_empty_txt_string_is_dropped_rather_than_keyed_on_nothing(): assert mdns.parse_records(data)[0][2] == {"a": "b"} +def test_a_txt_string_longer_than_its_rdata_is_dropped_not_truncated(): + """The same class of input `parse_records` already refuses one level up. + + Truncating gives `model=Mac`, which is a confident wrong answer where the + honest outcome is no answer. Everything read before the bad length is kept: + it was well-formed, and one lying string is not grounds to discard the rest. + """ + rdata = b"\x03a=b" + b"\x40model=Mac15,7" # claims 64 bytes, carries 13 + data = message(record("tv.local", mdns.TYPE_TXT, rdata)) + assert mdns.parse_records(data)[0][2] == {"a": "b"} + + # --- describing a device ----------------------------------------------------- diff --git a/tests/test_scan_and_store.py b/tests/test_scan_and_store.py index 91f6883..02e0a8f 100644 --- a/tests/test_scan_and_store.py +++ b/tests/test_scan_and_store.py @@ -443,13 +443,18 @@ def test_first_seen_survives_the_device_changing_ip(conn): assert store.first_seen(conn, "aa:bb:cc:00:00:01") is not None -def find(rule="plaintext-protocol", device="192.168.1.10", title="Telnet on port 23"): +def find( + rule="plaintext-protocol", + device="192.168.1.10", + title="Telnet on port 23", + evidence="banner", +): return Finding( rule=rule, severity="high", device=device, title=title, - evidence="banner", + evidence=evidence, why="w", fix="f", verify="v", @@ -473,6 +478,27 @@ def test_two_findings_of_one_rule_on_one_device_do_not_collide(conn): assert len(store.finding_keys(conn, scan_id)) == 2 +def test_two_forwards_differing_only_in_protocol_are_both_kept(conn): + """A TCP and a UDP forward on the same external port are two doors. + + Their rule, device and title are identical - only the evidence, which is the + router's own `str(Mapping)` line, carries the protocol. Without evidence in + the primary key the second silently replaced the first, and the audit that + the database recorded was not the audit that was printed. + """ + scan_id = store.record_scan(conn, "192.168.1.0/24", []) + store.record_findings( + conn, + scan_id, + [ + find(title="port 8080 forwarded", evidence="*:8080/tcp -> 192.168.1.10:80"), + find(title="port 8080 forwarded", evidence="*:8080/udp -> 192.168.1.10:80"), + ], + ) + rows = conn.execute("SELECT COUNT(*) FROM findings").fetchone()[0] + assert rows == 2 + + def test_a_repeat_audit_finds_nothing_new(conn): first = store.record_scan(conn, "192.168.1.0/24", []) store.record_findings(conn, first, [find()])