How to Audit the Devices on Your Own Network

I own a router, a half-dozen cameras, a smart lock, a video doorbell and a speaker. Every so often I wonder which of them is still running the password it shipped with. nmap will tell me there is something on 192.168.1.37:554. It will not tell me that the thing on 192.168.1.37:554 is a Hikvision camera whose admin password is 12345 and whose firmware still carries CVE-2017-7921.

What this post is about. That gap is what net-scan was built to close. It is a security self-check tool: you point it at your own network, and it produces a report you can act on — and hand to someone else who does not speak security. You will see how the scan is layered, why the tool refuses to leave your network unless you ask it to, and the parts of the code that exist but are never called.

What you need to know first. A port is a numbered door into a device: a camera serving video on port 554 is a different door from the one serving its web interface. A firmware is the software baked into the device, and it is the thing that carries bugs. A CVE is a public identifier for one such bug — CVE-2017-7921 names exactly one of them. The tool is written in Rust, a compiled language that checks its types before the program runs; you do not need to know Rust to follow this post.

Why this design

The scoping decision came first, and everything else follows from it: this tool is for your own network. Not a subnet — a range of addresses on the same network — you have permission to test, not a range you found in a Shodan export, where Shodan is a search engine that indexes internet-exposed devices. Your network.

That is enforced in code, not just in the README:

1
2
3
4
5
6
if !cli.scan_external && !subnet_on_interface(&subnet, &iface) {
return Err(Error::Other(format!(
"subnet {} is not on local interface {}; pass --scan-external to allow",
subnet.cidr(), iface.name,
)).into());
}

src/main.rs:62. Cross-subnet scanning requires an explicit flag, and the flag’s help text says why: “Opt-in: allow scanning a subnet that’s not on the local interface. Default off (safer — won’t accidentally reach across your router).”

This constraint also dictates the shape of the tool. A penetration-testing tool wants breadth: scan everything, try everything, report what responded. A home self-check wants the opposite: a small, curated set of checks with high precision, because a report full of false positives trains its reader to ignore it.

Five layers

The scan is organised as five layers, each with a different failure mode:

Layer Question Method
0 — discovery Who is here? ARP sweep + ONVIF WS-Discovery + mDNS + SSDP, concurrently
1 — ports What speaks? Raw SYN scan over 13 IoT-specific ports
2 — fingerprint What is it? RTSP OPTIONS, ONVIF SOAP, HTTP banner, OUI prefix
3 — credentials Is it open? Layered HTTP Basic/Digest probe against default credentials
4 — risk Does it matter? Rule engine: 190 rules → severity + remediation

In plain terms: the first layer finds out which machines are switched on, using four different methods at once — an ARP sweep asks each address “who answers, and what hardware address do you have?”, ONVIF WS-Discovery is the video-industry standard for cameras shouting “here I am”, mDNS is the zeroconf protocol laptops use to find printers and speakers, and SSDP is the protocol used by streaming boxes and smart TVs. The second layer knocks on 13 ports that internet-of-things devices typically use, sending a SYN packet, which is the first half of a TCP connection handshake. The third layer asks the device to identify itself — by the protocol it speaks, by the banner it returns, or by its OUI prefix, the first half of its hardware address, which usually names the manufacturer. The fourth layer tries the passwords these devices ship with. The fifth layer turns all of that into a severity and a fix.

Layers 2, 3 and 4 are deliberately separate modules, because they fail in different ways. Identification is probabilistic — a banner is the header a device sends back, and matching it with a regular expression is a guess — and the code ranks its sources by confidence (RTSP server banner > HTTP Server header > HTML <title> > MAC OUI). Authentication is stateful and mutates the device: every wrong password increments a lockout counter on the real hardware. Risk is a pure function over the first two, which is why it can be tested against a JSON rule file with no network at all.

Everything offline

data/oui.json (547 IEEE OUI prefixes), data/rules.json (190 rules) and data/default_creds.json (55 factory credentials) are all embedded at compile time with include_str! and parsed once behind a Lazy static — a value Rust builds the first time something asks for it, and then reuses:

1
2
3
4
5
6
7
8
pub fn load() -> Result<Self> {
let raw = include_str!("../../data/oui.json");
let file: OuiFile = serde_json::from_str(raw)
.map_err(|e| crate::error::Error::Parse(format!("oui.json: {e}")))?;
let mut by_prefix = std::collections::HashMap::new();
for e in file.entries { if let Some(b) = parse_prefix(&e.prefix) { by_prefix.insert(b, e); } }
Ok(Self { by_prefix })
}

src/identify/oui.rs:32. The table is keyed by the first three bytes of a hardware address — the MAC address every network card has; the IEEE assigns each manufacturer a block of them, so those bytes often name the maker. Three reasons for embedding, all practical: a self-check tool must work on a LAN with no internet, which is often exactly the condition where you want an audit; include_str! means zero runtime file I/O; and a security tool should not depend on a third-party API at runtime.

rustls, and a TUI

reqwest is configured with default-features = false, features = ["rustls-tls"] — no OpenSSL headers, no brew install openssl. rustls is a TLS implementation written in Rust, and TLS is the encryption layer under https. The tool is meant to be installed by a non-specialist on a laptop.

The interactive view is ratatui — a TUI is a terminal user interface, a program that draws a text-mode interface with a keyboard instead of a mouse — and it renders captured camera snapshots inline using ratatui-image, which negotiates the terminal’s graphics protocol automatically (Sixel → Kitty → half-block fallback).

How it works

Discovery runs the three UDP probes concurrently — UDP is the connectionless cousin of TCP: it sends a packet and does not wait for a reply:

1
2
3
4
let ws_handle = crate::identify::onvif::ws_discover_multicast(bind_ip, timeout);
let mdns_handle = discover::mdns_query(bind_ip, &mdns_services, timeout);
let ssdp_handle = discover::ssdp_search(bind_ip, "ssdp:all", timeout);
let (ws_res, mdns_res, ssdp_res) = tokio::join!(ws_handle, mdns_handle, ssdp_handle);

src/scan.rs:360. tokio::join! waits for all three at once instead of one after another, which is the difference between a fast scan and a slow one. Then, for each live host, the port scan runs, then fingerprinting, then credentials, then risk.

Being intrusive on your own network

This is the part where a home tool has to behave like a guest. The credential probe tries the no-auth case first, then falls back to credentials — and it watches the device’s reaction:

1
2
3
4
5
6
7
8
let f = user_failures.entry(user.clone()).or_insert(0);
*f += 1;
total_failures += 1;
if *f >= MAX_FAILURES_PER_USER {
cooldown_until.insert(user.clone(), Instant::now() + PER_USER_COOLDOWN);
}
// Slow down to avoid lockout.
tokio::time::sleep(Duration::from_millis(500)).await;

src/auth/probe.rs:104, with MAX_FAILURES_PER_USER = 3, MAX_FAILURES_PER_HOST = 10, PER_USER_COOLDOWN = 30s. The counter tracks how many times this username has failed, and after three failures that username is put in a 30-second cooldown box. A real attack tool would want the lockout; a self-check tool must not brick your own doorbell.

The same applies to evidence capture. The RTSP snapshot is only ever taken with a credential pair the tool itself validated, and only when the user explicitly asks; src/stream/preview.rs:89 refuses with “preview requires non-empty credentials” if either half is empty.

The rule engine

Rules live in JSON, each with an optional precondition:

1
2
3
4
5
6
7
8
if let Some(check) = &rule.check {
match check.as_str() {
"no_auth_allowed" if !ctx.no_auth_detected => continue,
"default_cred" if !ctx.default_cred_detected => continue,
"rtsp_unencrypted" if !ctx.rtsp_unencrypted => continue,
_ => {} }
}
let severity = rule.severity_override.unwrap_or_else(|| severity_from_cvss(rule.cvss));

src/risk/rules.rs:141. The precondition is the false-positive killer: a rule about a backdoor that requires default credentials only fires if default credentials were actually accepted. The match arms each name a condition and skip the rule when it is not met; the final _ => {} arm catches every other case and does nothing. Severity comes from CVSS, the standard scoring system for vulnerabilities, unless a rule overrides it, and the report renders it with plain-language labels (严重 / 高 / 中 / 低, that is severe, high, medium and low) plus remediation text — plain instructions for how to fix the problem — because the reader is a homeowner, not a penetration tester.

Baseline diffing

--diff-baseline compares a new scan against a stored one, matching devices by IP and findings by rule id, and reports what is new, what disappeared, and what changed severity. This turns the tool from a snapshot into a regression detector: “since last month’s scan, this new CVE appeared on your doorbell.”

Two async bugs, one week apart

The most transferable thing in this repository is not the scanner. It is the pair of commits that fixed two different ways of using async incorrectly.

The first was a nested runtime inside a block_on. block_on is the call that runs an async program to completion; creating a second runtime inside one deadlocks. The fix was to stop nesting and simply .await directly, because a runtime was already running.

The second was blocking_send from inside a runtime worker, which panics with “Cannot block the current thread from within a runtime”. The progress callbacks are invoked from the blocking pcap loops — pcap being the library that captures raw packets — so they cannot await. The fix is to make the telemetry lossy:

1
2
3
|sent, total| { let _ = tx.try_send(ScanUpdateMsg::Status(
format!("arp {sent}/{total}")
)); },

src/scan.rs:50. try_send hands a message to a queue if there is room and gives up if there is not. Note the let _ =: even a Full error is deliberately swallowed. A dropped “arp 120/254” tick is irrelevant. A panicking scan is fatal. Progress telemetry must be allowed to lose messages.

If you are mixing blocking packet capture with tokio, the question to ask is always: which context am I in right now?

Trade-offs

False positives are the enemy. The CVE sync tool (tools/nvd_sync.py) applies a two-stage filter: a list of terms that disqualify an entry entirely (erlang, tomcat, jackson-datibind, openssl, spring-boot) and a list of terms that are required (camera, smart lock, nvr). Without that filter, a 30-day NVD pull — the National Vulnerability Database, the public feed of newly published CVEs — is thousands of irrelevant server CVEs and the report becomes noise.

The value is in the rules and the report, not the packets. Anyone can write an ARP scanner; nmap exists. What this tool adds is the mapping from “device on port 554” to “this specific model, this firmware, this CVE, this severity, this fix” — and a report format a non-specialist can act on.

The report is written in Chinese. The severity labels and remediation text are in Chinese, because the intended reader is the person who owns the devices. That is a deliberate choice, and it is why the tool’s report title is 家庭网络安全体检 — “home network security health check”.

The tool is intrusive by nature. Even on your own network, an ARP sweep plus SYN scans plus credential attempts will show up in your router’s logs and may slow a cheap switch. The counter-measures are the 500 ms delay between credential attempts and the per-user cooldown; there is no global rate limiter, which is the gap I would fix first.

What I would do differently

The interesting thing about this repository is the gap between “module exists, is tested, and is correct” and “module is actually used”.

src/identify/oui.rs::lookup() is implemented, unit-tested, and never called. scan.rs loads the OUI table and then does let _ = oui;. The same is true of identify/banner_match.rs::best_match() — only the RTSP branch of it is used; the HTTP-server and HTML-title branches are dead in the pipeline. And auth/default.rs::for_vendor(), which knows which factory credentials belong to which vendor, is never called either: the scan tries all 55 vendors’ credentials against every HTTP device.

That last one is not just wasteful, it is wrong. Trying admin/12345 against a device that has never heard of it is exactly the kind of noise that erodes trust in a report.

The remaining gaps are the same shape:

  • --concurrency is parsed, documented, defaulted to 1000, and wired to nothing. Either implement it or delete it.
  • DeviceReport hardcodes model: None, firmware: None, which means the 18 rules carrying a firmware_lt constraint can never fire in practice. The unit tests pass because they construct a DeviceContext directly instead of going through the scanner.
  • src/auth/creds.rs is a nine-byte file containing //! Stub.
  • reqwest is in Cargo.toml but every HTTP request in the codebase is hand-rolled over tokio::net::TcpStream.

The build order shows through all of this: implement a module thoroughly with tests, then wire it up later. The wiring is about 60% done.

If I were picking up this codebase again, the first commit would be to make the orchestrator call what the library already knows how to do — OUI lookup, banner matching, per-vendor credentials, ONVIF model and firmware extraction — and the second would be to delete the flags that do nothing. A tool whose report says “I checked 55 credential sets against your camera” when it should have checked four is worse than one that says nothing.

The 136 tests and the clean crate structure are worth keeping. The discipline of separating what a device is, whether it is open, and whether that matters is the part of this design that has held up best — even in the places where I did not finish wiring it up.

References

  • ratatui — the Rust library the interactive terminal interface is drawn with
  • rust-pcap — a Rust binding to libpcap, the packet capture library behind the scanning loops
  • IEEE Standards Association — where the OUI prefixes that identify manufacturers are published
  • nmap — the general-purpose scanner this tool borrows its discovery ideas from
  • RFC 793 — the original specification of TCP, including the three-way handshake the SYN scan uses
  • pnet — a Rust crate for low-level packet and socket work