A Python CLI tool that deeply scans local networks to discover devices, classify them by type, assess their security posture, analyze traffic patterns, detect firmware vulnerabilities, audit WiFi security, track changes over time, and map network topology.
Built for personal/home network security auditing.
- Home network audit — Find every device on your network, see what ports they expose, and get actionable security fixes
- IoT device inventory — Identify smart speakers, cameras, hubs, printers, and other IoT devices by type with confidence scoring
- Security assessment — Test for default credentials, weak TLS, outdated firmware, open admin panels, and known CVEs
- Traffic monitoring — Capture and analyze per-device traffic patterns, DNS queries, and beaconing behavior
- Change detection — Track devices appearing/disappearing over time, compare scans, get alerts on changes (terminal or webhook)
- Network segmentation planning — Get zone recommendations (trusted/IoT/quarantine) and firewall rules
- WiFi security — Detect rogue APs, hidden SSIDs, WEP / Open networks, WPS PIN brute-force exposure (Reaver / Pixie-Dust), and PMKID-capturable WPA2/WPA3 networks (Steube 2018 / hashcat
-m 22000) - Cross-radio device tracking — Auto-join WiFi/IP devices with their BLE adverts ("your iPhone is at 192.168.1.42 and broadcasting BLE AA:BB:…") — a view no free competitor produces automatically
- Baseline drift alerts — A device opening a new port, changing OS, or picking up a firmware update surfaces as a scan finding under
netlens risks - Bluetooth audit — Inventory BLE devices in range, map them to known products (smart locks, trackers, wearables, smart TVs, soundbars, Chromecasts, fitness trackers), map their chipset firmware to published CVEs (SweynTooth / BrakTooth / BleedingTooth / KNOB), detect privacy leaks from Apple Continuity and Microsoft Swift Pair beacons, and surface anti-stalking warnings from persistent AirTag / Tile presence across scans
- Interactive BLE probe —
netlens probe <address>enumerates a single target device's GATT services, marks writable characteristics, surfaces the negotiated MTU, and drops into a REPL for one-shot writes — the manual hands-on counterpart to the passivenetlens bluetoothaudit. Paged reads defeat the 512-byte ATT-MTU ceiling so gzipped config blobs come out whole.
- Python 3.13+
- nmap system binary
# macOS
brew install nmap
# Debian/Ubuntu
sudo apt-get install nmap# Clone and install with uv
git clone <repo-url> && cd netlens
uv sync
# Optional: macOS WiFi scanning support
uv sync --extra macos
# Optional: Bluetooth (BLE) audit support — adds bleak + pyyaml
uv sync --extra bluetooth# Full scan with root (best results — enables ARP, traffic capture, WiFi, OS detection)
sudo uv run netlens scan
# Scan without root (reduced capabilities, no traffic/WiFi)
uv run netlens scan
# Scan a specific subnet
sudo uv run netlens scan 192.168.1.0/24
# View security findings
uv run netlens risks
# Generate an HTML report
uv run netlens report
# Bluetooth audit (no sudo needed on macOS; requires [bluetooth] extra)
uv run netlens bluetooth --duration 30All flags: see Scan Options for the
netlens scanreference, Bluetooth Options for thenetlens bluetoothreference, or Global Flags for flags that work on any command. You can also runnetlens --helpandnetlens <command> --help.
| Command | Description |
|---|---|
netlens scan [NETWORK] |
Full network discovery and security audit |
netlens watch [NETWORK] |
Continuous monitoring — alerts on device changes |
netlens bluetooth |
BLE discovery + security audit (firmware CVEs, beacons, anti-stalking) |
netlens probe [ADDRESS] |
Interactive single-target BLE GATT probe — enumerate writable characteristics, send targeted writes, observe notifications |
| Command | Description |
|---|---|
netlens risks |
Security findings sorted by severity with remediation |
netlens traffic |
Per-device traffic patterns, DNS queries, anomalies |
netlens segment |
Network zone recommendations and firewall rules |
netlens topology |
ASCII network topology map (gateway → switches → devices) |
netlens correlate |
Join WiFi/IP devices with BLE devices from the same audit window |
| Command | Description |
|---|---|
netlens history |
List past scans with device/finding counts |
netlens diff <SCAN_ID> |
Compare two scans — new, removed, changed devices |
netlens report |
Generate a self-contained HTML security report |
netlens devices |
View, label, and annotate device profiles |
| Command | Description |
|---|---|
netlens setup-geoip |
Download MaxMind GeoIP databases for traffic geolocation |
| Flag | Description |
|---|---|
--version, -V |
Print netlens <version> and exit |
--quiet, -q |
Suppress pre-flight and next-steps panels |
--no-color |
Disable ANSI colors (useful for CI logs) |
--no-progress |
Disable the live scan dashboard |
--theme {auto,dark,light} |
Color theme variant |
Every netlens scan flag, what it does, and which stage
it affects. Defaults shown for value-taking flags.
| Flag | Default | What it does | Stage |
|---|---|---|---|
--full |
off (top ~1000 ports) | Scan all 65535 TCP ports | 2 (Fingerprint) |
--deep |
off | Enable NSE vulnerability scripts, DHCP rogue detection, SNMP community probing, and ARP tracking during traffic capture | 1, 3, 6 |
--ports TEXT |
— | Custom port list (e.g. 22,80,443 or 1-1024) — overrides --full |
2 |
--timeout INT |
10 | Per-host scan timeout in seconds | 2 |
--capture-time INT |
60 | Traffic capture duration in seconds | 6 |
--skip-creds |
off | Skip default-password testing (SSH/Telnet/HTTP) | 5 |
--skip-traffic |
off | Skip packet capture (faster, but loses beaconing detection) | 6 |
--skip-wifi |
off | Skip WiFi environment scan | 7 |
--skip-topology |
off | Skip network topology mapping | post-scan |
--wifi-interface TEXT |
auto-detect | WiFi interface for monitor mode (e.g. en0) |
7 |
--json |
off | Emit results as JSON to stdout (machine-readable) | output |
--verbose, -v |
off | Show scan duration and extra per-stage detail | output |
# Heaviest scan — all ports + deep checks (slow but thorough)
sudo uv run netlens scan --full --deep
# Fast triage — discovery + classification only, no creds/traffic/wifi
uv run netlens scan --skip-creds --skip-traffic --skip-wifi
# Custom port set (overrides --full)
uv run netlens scan --ports 22,80,443,8080
# Longer traffic capture for catching low-rate beaconing
sudo uv run netlens scan --capture-time 300
# CI / log-friendly output
uv run netlens --no-progress --no-color scan --jsonGlobal flags like
--quiet,--no-progress,--no-color,--themeapply to every command and must precede the subcommand (netlens --no-progress scan, notnetlens scan --no-progress). See Global Flags.
NetLens runs 7 stages sequentially. Each stage can fail independently without blocking others.
| # | Stage | What it does | Needs root? |
|---|---|---|---|
| 1 | Discovery | ARP + mDNS + SSDP + DHCP rogue detection | No (fallback to arp -a) |
| 2 | Fingerprinting | Nmap ports/services, MAC vendor, SNMP probe, banner grab, favicon hash | No (connect scan only) |
| 3 | SNMP Deep | Interface tables, ARP/routing tables, community string audit | No |
| 4 | Classification | Device type assignment (12 categories), firmware extraction, confidence scoring | No |
| 5 | Security Audit | 12 parallel security checks (see below) | No |
| 6 | Traffic Analysis | Packet capture, DNS extraction, anomaly detection | Yes |
| 7 | WiFi Security | Nearby networks, rogue AP detection, encryption + WPS + PMKID + PMF audit | Yes |
| Check | Severity | What it detects |
|---|---|---|
| Default credentials | CRITICAL | SSH/Telnet/HTTP login with common defaults (root/root, admin/admin) |
| CVE lookup | CRITICAL–LOW | Known vulnerabilities via NVD API by CPE/product name |
| Firmware outdated | HIGH | Device firmware behind manufacturer's latest release |
| TLS/SSL analysis | HIGH–MEDIUM | Expired certs, weak ciphers, self-signed certificates |
| Admin panels | MEDIUM | Accessible /admin, /login, /console without authentication |
| UPnP control surface | HIGH | Controllable UPnP services accepting SOAP commands |
| MQTT injection | HIGH | Unauthenticated MQTT broker accepting publish commands |
| REST API exposure | MEDIUM | Unauthenticated API endpoints on common ports |
| DNS rebinding | MEDIUM | Devices vulnerable to DNS rebinding attacks |
| Protocol security | MEDIUM | Telnet, FTP, unencrypted HTTP, weak SSH ciphers |
| HTTP methods | MEDIUM | Dangerous methods enabled (PUT, DELETE, TRACE) |
| Printer vulnerabilities | MEDIUM | SNMP community strings, default management passwords |
Router, Gateway, Managed Switch, Smart Home Hub, Smart Speaker, Smart TV, Media Player, Media Server, Printer, IP Camera, IoT Sensor, Mobile Device, Single Board Computer.
Beacon-parsed findings that ride the same severity / netlens risks /
JSON / HTML report pipeline as Stage-5 device findings. Each finding
is attached to its source network by BSSID.
| Finding | Severity | What it detects |
|---|---|---|
| WEP encryption | HIGH | Broken since 2001 (Fluhrer/Mantin/Shamir); aircrack-ng recovers the key in seconds from a few captured IVs |
| Open (unencrypted) WiFi | HIGH | All traffic broadcast in the clear; flagged for any visible (non-hidden) network |
| WPS PIN brute-force possible | MEDIUM | WPS enabled + AP-Setup-Locked clear (or unknown): Reaver (4-10h for the full 10^4+10^3 PIN space) |
| WPS PIN attack (Pixie-Dust likely) | HIGH | Same as above, but the AP's BSSID OUI matches a curated Pixie-Dust-vulnerable chipset (Realtek RTL81xx / Ralink RT3xxx / Broadcom BCM63xx). Extend the list via ~/.netlens/wifi_chipsets.yaml. |
| WPS enabled but currently locked | INFO | AP rate-limited PIN entry; lock typically expires and re-enables the attack surface |
| PMKID capture possible | MEDIUM | WPA/WPA2/WPA3-PSK without PMF required: attacker initiates association without a real client, harvests PMKID from EAPOL M1, cracks offline with hashcat -m 22000 (Steube, 2018) |
netlens risks runs a full scan and lists every security finding,
sorted by severity. Covers IP-device findings (Stage 5) and WiFi
findings (Stage 7) in one unified pipeline — --severity filters
either source.
| Flag | Default | What it does |
|---|---|---|
NETWORK (positional) |
auto-detect | Target network CIDR |
--severity TEXT |
— | Filter by severity (comma-separated: critical,high) |
--verbose, -v |
off | Show full description + remediation per finding |
--json |
off | JSON output; WiFi findings tagged "source": "wifi" |
# All findings on the local network
sudo uv run netlens risks
# Only HIGH and CRITICAL
sudo uv run netlens risks --severity critical,high
# Verbose: full remediation guidance
sudo uv run netlens risks --severity high -v
# Pipe to jq (JSON output is `soft_wrap=True` so it survives long descriptions)
sudo uv run netlens risks --json | jq '.[] | select(.severity == "high")'netlens traffic runs a scan with packet capture enabled and renders
per-device communication summaries (DNS queries, destination peers,
detected anomalies). Requires root for packet capture; without it
the table will be empty.
| Flag | Default | What it does |
|---|---|---|
NETWORK (positional) |
auto-detect | Target network CIDR |
--capture-time INT |
60 | Traffic capture duration in seconds |
--verbose, -v |
off | Show per-device traffic breakdown |
--json |
off | JSON output for piping into analytics |
# Default 60-second capture
sudo uv run netlens traffic
# Longer capture for low-rate beaconing IoT devices
sudo uv run netlens traffic --capture-time 300
# Per-device DNS + peer breakdown
sudo uv run netlens traffic -v
# Pipe DNS queries into jq
sudo uv run netlens traffic --json | jq '.devices[].dns_queries'netlens topology detects the gateway, passively sniffs LLDP/CDP
frames for switch/AP discovery (needs root), and renders an ASCII
tree.
| Flag | Default | What it does |
|---|---|---|
--sniff-time INT |
60 | Seconds to listen for LLDP/CDP frames |
--json |
off | Emit topology as JSON for piping |
# Default 60-second sniff + ASCII tree
sudo uv run netlens topology
# Quick gateway-only view (10s sniff)
sudo uv run netlens topology --sniff-time 10
# JSON for piping into other tools
sudo uv run netlens topology --json > topology.jsonTwo commands share the SQLite history DB at ~/.netlens/history.db:
history lists past scans; diff compares two of them.
| Flag | Default | What it does |
|---|---|---|
--limit, -n INT |
20 | Maximum past scans to show |
--json |
off | JSON output |
# 20 most recent scans
uv run netlens history
# Just the last 5
uv run netlens history -n 5
# Full history as JSON
uv run netlens history -n 1000 --json| Arg / Flag | Default | What it does |
|---|---|---|
SCAN_ID (required) |
— | Baseline scan ID (from netlens history) |
SCAN_ID_2 (optional) |
latest scan | Second scan to compare against |
--json |
off | JSON output |
# Compare abc123 against the latest
uv run netlens diff abc123
# Compare two specific scans
uv run netlens diff abc123 def456
# JSON pipe
uv run netlens diff abc123 --json | jq '.new_devices'netlens bluetooth is a separate subcommand that performs an industry-standard
BLE security audit comparable to the workflow a security professional on Kali
would run by hand. It's gated behind the [bluetooth] extra so the base install
stays lean:
uv sync --extra bluetooth
uv run netlens bluetooth --duration 30Platform support: macOS works out of the box (no sudo). Linux works as long as the user has CAP_NET_ADMIN or root. Windows is supported via WinRT.
The audit is an 8-layer pipeline; every layer is independently skippable:
| # | Layer | Output |
|---|---|---|
| 1 | Passive discovery via bleak |
Address, name, RSSI, advertised services, manufacturer data, BLE address-type (Public / Random Static / RPA / Random Non-Resolvable) |
| 2 | GATT enumeration of the Device Information Service | Firmware/hardware/software revision, model, serial, manufacturer string, PnP ID. Allowlist-gated by default. |
| 3 | Beacon parsing & device classification | iBeacon, Eddystone (UID/URL/TLM/EID), AltBeacon, Bluetooth Mesh detection. Service-UUID device class (e.g. "Heart rate monitor", "Smart Lock"). |
| 4 | Continuity-protocol parsers | Apple Continuity (Handoff, AirDrop, Nearby Info, Wi-Fi Settings, AirPods, FindMy) and Microsoft Swift Pair |
| 5 | CVE pipeline (3-stage fallback) | NVD CPE search → NVD keyword search → curated offline chipset DB covering SweynTooth / BrakTooth / BleedingTooth / KNOB |
| 6 | Curated misconfiguration checks | Pairing-mode posture, writable-no-auth GATT, manufacturer/DIS spoofing, PII-in-name, non-randomizing address |
| 7 | Companion-app fingerprint DB | Maps advertisements to ~15 consumer products (smart locks, trackers, wearables, bulbs) with product-specific CVEs |
| 8 | Cross-scan tracking detection | Anti-stalking: flags devices appearing across N of last K scans + ≥2 location tags. Special handling for AirTag-class beacons that rotate identifiers. |
| + | macOS escalation (system_profiler) |
Enumerates paired Classic Bluetooth devices invisible to BLE-only scanning |
| + | Linux escalation (bettercap if installed) |
Deeper GATT enrichment via Kali's standard tool |
| + | Optional active write-probe | Writes a benign 0x00 to confirm-or-mitigate writable-no-auth findings (allowlist-gated by default; --probe-writes-all for authorized testing) |
| Flag | Default | What it does |
|---|---|---|
--duration INT |
30 | Passive listen duration in seconds |
--connect/--no-connect |
--connect |
Attempt GATT enum on allowlisted devices to read DIS (firmware/model) |
--connect-all |
off | Override the allowlist gate; GATT-enum every device. Prints a 5-second active-probing consent banner. |
--gatt-timeout INT |
5 | Per-device GATT connect timeout in seconds |
--gatt-concurrency INT |
2 | Max simultaneous GATT connects (2 = safe default for BlueZ) |
--allowlist PATH |
~/.netlens/bluetooth_allowlist.txt |
Devices approved for GATT enum (see Allowlist file format) |
--skip-cve |
off | Skip CVE lookup entirely |
--offline-only |
off | Don't query NVD; use only the curated offline chipset DB (air-gapped audits) |
--probe-writes |
off | Active write-probe: write benign 0x00 to writable handles to confirm-or-mitigate findings (allowlist-gated) |
--probe-writes-all |
off | Probe ALL devices' writable handles. Prints 5-second consent banner. |
--tracking/--skip-tracking |
--tracking |
Cross-scan tracking detection (needs ≥3 prior scans in history) |
--tracking-lookback INT |
10 | Past scans to correlate against |
--tracking-threshold FLOAT |
0.6 | Minimum appearance ratio to flag as following |
--owned-devices PATH |
~/.netlens/bluetooth_owned.txt |
Fingerprints to suppress in tracking detection |
--skip-escalation |
off | Skip platform-specific escalation (system_profiler / bettercap) |
--location TEXT |
untagged |
Tag this scan with a location label for cross-scan tracking |
--report PATH |
— | Write a self-contained HTML audit report to PATH after the scan |
--load-external-checks |
off | Load community-authored BLE checks from ~/.netlens/checks/*.py. Executes arbitrary Python so prints a 5-second consent banner; built-in check names cannot be shadowed |
--json |
off | Output as JSON (suppresses panels and HTML report banner) |
--verbose, -v |
off | Show per-device GATT-enum outcomes and fingerprint signals (the manufacturer ID + service UUIDs + name that fed the cross-scan identity hash — answers "why are these two adverts collapsed to one device?") |
15 distinct finding types ship with the v1.0 audit:
| Finding | Severity | What it surfaces |
|---|---|---|
| NVD chipset CVEs | CRITICAL–LOW | Known firmware CVEs (SweynTooth, BrakTooth, BleedingTooth, KNOB) via chipset+firmware → NVD CPE/keyword/offline DB |
| Companion-app CVEs | CRITICAL–LOW | Product-specific CVEs (e.g. August Smart Lock Pro firmware <2.5.0 → CVE-2019-17098) |
| Detached AirTag in range | HIGH | Apple Continuity subtype 0x12 — a FindMy accessory separated from its owner |
| Persistent tracker across scans | HIGH/MEDIUM | A device fingerprint appearing in ≥60% of last 10 scans across ≥2 location tags |
| Writable GATT char without auth | HIGH (confirmed) / MEDIUM (advertised) | A characteristic accepts writes without encryption/auth — classic IoT lock attack surface |
| Manufacturer mismatch (spoofing) | HIGH | Advertised manufacturer ID doesn't match the GATT-read DIS string |
| LE Legacy pairing | HIGH | Device only accepts pre-4.2 pairing methods (broken key derivation) |
| Apple Wi-Fi join in progress | MEDIUM | Apple device is on the Wi-Fi settings screen — social-engineering window for rogue APs |
| Discoverable + connectable | MEDIUM | Pairing-mode advertisement on a device that may not have meant to be paired |
| Just Works pairing | MEDIUM | Pairable without MITM protection |
| Eddystone-URL (non-https / IP-literal) | MEDIUM | Beacon advertising a suspicious URL |
| iBeacon / Eddystone-UID disclosure | INFO | Deployment UUID / namespace / instance disclosed |
| Unprovisioned Bluetooth Mesh | MEDIUM | A mesh-provisioning beacon — anyone in range could provision the device |
| PII in advertised name | LOW | Names like Alice's iPhone, Bedroom-Lock, Printer-AC4F1B |
| Non-randomizing address | LOW | Public / Random Static addresses are trackable across scans |
| AirPods / earbuds in range | INFO | Battery state visible in cleartext (informational) |
| Swift Pair beacon | LOW / MEDIUM (PII) | Windows device in pairing mode; PII finding when the laptop name looks personal |
# Standard audit — 30s passive scan, NVD CVE lookup, all checks
uv run netlens bluetooth
# Tag scans with locations to enable anti-stalking detection
uv run netlens bluetooth --location home
uv run netlens bluetooth --location office
uv run netlens bluetooth --location cafe # ≥3 scans + ≥2 locations → tracking engine activates
# Air-gapped audit — no NVD calls, offline static DB only
uv run netlens bluetooth --offline-only
# Authorized active write-probe (your own devices only — populate allowlist first)
uv run netlens bluetooth --probe-writes
# Active probe on all devices (authorized testing only — prints consent banner)
uv run netlens bluetooth --probe-writes-all
# Standalone HTML audit report
uv run netlens bluetooth --report ~/Desktop/bt-audit.html
# JSON output for piping / CI
uv run netlens bluetooth --json --skip-tracking | jq '.devices[].findings'
# Bare-bones discovery, no CVE / escalation / tracking
uv run netlens bluetooth --skip-cve --skip-escalation --skip-tracking --no-connectnetlens probe is the active, single-target counterpart to the passive
netlens bluetooth audit. Use it when you already own a device (or have
authorization) and want to enumerate its writable characteristics, send
targeted writes, or watch its notification traffic.
# List every BLE device currently broadcasting (10s live scan, no connect)
uv run netlens probe --list
# Auto-pick the strongest-RSSI non-Apple device and probe it
uv run netlens probe --latest
# Connect to a specific peripheral and dump writable characteristics
uv run netlens probe D1B8EA1F-E5A6-FA50-7DEF-06B29F536D81
# One-shot targeted write to a known characteristic UUID
uv run netlens probe AA:BB:CC:DD:EE:FF \
--write 0000180a-0000-1000-8000-00805f9b34fb --payload 00| Flag | Default | What it does |
|---|---|---|
ADDRESS |
— | BLE MAC (Linux/Windows) or peripheral UUID (macOS). Optional when using --list or --latest. |
--list |
off | 10-second live scan; print every broadcasting device sorted by RSSI, then exit |
--latest |
off | Auto-pick the strongest-RSSI non-Apple device from a fresh 10-second scan |
--write UUID |
— | Characteristic UUID to write to. Skips the interactive REPL. |
--payload HEX |
00 |
Hex bytes for --write (e.g. 5601020303f0aa) |
--timeout SEC |
15 | Connect timeout (macOS often needs 10-20s) |
--no-notify |
off | Skip subscribing to notification/indication characteristics |
The probe prints the negotiated MTU at connect time so you can interpret
⚠ truncated annotations on read-back. It uses paged reads internally
(see netlens.bluetooth.gatt.read_full_value) to defeat the 512-byte
ATT-MTU ceiling — gzipped config blobs from devices like Chromecast
come back whole instead of truncated at the first PDU.
Three single-purpose scripts in scripts/ for ad-hoc exploration
outside the netlens CLI. Each is --help-discoverable, depends
only on bleak, and prints worked examples in its docstring.
| Script | What it does | Risk |
|---|---|---|
scripts/bt_connect_demo.py |
Surveys BLE range, attempts a read-only connect to every non-Apple/Microsoft device, prints a per-device verdict (✓ connected / ✗ timeout / ✗ refused). Answers "who in my room accepts strangers?" |
None — connect + disconnect only, no reads or writes. |
scripts/bt_read_all.py <address> |
Connects to one device and reads every readable characteristic, dumping hex + printable-ASCII so firmware versions, model strings, and JSON config blobs are immediately visible. | None — read-only. Some auth-required chars will fail individually; the dump continues. |
scripts/bt_control.py <address> |
Active counterpart. --list-writable (safe default) enumerates writable chars. --write UUID --payload HEX does a single targeted write. --probe-volume runs a reversible demo against the Bluetooth-standard Volume Control Service (0x2B7D/0x2B7E): captures current volume, issues a 1-step decrement, confirms, restores original. |
Low for --probe-volume (reversible). Custom --write writes are the user's responsibility — vendor-specific chars can trigger OTA/factory-reset on misuse. |
# 1. survey: who's reachable from a stranger Mac?
uv run python scripts/bt_connect_demo.py --rssi-min -85
# 2. read: what does this device leak to an unpaired peer?
uv run python scripts/bt_read_all.py <BLE_ADDRESS>
# 3. control: does it honour standard Bluetooth volume writes?
uv run python scripts/bt_control.py <BLE_ADDRESS> --probe-volumeGATT enumeration and write-probing are gated by ~/.netlens/bluetooth_allowlist.txt
(or --allowlist PATH). One entry per line, comments allowed:
# My devices
AA:BB:CC:DD:EE:FF # match by address
name:My AirPods Pro # match by exact advertised name
fp:6b7e2a91b4... # match by fingerprint hash
AABBCCDD-EEFF-1122-3344-556677889900 # macOS CoreBluetooth UUID also accepted
The owned-devices file (~/.netlens/bluetooth_owned.txt, suppresses tracking
findings) uses the same format but only fp:HASH lines are recognised — get
the fingerprint hash from netlens bluetooth --json and copy it in.
By default, NetLens never connects to a stranger's BLE device:
- GATT enum runs only on devices in your allowlist
- Write-probe runs only on devices in your allowlist
--connect-alland--probe-writes-allexist for authorized security testing but print a 5-second consent banner before bypassing the gate
This is the same posture industry pen-test frameworks default to. Connecting to or probing strangers' devices is legally grey in most jurisdictions — NetLens makes it explicit rather than implicit.
macOS: BLEDevice.address is a CoreBluetooth peripheral UUID, not a MAC.
Cross-scan dedupe relies on the advertisement fingerprint rather than the
address. system_profiler escalation runs automatically (no sudo needed) to
surface paired Classic Bluetooth devices invisible to BLE-only scanning.
⚠ macOS GATT enumeration depth varies. CoreBluetooth applies stricter privacy guards than Linux's BlueZ — for many devices (especially Apple peers) the OS hides the GATT service tree until the user explicitly pairs through System Settings → Bluetooth, in which case the writable-no-auth detection and
--probe-writesconfirmation will reportsvc_count=0. However, some non-Apple devices DO expose their full service tree to unpaired macOS peers — this was empirically observed during a Chromecast Ultra probe where 11 writable and 11 readable characteristics were enumerated with no pairing. So macOS isn't categorically blind, it's selectively so. NetLens emits anINFOfinding (macOS GATT service enumeration limited) when zero services come back across all connected devices on macOS, but absence of that finding doesn't guarantee full visibility for every target. For consistent deep GATT audit across every device, Linux (BlueZ exposes the full tree without pairing) remains the recommended platform.
Linux: Real MACs are exposed for non-RPA devices. Active scanning needs
root or CAP_NET_ADMIN — a warning panel surfaces when running unprivileged.
If bettercap is on PATH, it's invoked automatically for deeper GATT
enrichment. BlueZ exposes the full GATT service tree without pairing, so
this is the recommended platform for deep audit (writable-no-auth detection,
write-probe confirmation).
Windows: WinRT backend; real MACs exposed; no privilege requirement. GATT service enumeration depth varies by adapter driver; usually works without pairing on first-party Microsoft drivers but may require pairing on generic USB BLE dongles. No platform-specific escalation in v1.
NetLens auto-detects privileges and degrades gracefully:
| Feature | With sudo |
Without sudo |
|---|---|---|
| ARP scanning | Raw socket (fast, complete) | arp -a fallback (cached only) |
| Port scanning | SYN scan + OS detection | Connect scan only |
| Traffic capture | Full packet sniffing | Skipped |
| WiFi scanning | Monitor mode / CoreWLAN | Skipped |
| LLDP/CDP topology | Passive frame sniffing | Gateway-only |
| DHCP rogue detection | Listens for rogue servers | Skipped |
| Security checks | All checks run | All checks run |
BLE audit (netlens bluetooth) |
Works on macOS/Windows without sudo; on Linux needs root or CAP_NET_ADMIN | Same — privilege model is per-platform, not per-NetLens-feature |
All commands support --json for machine-readable output. Terminal tables auto-adapt column visibility to your terminal width.
netlens report produces a self-contained HTML file (CSS inline, no
external resources). Auto-attaches a "Cross-Radio Correlations"
section when a BLE scan exists within ±1 hour of the IP scan.
| Flag | Default | What it does |
|---|---|---|
--scan-id TEXT |
latest scan | Scan ID to report on |
--output, -o PATH |
docs/reports/ |
Output file path |
--compare TEXT |
— | Second scan ID — generates a comparison report |
--bluetooth-scan-id TEXT |
auto (±1h) | Specific BLE scan to correlate against |
--no-correlate |
off | Skip Cross-Radio Correlations even if a BLE scan exists |
# Latest scan, default path
uv run netlens report
# Specific scan (get IDs via `netlens history`)
uv run netlens report --scan-id abc123
# Comparison report (baseline vs current)
uv run netlens report --scan-id abc123 --compare def456
# Force a specific BLE scan to correlate
uv run netlens report --bluetooth-scan-id ble-987
# Skip Cross-Radio Correlations
uv run netlens report --no-correlate
# Custom output path
uv run netlens report -o my-report.html| What | Location |
|---|---|
| Scan history + device profiles (IP & Bluetooth) | ~/.netlens/history.db (SQLite, schema v4) |
| HTML reports | docs/reports/ (relative to working directory) |
| GeoIP databases | ~/.netlens/geoip/ (optional) |
| Bluetooth GATT allowlist | ~/.netlens/bluetooth_allowlist.txt (optional) |
| Bluetooth owned-device list (tracking suppression) | ~/.netlens/bluetooth_owned.txt (optional) |
| User-curated vulnerable chipset overlay | ~/.netlens/bluetooth_vulnerable_chipsets_user.yaml (optional, merged on top of package-shipped DB) |
| User-curated companion-app overlay | ~/.netlens/bluetooth_companion_apps_user.yaml (optional) |
Traffic destination geolocation requires free MaxMind databases.
Databases land in ~/.netlens/geoip/ and are read by the
traffic-analysis stage.
- Create a free account at maxmind.com
- Generate a license key under My Account → Manage License Keys
- Run
netlens setup-geoipwith your credentials:
| Flag | Required | What it does |
|---|---|---|
--account-id TEXT |
✓ | Your MaxMind account ID |
--license-key TEXT |
✓ | Your MaxMind license key |
# One-time setup (or refresh — overwrites existing DBs)
uv run netlens setup-geoip --account-id 123456 --license-key XYZNetLens scans 33 IoT-common ports by default:
21, 22, 23, 25, 53, 80, 81, 443, 554, 631, 1883, 1900,
3000, 3689, 5000, 5353, 5683, 6668, 8000, 8008, 8009,
8080, 8443, 8883, 9000, 9100, 49152, 49153, 49154
Use --full for all 65535 ports or --ports for a custom list.
netlens watch runs repeated scans at a fixed interval and alerts
when devices appear, disappear, or change. Each cycle is saved to
history. Ctrl+C to stop. Skips traffic + WiFi by default for faster
cycles; opt back in with --with-* flags.
| Flag | Default | What it does |
|---|---|---|
NETWORK (positional) |
auto-detect | Target network CIDR |
--interval, -i INT |
300 | Seconds between scans |
--skip-traffic / --with-traffic |
skip | Toggle traffic capture per cycle |
--skip-wifi / --with-wifi |
skip | Toggle WiFi audit per cycle |
--webhook-url URL |
— | POST a JSON diff payload on every change |
NETLENS_WEBHOOK_URL (env) |
— | Same as --webhook-url; keeps URL out of shell history |
# Default — scan every 5 minutes, alert in terminal
sudo uv run netlens watch
# Custom interval (10 minutes between cycles)
sudo uv run netlens watch -i 600
# Lightweight cycles — already the default; explicit for clarity
sudo uv run netlens watch --skip-traffic --skip-wifi
# Heaviest cycles — enable traffic + WiFi capture in each pass
sudo uv run netlens watch --with-traffic --with-wifi
# Push every diff to a webhook (ntfy / Pushover / Discord / Slack)
sudo uv run netlens watch --webhook-url https://ntfy.example.com/netlens
# Same, via env var
NETLENS_WEBHOOK_URL=https://ntfy.example.com/netlens sudo uv run netlens watchThe webhook POSTs JSON on each detected diff:
{
"scan_id": "abc123",
"timestamp": "2026-05-17T00:55:00Z",
"new": [{"ip": "...", "mac": "...", "vendor": "..."}],
"removed": [{"ip": "...", "mac": "...", "vendor": "..."}],
"changed": [{"ip": "...", "mac": "...", "changes": ["port 22 opened"]}],
"truncated": false
}TLS verification is on by default; redirects are disabled
(follow_redirects=False) as defense in depth against
attacker-controlled URL bouncing. Each category is capped at
100 entries — truncated: true is set if the diff overflowed.
Failures log a warning and never crash the watch loop.
netlens correlate joins WiFi/IP devices with BLE devices from
the same audit window — surfaces "your iPhone is at 192.168.1.42
on Wi-Fi and broadcasting BLE adverts as AA:BB:…" with no
manual cross-referencing.
| Flag | Default | What it does |
|---|---|---|
--scan-id TEXT |
latest IP scan | IP scan to correlate |
--bluetooth-scan-id TEXT |
latest BLE scan | BLE scan to correlate against |
--threshold FLOAT |
0.5 | Minimum aggregate score to emit a correlation row |
--window FLOAT |
600.0 | Time window (seconds) for temporal-proximity signal |
# Run an IP scan and a BLE scan, then correlate
sudo uv run netlens scan
uv run netlens bluetooth --duration 30
uv run netlens correlate
# Lower threshold to see weaker matches (for forensic review)
uv run netlens correlate --threshold 0.3
# Larger time window when the two scans aren't back-to-back
uv run netlens correlate --window 1800
# Compare specific historical scans
uv run netlens correlate --scan-id ip-abc --bluetooth-scan-id ble-xyzHeuristics with per-signal weights (capped at 1.0; emits any
pair scoring ≥ --threshold):
| Signal | Weight | Notes |
|---|---|---|
| Vendor match | +0.40 | IEEE OUI ↔ Bluetooth SIG company ID, normalised |
| Temporal proximity | +0.30 → 0.0 | Linear taper within --window seconds (default 600) |
| Advertised name ≈ hostname | +0.30 | Tolerant of .local and trailing dots |
Microsoft Swift Pair friendly_name |
+0.20 bonus | Stacks on top when present |
BLE addresses are deliberately not used as join keys (they rotate every ~15 min on RPA, and macOS replaces them with a per-host UUID). The "Signals" column in the output shows which heuristics fired and by how much, so a correlation can be audited at a glance.
netlens report auto-attaches this section when a BLE scan
exists within ±1 hour of the IP scan's start time — use
--bluetooth-scan-id to force a specific one, or
--no-correlate to skip entirely.
netlens bluetooth --load-external-checks discovers
community-authored checks in ~/.netlens/checks/*.py. Each
module exposes a register() -> list[BluetoothCheck]:
# ~/.netlens/checks/my_check.py
from netlens.bluetooth.checks import BluetoothCheck, CheckContext
from netlens.models.bluetooth import BluetoothDevice
from netlens.models.finding import Finding, Severity
async def check_thing(device: BluetoothDevice, ctx: CheckContext) -> list[Finding]:
if device.manufacturer_id == 0x1234:
return [Finding(
severity=Severity.HIGH,
title="Custom Vendor X risk",
description="…",
evidence="…",
remediation="…",
)]
return []
def register() -> list[BluetoothCheck]:
return [BluetoothCheck(name="my_check", func=check_thing)]The loader is opt-in (--load-external-checks flag with a
5-second consent banner) because it executes arbitrary Python
from disk. Built-in check names cannot be shadowed; import
errors, missing register(), and type mismatches are logged
and skipped without crashing the scan.
netlens segment assigns devices to 4 zones — TRUSTED, SMART_HOME,
IOT_ISOLATED, QUARANTINE — and emits firewall rules for inter-zone
communication.
| Flag | Default | What it does |
|---|---|---|
--scan-id TEXT |
latest scan | Scan ID to analyze |
--verbose, -v |
off | Show rationale for each zone assignment |
--json |
off | JSON output for piping into firewall config generators |
# Recommendations from the latest scan
uv run netlens segment
# Detailed rationale for each device-to-zone assignment
uv run netlens segment -v
# Analyze a specific historical scan
uv run netlens segment --scan-id abc123
# JSON for piping into firewall config generators
uv run netlens segment --json > segment.jsonnetlens devices manages persistent per-MAC device profiles (custom
names, labels, notes, history). Four sub-subcommands; calling
netlens devices with no subcommand is equivalent to list.
| Flag | Default | What it does |
|---|---|---|
--limit, -n INT |
100 | Maximum profiles to show |
--json |
off | JSON output |
| Arg / Flag | Default | What it does |
|---|---|---|
MAC (required) |
— | Device MAC address (e.g. AA:BB:CC:DD:EE:FF) |
--history |
off | Include per-scan timeline / baseline-drift view |
--limit, -n INT |
20 | Max history entries to show |
--json |
off | JSON output |
| Arg / Flag | Default | What it does |
|---|---|---|
MAC (required) |
— | Device MAC address |
--name TEXT |
— | Set custom display name |
--label TEXT |
— | Add a label (repeatable) |
--remove-label TEXT |
— | Remove a label (repeatable) |
--notes TEXT |
— | Free-form notes for this device |
| Arg / Flag | Default | What it does |
|---|---|---|
MAC (required) |
— | Device MAC address |
--yes, -y |
off | Skip confirmation prompt |
# List all known devices (also the default for `netlens devices`)
uv run netlens devices list
# Deep-dive into a specific device
uv run netlens devices show AA:BB:CC:DD:EE:FF
# Detailed view with per-scan timeline
uv run netlens devices show AA:BB:CC:DD:EE:FF --history
# Tag a device with a custom name + labels
uv run netlens devices edit AA:BB:CC:DD:EE:FF \
--name "Living Room TV" --label smart-tv --label trusted
# Add a note
uv run netlens devices edit AA:BB:CC:DD:EE:FF \
--notes "Powered off when guests leave"
# Remove a stale profile
uv run netlens devices delete AA:BB:CC:DD:EE:FF# Install dev dependencies
uv sync --group dev
# Run tests
uv run pytest -q
# Lint and format
uv run ruff check src/ tests/
uv run ruff format src/ tests/The repo-root VERSION file is the single source of truth. pyproject.toml,
netlens.__version__, pip show netlens, and netlens --version all read
from it. See docs/RELEASING.md for the cut-a-release
runbook.
| Component | Technology |
|---|---|
| Language | Python 3.13+ |
| Package manager | uv |
| CLI framework | Typer |
| Terminal UI | Rich |
| Packet manipulation | Scapy |
| Port scanning | nmap via python-nmap |
| mDNS discovery | zeroconf |
| UPnP discovery | ssdp |
| SSH testing | asyncssh |
| Telnet testing | telnetlib3 |
| SNMP queries | pysnmp 7 |
BLE discovery + GATT (optional [bluetooth] extra) |
bleak |
| CVE lookup | nvdlib (NVD API) |
| HTTP client | httpx |
| Data models | Pydantic |
| XML parsing | defusedxml |
| MAC vendor | mac-vendor-lookup |
| GeoIP | geoip2 (MaxMind) |
| Reports | Jinja2 |
| Database | SQLite (stdlib) |
| Linter/formatter | ruff |
| Testing | pytest |
Private — personal/home network security auditing tool.