Replace nmap ARP scan with passive /proc/net/arp read — no active probing (#449)

- Removed get_local_subnets() and scan_subnet() — no longer needed
- Added read_arp_cache() to read kernel ARP table from /proc/net/arp
- Added fallback to 'ip neigh show' if /proc/net/arp unavailable
- Added lookup_oui() to query system /usr/share/hwdata/oui.txt for vendor names
- Removed subprocess nmap call and re import (no longer needed)
- run() now directly reads ARP cache instead of scanning subnets
- Zero active network traffic — purely passive monitoring
- Removed sudo requirement
This commit is contained in:
Cobra
2026-04-08 21:01:12 -04:00
parent 57d70feb20
commit 402c4e849b
+99 -58
View File
@@ -6,7 +6,6 @@ Baseline on first run (silent). Alerts on join and rejoin.
import json
import os
import re
import socket
import sqlite3
import subprocess
@@ -140,75 +139,117 @@ def send_matrix_alert(msg: str) -> None:
print(f"[error] Matrix send failed with HTTP {code}", file=sys.stderr)
def get_local_subnets() -> list[str]:
try:
out = subprocess.check_output(["ip", "-o", "-4", "addr", "show"], text=True)
except Exception:
return []
subnets = []
for line in out.splitlines():
parts = line.split()
if len(parts) < 4:
continue
iface = parts[1]
cidr = parts[3]
if any(iface.startswith(p) for p in ("lo", "tailscale", "wg", "tun", "docker", "br-")):
continue
subnets.append(cidr)
return subnets
def scan_subnet(cidr: str) -> list[dict]:
try:
out = subprocess.check_output(
["sudo", "nmap", "-sn", "-PR", "--host-timeout", "5s", cidr],
text=True, stderr=subprocess.DEVNULL
)
except Exception:
return []
def read_arp_cache() -> list[dict]:
"""
Read kernel ARP cache from /proc/net/arp (passive, no active probing).
Falls back to 'ip neigh show' if /proc/net/arp unavailable.
OUI vendor lookup from system hwdata if available.
Returns list of dicts: {"ip": str, "mac": str, "name": str}.
"""
devices = []
current: dict = {}
for line in out.splitlines():
if line.startswith("Nmap scan report for"):
if current.get("mac"):
devices.append(current)
m = re.search(r"for (.+?)(?:\s+\((.+?)\))?$", line)
if m:
if m.group(2):
current = {"name": m.group(1), "ip": m.group(2), "mac": ""}
else:
current = {"name": "", "ip": m.group(1), "mac": ""}
elif "MAC Address:" in line:
m = re.search(r"MAC Address: ([0-9A-F:]{17})(?:\s+\((.+?)\))?", line)
if m:
current["mac"] = m.group(1).lower()
if m.group(2) and m.group(2) != "Unknown":
current["vendor"] = m.group(2)
if current.get("mac"):
devices.append(current)
# Try /proc/net/arp first
proc_path = Path("/proc/net/arp")
if proc_path.exists():
try:
lines = proc_path.read_text().splitlines()
for line in lines:
if line.startswith("IP"): # Skip header
continue
fields = line.split()
if len(fields) < 4:
continue
ip = fields[0]
hw_addr = fields[3]
flags = fields[2]
# Skip incomplete entries
if hw_addr == "00:00:00:00:00:00":
continue
if flags == "0x0":
continue
device = {"ip": ip, "mac": hw_addr.lower(), "name": ""}
# Try OUI vendor lookup from system hwdata
try:
vendor = lookup_oui(hw_addr)
if vendor:
device["vendor"] = vendor
except Exception:
pass
devices.append(device)
return devices
except Exception:
pass
# Fallback to 'ip neigh show'
try:
out = subprocess.check_output(["ip", "neigh", "show"], text=True)
for line in out.splitlines():
fields = line.split()
if len(fields) < 5:
continue
# Format: <IP> dev <IFACE> lladdr <MAC> ...
ip = fields[0]
try:
lladdr_idx = fields.index("lladdr")
if lladdr_idx + 1 < len(fields):
mac = fields[lladdr_idx + 1].lower()
device = {"ip": ip, "mac": mac, "name": ""}
try:
vendor = lookup_oui(mac)
if vendor:
device["vendor"] = vendor
except Exception:
pass
devices.append(device)
except ValueError:
continue
except Exception:
pass
return devices
def lookup_oui(mac: str) -> str:
"""
Look up OUI vendor from MAC address (first 3 octets).
Searches /usr/share/hwdata/oui.txt or /usr/share/misc/oui.txt if available.
Returns vendor name or empty string if not found.
"""
if not mac or mac == "00:00:00:00:00:00":
return ""
oui_prefix = mac[:8].replace(":", "").upper()
# Check both possible OUI database locations
for oui_path in ["/usr/share/hwdata/oui.txt", "/usr/share/misc/oui.txt"]:
try:
lines = Path(oui_path).read_text().splitlines()
for line in lines:
if line.startswith(oui_prefix):
# Format: XXXXXX (hex) (vendor name)
parts = line.split(maxsplit=1)
if len(parts) > 1:
return parts[1].strip()
except Exception:
continue
return ""
def run() -> None:
conn = get_db()
ts = now_utc()
hostname = socket.gethostname()
subnets = get_local_subnets()
if not subnets:
print("[warn] No local subnets found", file=sys.stderr)
conn.close()
return
# Collect all MACs visible right now across all subnets
# Read kernel ARP cache (passive, no active probing)
arp_devices = read_arp_cache()
scan_results: dict[str, dict] = {}
for cidr in subnets:
for d in scan_subnet(cidr):
scan_results[d["mac"]] = d
for d in arp_devices:
scan_results[d["mac"]] = d
# Is this the first run? (empty DB = baseline mode, no alerts)
row_count = conn.execute("SELECT COUNT(*) FROM devices").fetchone()[0]