diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index dfc6e7296..3c0288a09 100755 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -134,7 +134,7 @@ ENV NETALERTX_USER=netalertx NETALERTX_GROUP=netalertx ENV LANG=C.UTF-8 -RUN apk add --no-cache bash mtr libbsd zip lsblk tzdata curl arp-scan iproute2 iproute2-ss nmap fping \ +RUN apk add --no-cache bash mtr libbsd zip lsblk tzdata curl arp-scan iproute2 iproute2-ss iw nmap fping \ nmap-scripts traceroute nbtscan net-tools net-snmp-tools bind-tools awake ca-certificates \ sqlite php83 php83-fpm php83-cgi php83-curl php83-sqlite3 php83-session python3 py3-psutil envsubst \ nginx supercronic shadow su-exec jq && \ @@ -178,6 +178,7 @@ RUN for vfile in .VERSION; do \ apk add --no-cache libcap && \ setcap cap_net_raw,cap_net_admin+eip /usr/bin/nmap && \ setcap cap_net_raw,cap_net_admin+eip /usr/bin/arp-scan && \ + setcap cap_net_raw,cap_net_admin+eip /usr/sbin/iw && \ setcap cap_net_raw,cap_net_admin,cap_net_bind_service+eip /usr/bin/nbtscan && \ setcap cap_net_raw,cap_net_admin+eip /usr/bin/traceroute && \ setcap cap_net_raw,cap_net_admin+eip "$(readlink -f ${VIRTUAL_ENV_BIN}/python)" && \ diff --git a/Dockerfile.debian b/Dockerfile.debian index 0755f4a96..9767cdac7 100755 --- a/Dockerfile.debian +++ b/Dockerfile.debian @@ -120,6 +120,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ net-tools \ python3 \ iproute2 \ + iw \ nmap \ fping \ zip \ @@ -187,6 +188,7 @@ RUN for vfile in .VERSION .VERSION_PREV; do \ # Set capabilities for raw socket access setcap cap_net_raw,cap_net_admin+eip /usr/bin/nmap && \ setcap cap_net_raw,cap_net_admin+eip /usr/sbin/arp-scan && \ + setcap cap_net_raw,cap_net_admin+eip /usr/sbin/iw && \ setcap cap_net_raw,cap_net_admin,cap_net_bind_service+eip /usr/bin/nbtscan && \ setcap cap_net_raw,cap_net_admin+eip /usr/bin/traceroute.db && \ # Note: python path needs to be dynamic or verificed diff --git a/server/plugins/wificanary/script.py b/server/plugins/wificanary/script.py index 1c2544026..59cb9f357 100644 --- a/server/plugins/wificanary/script.py +++ b/server/plugins/wificanary/script.py @@ -72,6 +72,7 @@ def main(): detections += check_global_signatures(aps) detections += check_trusted_aps(aps, trusted_aps) detections += check_duplicate_ssid(aps, trusted_aps) + detections = dedupe_detections(detections) escalate_known_devices(detections) for det in detections: @@ -352,6 +353,22 @@ def check_duplicate_ssid(aps, trusted_aps): return found +def dedupe_detections(detections): + """Collapse detections sharing the same (bssid, motor) identity - + check_trusted_aps() can otherwise flag one rogue clone once per + WIFICANARY_trusted_aps entry sharing its SSID (e.g. a main AP + range + extender pair). Keeps the first occurrence of each identity.""" + seen = set() + deduped = [] + for det in detections: + key = (det['bssid'], det['motor']) + if key in seen: + continue + seen.add(key) + deduped.append(det) + return deduped + + def escalate_known_devices(detections): """Motor 10 (see the addendum on issue #1789): a BSSID this plugin just flagged might not be a stranger's radio at all - it might be a device diff --git a/test/plugins/test_wificanary.py b/test/plugins/test_wificanary.py index 13c526635..e47a4c517 100644 --- a/test/plugins/test_wificanary.py +++ b/test/plugins/test_wificanary.py @@ -488,6 +488,51 @@ def test_duplicate_ssid_flags_oui_not_among_multiple_trusted(): assert found[0]['bssid'] == '11:22:33:44:55:66' +# --------------------------------------------------------------------------- +# dedupe_detections() +# --------------------------------------------------------------------------- + +def test_dedupe_detections_collapses_same_bssid_and_motor(): + detections = [ + _detection(bssid='aa:bb:cc:11:22:33', motor='evil_twin'), + _detection(bssid='aa:bb:cc:11:22:33', motor='evil_twin'), + _detection(bssid='aa:bb:cc:11:22:33', motor='duplicate_ssid_diff_vendor'), + ] + deduped = wificanary.dedupe_detections(detections) + assert len(deduped) == 2 + assert {d['motor'] for d in deduped} == {'evil_twin', 'duplicate_ssid_diff_vendor'} + + +def test_dedupe_detections_keeps_distinct_bssids(): + detections = [ + _detection(bssid='aa:bb:cc:11:22:33', motor='evil_twin'), + _detection(bssid='ff:ee:dd:99:88:77', motor='evil_twin'), + ] + assert wificanary.dedupe_detections(detections) == detections + + +def test_check_trusted_aps_flags_rogue_clone_twice_when_two_entries_share_ssid(): + # Reproduces the real gap jokob-sk found on PR #1809: a rogue AP cloning + # a protected SSID gets evaluated once per WIFICANARY_trusted_aps entry + # sharing that SSID - including the plugin's own documented range- + # extender pattern (main AP + extender, same SSID, each its own entry). + # check_trusted_aps() alone still produces the duplicate - dedupe_detections() + # is what main() uses to collapse it, tested at the main() level below. + trusted = [ + {'ssid': 'HomeWiFi', 'bssid': 'aa:bb:cc:11:22:33', 'security_set': {'wpa3'}}, + {'ssid': 'HomeWiFi', 'bssid': '44:55:66:aa:bb:cc', 'security_set': {'wpa2'}}, + ] + aps = [ + _ap('aa:bb:cc:11:22:33', 'HomeWiFi', 'wpa3'), + _ap('44:55:66:aa:bb:cc', 'HomeWiFi', 'wpa2'), + _ap('ff:ee:dd:99:88:77', 'HomeWiFi', 'open'), + ] + found = wificanary.check_trusted_aps(aps, trusted) + rogue_hits = [d for d in found if d['bssid'] == 'ff:ee:dd:99:88:77'] + assert len(rogue_hits) == 2 + assert {d['motor'] for d in rogue_hits} == {'evil_twin'} + + # --------------------------------------------------------------------------- # parse_security_set() # --------------------------------------------------------------------------- @@ -670,5 +715,43 @@ def test_main_end_to_end_one_detection(): wificanary.plugin_objects.write_result_file.assert_called_once() +def test_main_dedupes_rogue_clone_across_two_trusted_entries_sharing_ssid(): + # Regression for jokob-sk's PR #1809 review: a main AP + range extender + # (same SSID, each its own trusted_aps entry - the plugin's own + # documented pattern) must not turn one rogue clone into two identical + # (bssid, motor) rows - that pair is the plugin_objects identity NetAlertX + # core's own dedup guard hashes per run, so a real duplicate here would + # get the whole run's batch silently dropped once that guard lands. + settings = { + 'WIFICANARY_IFACE': 'wlan0', + 'WIFICANARY_RUN_TIMEOUT': 60, + 'WIFICANARY_trusted_aps': [ + _encode_trusted_entry('HomeWiFi', 'aa:bb:cc:11:22:33', ('wpa3',)), + _encode_trusted_entry('HomeWiFi', '44:55:66:aa:bb:cc', ('wpa2',)), + ], + } + aps = [ + _ap('aa:bb:cc:11:22:33', 'HomeWiFi', 'wpa3'), + _ap('44:55:66:aa:bb:cc', 'HomeWiFi', 'wpa2'), + _ap('ff:ee:dd:99:88:77', 'HomeWiFi', 'open'), + ] + + with patch.object(wificanary, 'get_setting_value', side_effect=lambda k: settings.get(k)): + with patch.object(wificanary, 'scan', return_value=aps): + with patch.object(wificanary, 'DeviceInstance') as MockDeviceInstance: + MockDeviceInstance.return_value.getAllByMacs.return_value = {} + wificanary.plugin_objects.add_object = MagicMock() + wificanary.plugin_objects.write_result_file = MagicMock() + wificanary.main() + + # The rogue AP legitimately trips two distinct motors (evil-twin clone AND + # duplicate-SSID/different-vendor, same as test_main_end_to_end_one_detection) + # - dedupe_detections() must not collapse those, only a repeated identity. + identities = [(c.kwargs['primaryId'], c.kwargs['secondaryId']) + for c in wificanary.plugin_objects.add_object.call_args_list] + assert len(identities) == len(set(identities)), f"duplicate (bssid, motor) row: {identities}" + assert identities.count(('ff:ee:dd:99:88:77', 'evil_twin')) == 1 + + if __name__ == '__main__': sys.exit(pytest.main([__file__, '-v']))