#!/usr/bin/env python3 """Annotate cpes.txt with SNMP-discovered device models. Queries each IP via SNMPv1 (community kdyyJrT0Mm) for: - sysDescr / sysObjectID / product code / firmware version - Falls back to MAC OUI for devices that don't respond to SNMP Output: clean table with MAC, device type, account info. Usage: python3 scripts/annotate_cpes.py [cpes.txt] > annotated.txt """ from __future__ import annotations import argparse import os import re import subprocess import sys from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Any SNMP_COMMUNITY = "kdyyJrT0Mm" # Ubiquiti product codes from 17713.21.1.1.2.0 → model name _PRODUCT_MAP: dict[int, str] = { 8: "NanoStation M5 / NS loco M5", 35: "NanoBeam M5 / LiteBeam M5", 55: "PowerBeam M5 / Loco M5", } # sysObjectID suffix → model hint _SYSOID_MAP: dict[str, str] = { "17713.21.9.35": "AirOS 5.x (NanoBeam/LiteBeam)", "17713.21.9.55": "AirOS 5.x (PowerBeam/Loco)", "10002.1": "AirOS (legacy MIB)", "41112.1.4": "airOS 8.x", "netSnmpAgentOIDs.10": "NET-SNMP agent (likely AirOS)", } def parse_cpes(path: str) -> list[dict[str, str]]: entries: list[dict[str, str]] = [] pending: dict[str, str] | None = None with open(path) as f: for line in f: line = line.rstrip() # Account detail line (indented, comes after the IP/MAC line) m_acct = re.search(r"account=#(\d+)", line) if m_acct and pending: pending["readable"] = m_acct.group(1) acct_m = re.search(r"name='([^']+)'", line) pending["name"] = acct_m.group(1) if acct_m else "" entries.append(pending) pending = None continue # IP/MAC line m = re.search(r"ip=(\S+)", line) if not m: continue ip = m.group(1) mac_m = re.search(r"mac=(\S+)", line) mac = mac_m.group(1) if mac_m else "" pending = {"ip": ip, "mac": mac, "name": "", "readable": ""} return entries def snmp_get(ip: str, oid: str, timeout: float = 8) -> str | None: try: p = subprocess.run( ["snmpget", "-v1", "-c", SNMP_COMMUNITY, "-Oqv", ip, oid], capture_output=True, text=True, timeout=timeout, ) if p.returncode == 0: return p.stdout.strip().strip('"') except Exception: pass return None def query_device(entry: dict[str, str]) -> dict[str, str]: ip = entry["ip"] result = {**entry, "model": "unknown"} sys_oid = snmp_get(ip, "1.3.6.1.2.1.1.2.0") sys_descr = snmp_get(ip, "1.3.6.1.2.1.1.1.0") prod_code = snmp_get(ip, "1.3.6.1.4.1.17713.21.1.1.2.0") fw = snmp_get(ip, "1.3.6.1.4.1.17713.21.1.1.1.0") snmp_mac = snmp_get(ip, "1.3.6.1.4.1.17713.21.1.1.5.0") # Build model string from available info parts: list[str] = [] # 1. Product code (most specific) if prod_code: try: code = int(prod_code) if code in _PRODUCT_MAP: parts.append(_PRODUCT_MAP[code]) else: parts.append(f"product={code}") except ValueError: parts.append(f"product={prod_code}") # 2. sysObjectID hint if sys_oid and not parts: for key, hint in _SYSOID_MAP.items(): if key in sys_oid: parts.append(hint) break else: parts.append(sys_oid.split("::")[-1] if "::" in sys_oid else sys_oid) # 3. sysDescr (sometimes contains model) if sys_descr and sys_descr not in ("vntx", "unknown"): if not parts or sys_descr != "vntx": if sys_descr not in ("vntx",): pass # sysDescr is usually just "vntx" or the host name, not useful # 4. MAC OUI fallback if not parts: mac = entry["mac"] if mac: oui = mac[:8].upper() parts.append(f"OUI {oui}") else: parts.append("unknown") # 5. Firmware version if fw: parts.append(f"fw={fw}") # 6. SNMP MAC verification if snmp_mac: snmp_mac_norm = re.sub(r"[^0-9a-f]", "", snmp_mac.lower()) entry_mac_norm = re.sub(r"[^0-9a-f]", "", entry["mac"].lower()) if snmp_mac_norm != entry_mac_norm: parts.append(f"(SNMP MAC: {snmp_mac})") result["model"] = " | ".join(parts) if parts else "no SNMP response" if sys_oid: result["sys_oid"] = sys_oid.split("::")[-1] if "::" in sys_oid else sys_oid if prod_code: result["prod_code"] = prod_code if fw: result["fw"] = fw return result def main(): ap = argparse.ArgumentParser(description="Annotate cpes.txt with SNMP device models") ap.add_argument("input", nargs="?", default="cpes.txt", help="path to cpes.txt (default: cpes.txt)") ap.add_argument("-w", "--workers", type=int, default=20, help="parallel SNMP queries (default: 20)") args = ap.parse_args() if not os.path.exists(args.input): sys.exit(f"{args.input} not found") entries = parse_cpes(args.input) print(f"Querying {len(entries)} devices via SNMP...", file=sys.stderr) results: list[dict[str, str]] = [] with ThreadPoolExecutor(max_workers=args.workers) as ex: futs = {ex.submit(query_device, e): e for e in entries} for i, f in enumerate(as_completed(futs), 1): results.append(f.result()) if i % 20 == 0: print(f" {i}/{len(entries)}", file=sys.stderr) print(f" {len(entries)}/{len(entries)} done", file=sys.stderr) # Sort by account name results.sort(key=lambda r: r["name"].lower()) # Output print(f"{'MAC':17} {'Model':50} {'Account':30} IP") print("-" * 120) for r in results: print(f"{r['mac']:17} {r['model']:50} #{r['readable']:5} {r['name']:24} {r['ip']}") if __name__ == "__main__": main()