Physically locate which switch port this device is plugged into by flapping the link in a timed pattern (link LED blinks). Managed switches already report the port via LLDP/CDP (Switch Discovery) -- this is the fallback for unmanaged switches. Runs in the background and returns at once.
(interface, count=6, on_ms=800, off_ms=800, force=False)
| 1332 | for e in hi_events[-8:]: |
| 1333 | reasons.append(f"IP {e['ip']} changed MAC {e['old_mac']} → {e['new_mac']} " |
| 1334 | f"({_fmt_ago(now - e['ts'])}) — possible spoof/clone in the past") |
| 1335 | |
| 1336 | # (4) Randomization inventory (aggregate, not per-MAC). |
| 1337 | randomized = [c for c in current if c['klass'] == 'randomized'] |
| 1338 | virtual = [c for c in current if c['klass'] == 'virtual_laa'] |
| 1339 | with _mac_watch_lock: |
| 1340 | macs_store = (_mac_watch_load().get('macs') or {}) |
| 1341 | ephemeral = 0 |
| 1342 | for mac, rec in macs_store.items(): |
| 1343 | if rec.get('klass') != 'randomized': |
| 1344 | continue |
| 1345 | life = rec.get('last', 0) - rec.get('first', 0) |
| 1346 | if 0 <= life < _MAC_EPHEMERAL_S and rec.get('count', 0) >= 1: |
| 1347 | ephemeral += 1 |
| 1348 | randomization = { |
| 1349 | 'count': len(randomized), |
| 1350 | 'ephemeral': ephemeral, |
| 1351 | 'virtual': len(virtual), |
| 1352 | 'macs': [c['mac'] for c in randomized], |
| 1353 | 'virtual_macs': [{'mac': c['mac'], 'vendor': c['vendor']} for c in virtual], |
| 1354 | } |
| 1355 | if randomized: |
| 1356 | note = (f"{len(randomized)} randomized (privacy) MAC(s) on the segment" |
| 1357 | + (f", {ephemeral} short-lived (active rotation)" if ephemeral else "")) |
| 1358 | reasons.append(note) |
| 1359 | |
| 1360 | # (5) Tracking — an IP that cycled through >=2 randomized MACs is one device |
| 1361 | # rotating to hide. Group its randomized addresses into a followable track. |
| 1362 | tracks = [] |
| 1363 | with _mac_watch_lock: |
| 1364 | ip_hist = (_mac_watch_load().get('ip_history') or {}) |
| 1365 | for ip, hist in ip_hist.items(): |
| 1366 | rand_macs, first, last = [], None, None |
| 1367 | for h in hist: |
| 1368 | if _classify_mac(h['mac'])['klass'] == 'randomized': |
| 1369 | rand_macs.append(h['mac']) |
| 1370 | first = h['first'] if first is None else min(first, h['first']) |
| 1371 | last = h['last'] if last is None else max(last, h['last']) |
| 1372 | uniq = sorted(set(rand_macs)) |
| 1373 | if len(uniq) >= 2 and last and now - last <= _MAC_WATCH_WINDOW_S: |
| 1374 | tracks.append({'ip': ip, 'macs': uniq, 'changes': len(uniq) - 1, |
| 1375 | 'first': first, 'last': last, |
| 1376 | 'span': _fmt_ago(now - first) if first else None}) |
| 1377 | tracks.sort(key=lambda t: len(t['macs']), reverse=True) |
| 1378 | for t in tracks[:6]: |
| 1379 | reasons.append(f"device at {t['ip']} rotated through {len(t['macs'])} " |
| 1380 | f"randomized MACs — tracked across its address changes") |
| 1381 |
no test coverage detected