Extract the 802.11 events that explain client drops: deauth/disassoc reason codes (per client), auth/assoc failure status codes, EAPOL 4-way handshake volume, retry rate, and SSIDs. tshark gives reason/status as hex.
(path, total_wlan_frames)
| 1536 | # proxy-NDP can also explain it). True NDP spoofing is ndpwatch's job, not this. |
| 1537 | v6_bindings, eui64_mismatches, seen6 = [], [], {} |
| 1538 | for ip6, mac6, _state in _neigh6_entries(): |
| 1539 | m6 = _mac_norm(mac6) |
| 1540 | if not m6 or m6 in local: |
| 1541 | continue |
| 1542 | seen6.setdefault(m6, set()).add(ip6) |
| 1543 | emb = _eui64_embedded_mac(ip6) |
| 1544 | if emb and emb != m6 and _classify_mac(emb)['klass'] != 'invalid': |
| 1545 | eui64_mismatches.append({'ip': ip6, 'mac': m6, 'embedded_mac': emb}) |
| 1546 | for mac6, ips6 in sorted(seen6.items()): |
| 1547 | info6 = _classify_mac(mac6) |
| 1548 | v6_bindings.append({'mac': mac6, 'ips': sorted(ips6), |
| 1549 | 'prefixes': sorted({_v6_prefix64(i) for i in ips6}), |
| 1550 | 'klass': info6['klass'], 'vendor': info6['vendor']}) |
| 1551 | for e in eui64_mismatches[:8]: |
| 1552 | reasons.append( |
| 1553 | f"IPv6 {e['ip']} is a SLAAC EUI-64 address embedding MAC " |
| 1554 | f"{e['embedded_mac']}, but the NDP cache binds it to {e['mac']} — a " |
| 1555 | f"MAC-identity contradiction (spoof; proxy-NDP can also explain it)") |
| 1556 | |
| 1557 | if spoofed or clones or hi_events or fhrp_hijacks: |
| 1558 | verdict = 'spoofed' |
| 1559 | elif tracks or randomized or fhrp_virtuals: |
| 1560 | verdict = 'suspicious' if tracks else ('fhrp' if fhrp_virtuals and not randomized |
| 1561 | else 'randomization') |
| 1562 | if eui64_mismatches and verdict in ('clean', 'randomization', 'fhrp'): |
| 1563 | verdict = 'suspicious' |
| 1564 | |
| 1565 | return { |
| 1566 | 'success': True, 'verdict': verdict, |
| 1567 | 'summary': { |
| 1568 | 'observed': len(current), |
| 1569 | 'spoofed': len(spoofed), |
| 1570 | 'clones': len(clones), |
| 1571 | 'past_events': len(hi_events), |
| 1572 | 'randomized': len(randomized), |
| 1573 | 'virtual': len(virtual), |
| 1574 | 'tracks': len(tracks), |
| 1575 | 'fhrp_virtual': len(fhrp_virtuals), |
| 1576 | 'fhrp_hijacks': len(fhrp_hijacks), |
| 1577 | 'ipv6_bindings': len(v6_bindings), |
| 1578 | 'eui64_mismatch': len(eui64_mismatches), |
| 1579 | }, |
| 1580 | 'spoofed': spoofed, |
| 1581 | 'clones': clones, |
| 1582 | 'fhrp': fhrp, |
| 1583 | 'ipv6': {'bindings': v6_bindings, 'eui64_mismatches': eui64_mismatches}, |
| 1584 | # Every MAC seen this pass, worst class first, so the UI can list them. |
| 1585 | 'observed_macs': sorted( |
| 1586 | current, |
| 1587 | key=lambda c: ({'spoofed_vendor_oui': 0, 'universal': 1, |
| 1588 | 'randomized': 2, 'virtual_laa': 3}.get(c['klass'], 4), |
| 1589 | c['ips'][0] if c['ips'] else '', c['mac'])), |
| 1590 | 'events': [dict(e, ago=_fmt_ago(now - e['ts'])) for e in recent_events[-20:]][::-1], |
| 1591 | 'randomization': randomization, |
| 1592 | 'tracks': tracks, |
| 1593 | 'gateway': {'ip': gw, 'mac': gw_mac}, |
| 1594 | 'interface': scan_iface, |
| 1595 | 'scanned': scanned, |
no test coverage detected