~/2026/01/07/python-discovery-fingerprint-every-device-on-a-subnet.md

Python: Discovery – Fingerprint Every Device on a Subnet

---
author: 
date: 
read: 3 min
in:   [scripts, engineering]
tags: [python, discovery, network]
---

$ grep -n '^#' post.md

Commercial discovery tools are great once they're deployed, but there's a recurring need for something faster and cheaper: a quick, scriptable sweep of a subnet during a site walk, a merger due-diligence pass, or a "what's actually plugged into this switch" moment. I keep a small Python script around for exactly this. It ARP-sweeps the subnet to find live hosts, maps each MAC address to its registered vendor for a rough device-type guess, probes a handful of common ports, pulls a service banner where one is offered, and writes everything to a CSV I can drop straight into a spreadsheet or a ticket.

It's the "what is on this VLAN" collector in the discovery-first approach to documenting an undocumented network. When nmap is available and allowed, nmap -sn does the same ARP discovery; this script is for the boxes where you'd rather pip install than get a scanner approved.

Requirements

  • Python 3.10 or later.
  • The scapy package (pip install scapy) for the ARP sweep.
  • Root on Linux/macOS (sudo), because Scapy sends raw frames. On Windows, Scapy needs Npcap installed and an elevated prompt.
  • Layer 2 adjacency to the subnet. ARP doesn't cross routers, so this only sees the broadcast domain the machine running it is plugged into. For routed subnets, use a TCP-based sweep like the Node.js RDP/SSH scanner.
  • Optional: a copy of the IEEE MA-L (OUI) registry in its text format, oui.txt, for full vendor lookups. Many Linux distributions already ship it (for example /usr/share/hwdata/oui.txt from the hwdata package), or download it from the IEEE Registration Authority's public listing.
  • Written authorization for the network you're sweeping. Even an ARP sweep plus a few TCP connects shows up in IDS and switch logs.

Usage

Sweep a /24 and write the report:

bash
sudo python3 fingerprint_subnet.py --subnet 192.168.10.0/24 --output subnet-fingerprint.csv

Use the full IEEE vendor list, a custom port list, and a longer timeout for a slow or lossy segment:

bash
sudo python3 fingerprint_subnet.py --subnet 10.20.30.0/24 --oui-file /usr/share/hwdata/oui.txt --ports 22,80,443,3389,8080,9100 --timeout 1.5 --output site-b.csv
text
ARP-sweeping 10.20.30.0/24...
6 host(s) responded.
10.20.30.1      70:10:6f:xx:xx:xx  Hewlett Packard Enterprise   ports: 22;443
10.20.30.10     00:50:56:xx:xx:xx  VMware, Inc.                 ports: 22;80;443
10.20.30.21     00:15:5d:xx:xx:xx  Microsoft Corporation        ports: 3389
10.20.30.40     3c:d9:2b:xx:xx:xx  Hewlett Packard              ports: 80;443;9100
10.20.30.55     24:a4:3c:xx:xx:xx  Ubiquiti Inc                 ports: 22;443
10.20.30.77     b8:ca:3a:xx:xx:xx  Dell Inc.                    ports: none
Wrote 6 row(s) to site-b.csv

The script prints full MAC addresses; the last three octets are masked above for publication. The CSV carries the detail the console line leaves out, for example 22:SSH-2.0-OpenSSH_9.6 in the banners column, a Server: header from port 80, and the reverse DNS name when one exists.

Script

python
#!/usr/bin/env python3
"""fingerprint_subnet.py

ARP-sweeps a local subnet to find live hosts, resolves each MAC address's OUI to a
vendor name (from the IEEE oui.txt registry if supplied, otherwise a small built-in
table), probes a set of common TCP ports, and grabs a short banner from services that
offer one (SSH identification strings, HTTP Server headers, SMTP/FTP greetings).
Writes one row per discovered host to a CSV report.

Reads: command-line arguments; optionally the IEEE oui.txt file named by --oui-file.
Writes: the CSV report named by --output.
Requires: Python 3.10+, scapy, root (or Npcap + admin on Windows).
"""

import argparse
import csv
import ipaddress
import re
import socket
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone

from scapy.all import ARP, Ether, srp

# Fallback vendor table, checked against the IEEE MA-L registry. Use --oui-file for real work.
OUI_VENDORS = {
    "00:50:56": "VMware, Inc.",
    "00:0C:29": "VMware, Inc.",
    "00:05:69": "VMware, Inc.",
    "00:15:5D": "Microsoft Corporation",
    "00:1A:A0": "Dell Inc.",
    "B8:CA:3A": "Dell Inc.",
    "00:23:04": "Cisco Systems, Inc",
    "00:1D:A1": "Cisco Systems, Inc",
    "3C:D9:2B": "Hewlett Packard",
    "70:10:6F": "Hewlett Packard Enterprise",
    "24:A4:3C": "Ubiquiti Inc",
    "3C:22:FB": "Apple, Inc.",
}

COMMON_PORTS = [22, 23, 25, 80, 443, 445, 3389, 8080, 8443, 9100]
HTTP_PORTS = {80, 8080}
BASE16_LINE = re.compile(r"^([0-9A-F]{6})\s+\(base 16\)\s+(.+)$")


def load_oui_file(path):
    """Parse the IEEE oui.txt format: '286FB9     (base 16)\t\tVendor Name'."""
    vendors = {}
    with open(path, encoding="utf-8", errors="replace") as handle:
        for line in handle:
            match = BASE16_LINE.match(line.strip())
            if match:
                hex_prefix, name = match.groups()
                key = ":".join(hex_prefix[i:i + 2] for i in range(0, 6, 2))
                vendors[key] = name.strip()
    return vendors


def guess_vendor(mac_address, vendors):
    prefix = mac_address.upper()[0:8]
    if int(prefix[0:2], 16) & 0x02:
        # Locally administered bit set: randomized/private MAC, no registered vendor.
        return "Locally administered (random MAC)"
    return vendors.get(prefix, "Unknown")


def arp_sweep(subnet, timeout):
    """Broadcast an ARP who-has for every address in subnet; return (ip, mac) replies."""
    packet = Ether(dst="ff:ff:ff:ff:ff:ff") / ARP(pdst=subnet)
    answered, _ = srp(packet, timeout=timeout, verbose=False)
    return sorted({(received.psrc, received.hwsrc) for _, received in answered},
                  key=lambda pair: ipaddress.ip_address(pair[0]))


def probe_port(ip_address, port, timeout):
    """Return (is_open, banner). Sends a HEAD request on HTTP ports, otherwise just listens."""
    try:
        with socket.create_connection((ip_address, port), timeout=timeout) as sock:
            sock.settimeout(timeout)
            if port in HTTP_PORTS:
                sock.sendall(f"HEAD / HTTP/1.0\r\nHost: {ip_address}\r\n\r\n".encode("ascii"))
            raw = b""
            try:
                # TCP may split the greeting; read until the first line ends (or 512 bytes).
                while b"\n" not in raw and len(raw) < 512:
                    chunk = sock.recv(512 - len(raw))
                    if not chunk:
                        break
                    raw += chunk
            except (socket.timeout, OSError):
                if not raw:
                    return True, ""
            data = raw.decode("utf-8", errors="replace")
            lines = data.splitlines()
            if port in HTTP_PORTS:
                server = [line for line in lines if line.lower().startswith("server:")]
                if server:
                    return True, server[0].strip()
            return True, lines[0].strip() if lines else ""
    except OSError:
        return False, ""


def reverse_dns(ip_address):
    try:
        return socket.gethostbyaddr(ip_address)[0]
    except OSError:
        return ""


def fingerprint(host, ports, timeout, vendors):
    ip_address, mac_address = host
    open_ports = []
    for port in ports:
        is_open, banner = probe_port(ip_address, port, timeout)
        if is_open:
            open_ports.append((port, banner))
    return {
        "ip_address": ip_address,
        "mac_address": mac_address.lower(),
        "vendor": guess_vendor(mac_address, vendors),
        "reverse_dns": reverse_dns(ip_address),
        "open_ports": ";".join(str(port) for port, _ in open_ports),
        "banners": " | ".join(f"{port}:{banner}" for port, banner in open_ports if banner),
        "scanned_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
    }


def main():
    parser = argparse.ArgumentParser(description="Fingerprint every live device on a local subnet.")
    parser.add_argument("--subnet", required=True, help="Subnet in CIDR notation, e.g. 192.168.10.0/24")
    parser.add_argument("--ports", default=",".join(str(p) for p in COMMON_PORTS),
                        help="Comma-separated TCP ports to probe")
    parser.add_argument("--timeout", type=float, default=1.0, help="ARP and per-port timeout in seconds")
    parser.add_argument("--workers", type=int, default=32, help="Hosts probed in parallel")
    parser.add_argument("--oui-file", help="Path to the IEEE oui.txt registry for vendor lookups")
    parser.add_argument("--output", required=True, help="Path to write the CSV report")
    args = parser.parse_args()

    network = ipaddress.ip_network(args.subnet, strict=False)
    if network.version != 4 or network.prefixlen < 16:
        parser.error("ARP sweeps are IPv4 only; use a /16 or smaller.")

    try:
        ports = [int(p) for p in args.ports.split(",") if p.strip()]
    except ValueError:
        parser.error("--ports must be a comma-separated list of integers.")
    if not ports or any(not 1 <= port <= 65535 for port in ports):
        parser.error("--ports values must be between 1 and 65535.")
    vendors = load_oui_file(args.oui_file) if args.oui_file else OUI_VENDORS

    print(f"ARP-sweeping {network}...")
    hosts = arp_sweep(str(network), args.timeout)
    print(f"{len(hosts)} host(s) responded.")

    with ThreadPoolExecutor(max_workers=args.workers) as pool:
        rows = list(pool.map(lambda h: fingerprint(h, ports, args.timeout, vendors), hosts))

    for row in rows:
        print(f"{row['ip_address']:15s} {row['mac_address']}  {row['vendor'][:28]:28s} "
              f"ports: {row['open_ports'] or 'none'}")

    fieldnames = ["ip_address", "mac_address", "vendor", "reverse_dns", "open_ports", "banners", "scanned_at"]
    with open(args.output, "w", newline="", encoding="utf-8") as csv_file:
        writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(rows)
    print(f"Wrote {len(rows)} row(s) to {args.output}")


if __name__ == "__main__":
    main()

Notes

  • Corrections from the first draft. The original hand-written OUI table mapped 70:10:6F to Cisco and B0:7B:25 to Ubiquiti; the IEEE registry assigns them to Hewlett Packard Enterprise and Dell respectively. The fallback table above has been re-checked, but the lesson stands: load the registry file with --oui-file rather than trusting a hand-maintained list.
  • Random MACs. Phones and many laptops can use randomized, locally administered MAC addresses on Wi-Fi. The universal/local bit (the second-least-significant bit of the first octet) marks those, so the script labels them instead of reporting "Unknown".
  • What the banners are. SSH servers must send an identification string (SSH-2.0-...) as soon as the connection opens, per RFC 4253, so port 22 almost always yields the server software. SMTP and FTP greet first too. HTTP says nothing until asked, so the script sends a HEAD request on ports 80 and 8080 and keeps the Server: header. TLS ports (443, 8443) and RDP stay silent without a protocol handshake; for those, nmap -sV is the right tool.
  • Empty rows are signal. A device that answers ARP and nothing on the probed ports still gets a row with empty open_ports: a locked-down IoT device, a printer with its web UI disabled, or a workstation with a host firewall.
  • ARP timing. A single broadcast pass can miss hosts that are slow to answer or sleeping. If counts vary between runs, raise --timeout or run it twice and merge.
  • Point in time. Keep the CSVs and diff them, or feed the rows into whatever inventory already tracks the rest of your assets. The discovery-first essay shows how this file merges with DHCP, DNS and AD exports keyed on MAC and IP.

References