Address jokob-sk review: dedupe rogue detections, add iw to remaining Dockerfiles

check_trusted_aps() evaluated a rogue AP once per trusted_aps entry sharing
its SSID, so the documented main-AP+extender pattern (same SSID, two
entries) produced duplicate (bssid, motor) rows for a real clone - a
problem once next_release's per-plugin identity-hash dedup guard lands in
main, since it drops a plugin's entire batch on any internal duplicate.
Fixed by deduping once in main() after collecting from all check_*
functions. Also added iw + its setcap to Dockerfile.debian and
.devcontainer/Dockerfile, which the original PR missed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011meLPKCzVpdZyAUfv5U6mm
This commit is contained in:
Mauricio CamayoandClaude Sonnet 5 committed 2026-09-24 20:10:12 -05:00
1 parent f7a0c5861e
commit 41efcbc971
4 files changed
+104 -1

No files matched your search

+2 -1
View File
@@ -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)" && \
+2
View File
@@ -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
+17
View File
@@ -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
+83
View File
@@ -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']))