network/scripts/annotate_cpes.py
2026-07-19 14:42:02 -05:00

186 lines
5.9 KiB
Python
Executable file

#!/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()