Best-effort identity of the network Ragnar is attached to: DNS search domain(s), nameservers, this host's name/FQDN, and the default gateway (with reverse-DNS). No single source is authoritative, so several are merged and the provenance is reported.
()
| 834 | baseline = _arp_baseline_load() |
| 835 | gws = baseline.setdefault('gateways', {}) |
| 836 | base_mac = gws.get(gw) |
| 837 | if gw_mac and not base_mac and learn: |
| 838 | gws[gw] = gw_mac |
| 839 | _arp_baseline_save(baseline) |
| 840 | base_mac = gw_mac |
| 841 | learned = True |
| 842 | |
| 843 | if not gw_mac: |
| 844 | verdict = 'unknown' |
| 845 | reasons.append(f'could not resolve the gateway {gw} MAC (no ARP reply)') |
| 846 | elif base_mac and gw_mac != base_mac: |
| 847 | verdict = 'spoofed' |
| 848 | reasons.append(f'gateway {gw} MAC changed from trusted {base_mac} to {gw_mac} ' |
| 849 | '— classic ARP-spoofing / MITM signature') |
| 850 | |
| 851 | # One MAC claiming many IPs (attacker impersonating multiple hosts). The |
| 852 | # gateway MAC is excluded — a router legitimately fronts its own address. |
| 853 | entries = _neigh_entries(iface) |
| 854 | mac_ips = {} |
| 855 | for ip, mac, _ in entries: |
| 856 | mac_ips.setdefault(mac, set()).add(ip) |
| 857 | impersonators = [{'mac': mac, 'ips': sorted(ips)} |
| 858 | for mac, ips in mac_ips.items() |
| 859 | if len(ips) >= _ARP_IMPERSONATOR_MIN_IPS and mac != gw_mac] |
| 860 | if impersonators: |
| 861 | if verdict == 'clean': |
| 862 | verdict = 'suspicious' |
| 863 | for imp in impersonators: |
| 864 | ips = imp['ips'] |
| 865 | reasons.append(f"MAC {imp['mac']} answers for {len(ips)} IPs " |
| 866 | f"({', '.join(ips[:4])}{'…' if len(ips) > 4 else ''}) " |
| 867 | '— possible ARP spoofing') |
| 868 | |
| 869 | return {'success': True, 'verdict': verdict, 'interface': iface, |
| 870 | 'gateway': {'ip': gw, 'mac': gw_mac, 'baseline': base_mac, |
| 871 | 'learned': learned}, |
| 872 | 'impersonators': impersonators, |
| 873 | 'neighbor_count': len(entries), |
| 874 | 'reasons': reasons} |
| 875 | |
| 876 | |
| 877 | # -------------------------------------------------------------------------- |
| 878 | # MAC Watch — detection-only MAC-spoofing + randomization detector & tracker. |
| 879 | # |
| 880 | # Three jobs, all passive (reads the kernel neighbour table, optionally an |
| 881 | # arp-scan sweep — no capture, and it never spoofs anything itself): |
| 882 | # 1. Spoofing / cloning: a vendor OUI wearing the locally-administered bit |
| 883 | # (disguised vendor), the same MAC bound to several IPs (a clone), and an |
| 884 | # IP whose MAC changed identity over time (the past-spoofing signature). |
| 885 | # 2. Randomization: privacy (locally-administered, vendor-less) MACs on the |
| 886 | # segment, reported as an aggregate inventory rather than per-MAC noise so |
| 887 | # a busy Wi-Fi segment full of iPhones doesn't drown a real spoof. |
| 888 | # 3. Tracking: an IP that cycles through several randomized MACs over time is |
| 889 | # one device rotating to hide — grouped into a track so it can be followed |
| 890 | # across the addresses it hides behind. |
| 891 | # |
| 892 | # A small JSON store keeps first/last-seen, per-IP MAC history and change |
| 893 | # events, so "current AND past" spoofing/rotation survives across checks and |
no test coverage detected