mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-10-02 10:45:05 -04:00
Add WIFICANARY plugin - passive WiFi rogue-AP detection
Periodic iw-scan-based detection of the 6 heuristics that don't need monitor-mode hardware (see issue #1789): pwnagotchi/Pineapple signatures, evil-twin/open clones, baseline-AP-absent-with-clone, security downgrades, and duplicate-SSID/different-vendor - all evaluated against a user-curated trusted-AP baseline (WIFICANARY_trusted_aps). A detection creates a flagged Devices entry even for BSSIDs that never associate, per the addendum on the same issue. - WIFICANARY_TRUSTED_SECURITY is multi-select: an observed encryption exactly matching any selected value is accepted; otherwise it's flagged if weaker than the strongest selected value (deliberate - comparing against the weakest would make multi-select pointless, since anything at/above the weakest would silently pass regardless of the rest of the selection). - Added a "known device turned rogue" motor: escalate_known_devices() cross-references each detection's BSSID against the Devices table via the new DeviceInstance.getAllByMacs(). This covers the BSSID-identity half of the issue #1789 addendum's motor 10; the deauth/probe-source-MAC half still needs monitor-mode data this plugin doesn't have. - Vendor is deliberately not looked up by this plugin - any device it creates gets devVendor filled in for free by core's own vendor_update plugin on its next pass. 43 wificanary unit tests + 10 DeviceInstance.getAllByMacs() tests, all test_plugin_conventions.py checks pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011meLPKCzVpdZyAUfv5U6mm
This commit is contained in:
1 parent
cd1d0ed11e
commit
d0a3a5416b
7 files changed
+2129
-1
No files matched your search
@@ -0,0 +1,655 @@
|
||||
"""Tests for the wificanary (WIFICANARY) plugin.
|
||||
|
||||
script.py is loaded with its NetAlertX-internal dependencies (plugin_helper,
|
||||
logger, helper, const, conf, pytz, models.device_instance) stubbed out -
|
||||
same approach test_dockerdisc.py uses - so these run without the full
|
||||
devcontainer environment. `normalize_mac`/`decode_settings_base64` are
|
||||
reimplemented locally (same shape as plugin_helper's) to avoid pulling in
|
||||
its own dependency chain. `subprocess.run` is mocked per test rather than
|
||||
actually shelling out to `iw`, and `DeviceInstance` is a MagicMock class -
|
||||
individual tests patch `.getAllByMacs` per case.
|
||||
|
||||
Layout:
|
||||
- parse_iw_scan(): unit tests for turning raw `iw scan` text into AP
|
||||
dicts - SSID, one of open/wep/wpa/wpa2/wpa3, signal, and the derived
|
||||
OUI - across the shapes real output takes (no Privacy bit, Privacy bit
|
||||
with no IE, RSN/PSK, RSN/SAE, WPA-only, multiple BSS entries in one
|
||||
dump, a BSS entry with no SSID at all which should be dropped).
|
||||
- check_global_signatures(): pwnagotchi BSSID and Pineapple OUI-pattern
|
||||
matches, independent of any trusted-AP configuration.
|
||||
- parse_security_set(): decoding WIFICANARY_TRUSTED_SECURITY's JSON-array-
|
||||
string value (how it actually arrives - see the function's own
|
||||
docstring), including the blank/malformed fallback to {'wpa2'}.
|
||||
- check_trusted_aps(): evil-twin/open-clone, baseline-AP-absent variant,
|
||||
security-downgrade (single- and multi-value accepted sets, including a
|
||||
non-contiguous one), wildcard-BSSID trusted entries, an explicitly-
|
||||
accepted `open` entry not tripping evil-twin, and the negative case
|
||||
(scan exactly matches the baseline - no detections).
|
||||
- check_duplicate_ssid(): impostor-OUI detection restricted to SSIDs
|
||||
present in the trusted list, and that the trusted BSSID's own OUI (not
|
||||
just whichever OUI happens to be more common) is what's treated as
|
||||
"expected" when it's present in the scan.
|
||||
- get_trusted_aps(): decoding WIFICANARY_trusted_aps popupForm entries,
|
||||
including a blank BSSID (wildcard) and a blank SSID (skipped - no
|
||||
usable baseline identity).
|
||||
- escalate_known_devices(): the "known device turned rogue" motor - no
|
||||
escalation when the BSSID isn't an existing device, no escalation when
|
||||
the only existing record is one WIFICANARY itself created on a prior
|
||||
run (self-escalation guard), and the motor/reason rewrite when it's a
|
||||
real pre-existing device from another source plugin.
|
||||
- main(): integration test with scan() mocked - covers the no-IFACE
|
||||
early-return and a run that finds one anomaly end to end.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _normalize_mac(mac):
|
||||
"""Same shape as plugin_helper.normalize_mac, without its import chain."""
|
||||
s = str(mac).strip().lower()
|
||||
if ':' in s:
|
||||
parts = s.split(':')
|
||||
elif '-' in s:
|
||||
parts = s.split('-')
|
||||
else:
|
||||
parts = [s[i:i + 2] for i in range(0, len(s), 2)]
|
||||
return ':'.join(p if p == '*' else p.zfill(2) for p in (part.strip() for part in parts))
|
||||
|
||||
|
||||
def _decode_settings_base64(encoded_str):
|
||||
"""Same shape as plugin_helper.decode_settings_base64, without its import chain."""
|
||||
decoded = base64.b64decode(encoded_str).decode('utf-8')
|
||||
settings_list = json.loads(decoded)
|
||||
return {key: value for _, key, _type, value in settings_list}
|
||||
|
||||
|
||||
def _encode_trusted_entry(ssid, bssid='', security=('wpa2',)):
|
||||
"""Builds a base64-encoded popupForm entry matching what NetAlertX would
|
||||
send for one `WIFICANARY_trusted_aps` row. `security` mirrors the real
|
||||
frontend contract for the multi-select array field: encoded as a
|
||||
JSON-array *string* value under an 'array' type tag, not a real list -
|
||||
see parse_security_set()'s docstring."""
|
||||
settings_list = [
|
||||
['trusted_aps', 'WIFICANARY_TRUSTED_SSID', 'string', ssid],
|
||||
['trusted_aps', 'WIFICANARY_TRUSTED_BSSID', 'string', bssid],
|
||||
['trusted_aps', 'WIFICANARY_TRUSTED_SECURITY', 'array', json.dumps(list(security))],
|
||||
]
|
||||
return base64.b64encode(json.dumps(settings_list).encode('utf-8')).decode('ascii')
|
||||
|
||||
|
||||
def _load_wificanary_module():
|
||||
missing_module = object()
|
||||
previous_modules = {}
|
||||
|
||||
def stub(name, **attributes):
|
||||
previous_modules[name] = sys.modules.get(name, missing_module)
|
||||
module = types.ModuleType(name)
|
||||
for attribute, value in attributes.items():
|
||||
setattr(module, attribute, value)
|
||||
sys.modules[name] = module
|
||||
|
||||
stub(
|
||||
'plugin_helper',
|
||||
Plugin_Objects=MagicMock,
|
||||
normalize_mac=_normalize_mac,
|
||||
decode_settings_base64=_decode_settings_base64,
|
||||
)
|
||||
stub('logger', mylog=MagicMock(), Logger=MagicMock())
|
||||
stub('helper', get_setting_value=MagicMock(return_value='UTC'))
|
||||
stub('const', logPath='/tmp')
|
||||
stub('models.device_instance', DeviceInstance=MagicMock)
|
||||
stub('conf', tz=None)
|
||||
stub('pytz', timezone=MagicMock(return_value='UTC'))
|
||||
|
||||
module_path = Path(__file__).resolve().parents[2] / 'server' / 'plugins' / 'wificanary' / 'script.py'
|
||||
spec = importlib.util.spec_from_file_location('wificanary_script', module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
finally:
|
||||
for name, previous_module in previous_modules.items():
|
||||
if previous_module is missing_module:
|
||||
sys.modules.pop(name, None)
|
||||
else:
|
||||
sys.modules[name] = previous_module
|
||||
|
||||
return module
|
||||
|
||||
|
||||
wificanary = _load_wificanary_module()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_iw_scan()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_parse_open_network():
|
||||
output = (
|
||||
'BSS 66:13:37:44:55:66(on wlan0)\n'
|
||||
'\tcapability: ESS ShortSlotTime (0x0401)\n'
|
||||
'\tsignal: -60.00 dBm\n'
|
||||
'\tSSID: FreeWiFi\n'
|
||||
)
|
||||
aps = wificanary.parse_iw_scan(output)
|
||||
assert len(aps) == 1
|
||||
assert aps[0] == {
|
||||
'bssid': '66:13:37:44:55:66',
|
||||
'ssid': 'FreeWiFi',
|
||||
'security': 'open',
|
||||
'signal': '-60.00',
|
||||
'oui': '66:13:37',
|
||||
}
|
||||
|
||||
|
||||
def test_parse_wpa2_psk_network():
|
||||
output = (
|
||||
'BSS aa:bb:cc:11:22:33(on wlan0)\n'
|
||||
'\tcapability: ESS Privacy ShortSlotTime (0x0411)\n'
|
||||
'\tsignal: -45.00 dBm\n'
|
||||
'\tSSID: HomeWiFi\n'
|
||||
'\tRSN:\t * Version: 1\n'
|
||||
'\t\t * Authentication suites: PSK\n'
|
||||
)
|
||||
aps = wificanary.parse_iw_scan(output)
|
||||
assert aps[0]['security'] == 'wpa2'
|
||||
|
||||
|
||||
def test_parse_wpa3_sae_network():
|
||||
output = (
|
||||
'BSS 11:22:33:44:55:66(on wlan0)\n'
|
||||
'\tcapability: ESS Privacy ShortSlotTime (0x0411)\n'
|
||||
'\tsignal: -55.00 dBm\n'
|
||||
'\tSSID: OfficeNet\n'
|
||||
'\tRSN:\t * Version: 1\n'
|
||||
'\t\t * Authentication suites: SAE\n'
|
||||
)
|
||||
aps = wificanary.parse_iw_scan(output)
|
||||
assert aps[0]['security'] == 'wpa3'
|
||||
|
||||
|
||||
def test_parse_wpa1_only_network():
|
||||
output = (
|
||||
'BSS 00:11:22:33:44:55(on wlan0)\n'
|
||||
'\tcapability: ESS Privacy ShortSlotTime (0x0411)\n'
|
||||
'\tsignal: -50.00 dBm\n'
|
||||
'\tSSID: OldNetwork\n'
|
||||
'\tWPA:\t * Version: 1\n'
|
||||
'\t\t * Authentication suites: PSK\n'
|
||||
)
|
||||
aps = wificanary.parse_iw_scan(output)
|
||||
assert aps[0]['security'] == 'wpa'
|
||||
|
||||
|
||||
def test_parse_privacy_bit_no_ie_is_wep():
|
||||
output = (
|
||||
'BSS 00:11:22:aa:bb:cc(on wlan0)\n'
|
||||
'\tcapability: ESS Privacy ShortSlotTime (0x0411)\n'
|
||||
'\tsignal: -65.00 dBm\n'
|
||||
'\tSSID: LegacyNet\n'
|
||||
)
|
||||
aps = wificanary.parse_iw_scan(output)
|
||||
assert aps[0]['security'] == 'wep'
|
||||
|
||||
|
||||
def test_parse_multiple_bss_entries():
|
||||
output = (
|
||||
'BSS aa:bb:cc:11:22:33(on wlan0)\n'
|
||||
'\tcapability: ESS Privacy ShortSlotTime (0x0411)\n'
|
||||
'\tsignal: -45.00 dBm\n'
|
||||
'\tSSID: HomeWiFi\n'
|
||||
'\tRSN:\t * Authentication suites: PSK\n'
|
||||
'BSS 66:13:37:44:55:66(on wlan0)\n'
|
||||
'\tcapability: ESS ShortSlotTime (0x0401)\n'
|
||||
'\tsignal: -60.00 dBm\n'
|
||||
'\tSSID: FreeWiFi\n'
|
||||
)
|
||||
aps = wificanary.parse_iw_scan(output)
|
||||
assert [ap['bssid'] for ap in aps] == ['aa:bb:cc:11:22:33', '66:13:37:44:55:66']
|
||||
|
||||
|
||||
def test_parse_bss_with_no_ssid_is_dropped():
|
||||
output = (
|
||||
'BSS aa:bb:cc:11:22:33(on wlan0)\n'
|
||||
'\tcapability: ESS ShortSlotTime (0x0401)\n'
|
||||
'\tsignal: -45.00 dBm\n'
|
||||
)
|
||||
assert wificanary.parse_iw_scan(output) == []
|
||||
|
||||
|
||||
def test_parse_empty_output():
|
||||
assert wificanary.parse_iw_scan('') == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_global_signatures()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_pwnagotchi_bssid_flagged():
|
||||
aps = [{'bssid': 'de:ad:be:ef:de:ad', 'ssid': 'pwned', 'security': 'open',
|
||||
'signal': '-70.00', 'oui': 'de:ad:be'}]
|
||||
found = wificanary.check_global_signatures(aps)
|
||||
assert len(found) == 1
|
||||
assert found[0]['motor'] == 'pwnagotchi_nearby'
|
||||
|
||||
|
||||
def test_pineapple_oui_flagged():
|
||||
aps = [{'bssid': '66:13:37:44:55:66', 'ssid': 'FreeWiFi', 'security': 'open',
|
||||
'signal': '-60.00', 'oui': '66:13:37'}]
|
||||
found = wificanary.check_global_signatures(aps)
|
||||
assert len(found) == 1
|
||||
assert found[0]['motor'] == 'pineapple_oui'
|
||||
|
||||
|
||||
def test_ordinary_bssid_not_flagged():
|
||||
aps = [{'bssid': 'aa:bb:cc:11:22:33', 'ssid': 'HomeWiFi', 'security': 'wpa2',
|
||||
'signal': '-45.00', 'oui': 'aa:bb:cc'}]
|
||||
assert wificanary.check_global_signatures(aps) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_trusted_aps()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ap(bssid, ssid, security, signal='-50.00'):
|
||||
return {'bssid': bssid, 'ssid': ssid, 'security': security, 'signal': signal,
|
||||
'oui': ':'.join(bssid.split(':')[:3])}
|
||||
|
||||
|
||||
def test_matching_baseline_no_detection():
|
||||
trusted = [{'ssid': 'HomeWiFi', 'bssid': 'aa:bb:cc:11:22:33', 'security_set': {'wpa2'}}]
|
||||
aps = [_ap('aa:bb:cc:11:22:33', 'HomeWiFi', 'wpa2')]
|
||||
assert wificanary.check_trusted_aps(aps, trusted) == []
|
||||
|
||||
|
||||
def test_evil_twin_open_clone_with_baseline_present():
|
||||
trusted = [{'ssid': 'HomeWiFi', 'bssid': 'aa:bb:cc:11:22:33', 'security_set': {'wpa2'}}]
|
||||
aps = [
|
||||
_ap('aa:bb:cc:11:22:33', 'HomeWiFi', 'wpa2'),
|
||||
_ap('ff:ee:dd:99:88:77', 'HomeWiFi', 'open'),
|
||||
]
|
||||
found = wificanary.check_trusted_aps(aps, trusted)
|
||||
assert len(found) == 1
|
||||
assert found[0]['motor'] == 'evil_twin'
|
||||
assert found[0]['bssid'] == 'ff:ee:dd:99:88:77'
|
||||
|
||||
|
||||
def test_absent_baseline_with_clone_present():
|
||||
trusted = [{'ssid': 'HomeWiFi', 'bssid': 'aa:bb:cc:11:22:33', 'security_set': {'wpa2'}}]
|
||||
aps = [_ap('ff:ee:dd:99:88:77', 'HomeWiFi', 'open')]
|
||||
found = wificanary.check_trusted_aps(aps, trusted)
|
||||
assert len(found) == 1
|
||||
assert found[0]['motor'] == 'absent_baseline_clone'
|
||||
|
||||
|
||||
def test_evil_twin_weaker_but_not_open_clone_with_baseline_present():
|
||||
# Regression: a different-BSSID clone using a weaker-than-accepted but
|
||||
# not fully `open` encryption (e.g. plain WPA against an accepted
|
||||
# wpa2/wpa3 set) used to fall through both check_trusted_aps() branches
|
||||
# uncaught - only check_duplicate_ssid's OUI mismatch happened to catch
|
||||
# it, which an attacker spoofing a real vendor OUI would evade entirely.
|
||||
trusted = [{'ssid': 'HomeWiFi', 'bssid': 'aa:bb:cc:11:22:33', 'security_set': {'wpa2', 'wpa3'}}]
|
||||
aps = [
|
||||
_ap('aa:bb:cc:11:22:33', 'HomeWiFi', 'wpa2'),
|
||||
_ap('ff:ee:dd:99:88:77', 'HomeWiFi', 'wpa'),
|
||||
]
|
||||
found = wificanary.check_trusted_aps(aps, trusted)
|
||||
assert len(found) == 1
|
||||
assert found[0]['motor'] == 'evil_twin'
|
||||
assert found[0]['bssid'] == 'ff:ee:dd:99:88:77'
|
||||
|
||||
|
||||
def test_absent_baseline_with_weaker_not_open_clone_present():
|
||||
trusted = [{'ssid': 'HomeWiFi', 'bssid': 'aa:bb:cc:11:22:33', 'security_set': {'wpa2', 'wpa3'}}]
|
||||
aps = [_ap('ff:ee:dd:99:88:77', 'HomeWiFi', 'wpa')]
|
||||
found = wificanary.check_trusted_aps(aps, trusted)
|
||||
assert len(found) == 1
|
||||
assert found[0]['motor'] == 'absent_baseline_clone'
|
||||
|
||||
|
||||
def test_security_downgrade_same_radio():
|
||||
trusted = [{'ssid': 'HomeWiFi', 'bssid': 'aa:bb:cc:11:22:33', 'security_set': {'wpa2'}}]
|
||||
aps = [_ap('aa:bb:cc:11:22:33', 'HomeWiFi', 'wep')]
|
||||
found = wificanary.check_trusted_aps(aps, trusted)
|
||||
assert len(found) == 1
|
||||
assert found[0]['motor'] == 'security_downgrade'
|
||||
|
||||
|
||||
def test_security_downgrade_same_radio_to_fully_open():
|
||||
trusted = [{'ssid': 'HomeWiFi', 'bssid': 'aa:bb:cc:11:22:33', 'security_set': {'wpa2'}}]
|
||||
aps = [_ap('aa:bb:cc:11:22:33', 'HomeWiFi', 'open')]
|
||||
found = wificanary.check_trusted_aps(aps, trusted)
|
||||
assert len(found) == 1
|
||||
assert found[0]['motor'] == 'security_downgrade'
|
||||
|
||||
|
||||
def test_wildcard_bssid_open_is_also_caught():
|
||||
trusted = [{'ssid': 'OfficeNet', 'bssid': '', 'security_set': {'wpa2'}}]
|
||||
aps = [_ap('11:22:33:44:55:66', 'OfficeNet', 'open')]
|
||||
found = wificanary.check_trusted_aps(aps, trusted)
|
||||
assert len(found) == 1
|
||||
assert found[0]['motor'] == 'security_downgrade'
|
||||
|
||||
|
||||
def test_upgrade_is_not_a_downgrade():
|
||||
trusted = [{'ssid': 'HomeWiFi', 'bssid': 'aa:bb:cc:11:22:33', 'security_set': {'wpa2'}}]
|
||||
aps = [_ap('aa:bb:cc:11:22:33', 'HomeWiFi', 'wpa3')]
|
||||
assert wificanary.check_trusted_aps(aps, trusted) == []
|
||||
|
||||
|
||||
def test_multi_value_accepted_set_no_false_positive():
|
||||
# A WPA2/WPA3-transition-mode AP: either value is legitimately expected,
|
||||
# neither should be flagged as a downgrade from the other.
|
||||
trusted = [{'ssid': 'HomeWiFi', 'bssid': 'aa:bb:cc:11:22:33', 'security_set': {'wpa2', 'wpa3'}}]
|
||||
assert wificanary.check_trusted_aps([_ap('aa:bb:cc:11:22:33', 'HomeWiFi', 'wpa2')], trusted) == []
|
||||
assert wificanary.check_trusted_aps([_ap('aa:bb:cc:11:22:33', 'HomeWiFi', 'wpa3')], trusted) == []
|
||||
|
||||
|
||||
def test_multi_value_accepted_set_still_catches_weaker_downgrade():
|
||||
trusted = [{'ssid': 'HomeWiFi', 'bssid': 'aa:bb:cc:11:22:33', 'security_set': {'wpa2', 'wpa3'}}]
|
||||
aps = [_ap('aa:bb:cc:11:22:33', 'HomeWiFi', 'wep')]
|
||||
found = wificanary.check_trusted_aps(aps, trusted)
|
||||
assert len(found) == 1
|
||||
assert found[0]['motor'] == 'security_downgrade'
|
||||
|
||||
|
||||
def test_non_contiguous_accepted_set_flags_the_gap():
|
||||
# Accepted = {wep, wpa2} (legacy compat, no plain wpa). Observed 'wpa' is
|
||||
# not itself accepted and is weaker than the strongest accepted (wpa2),
|
||||
# so it's still flagged even though it's stronger than the weakest
|
||||
# accepted (wep) - "not explicitly accepted and weaker than your best
|
||||
# configured posture" is the rule, not "outside the accepted range".
|
||||
trusted = [{'ssid': 'HomeWiFi', 'bssid': 'aa:bb:cc:11:22:33', 'security_set': {'wep', 'wpa2'}}]
|
||||
aps = [_ap('aa:bb:cc:11:22:33', 'HomeWiFi', 'wpa')]
|
||||
found = wificanary.check_trusted_aps(aps, trusted)
|
||||
assert len(found) == 1
|
||||
assert found[0]['motor'] == 'security_downgrade'
|
||||
|
||||
|
||||
def test_explicitly_accepted_open_does_not_trip_evil_twin():
|
||||
trusted = [{'ssid': 'GuestWiFi', 'bssid': 'aa:bb:cc:11:22:33', 'security_set': {'open'}}]
|
||||
aps = [_ap('aa:bb:cc:11:22:33', 'GuestWiFi', 'open')]
|
||||
assert wificanary.check_trusted_aps(aps, trusted) == []
|
||||
|
||||
|
||||
def test_wildcard_bssid_matches_any_radio():
|
||||
trusted = [{'ssid': 'OfficeNet', 'bssid': '', 'security_set': {'wpa2'}}]
|
||||
aps = [_ap('11:22:33:44:55:66', 'OfficeNet', 'wep')]
|
||||
found = wificanary.check_trusted_aps(aps, trusted)
|
||||
assert len(found) == 1
|
||||
assert found[0]['motor'] == 'security_downgrade'
|
||||
|
||||
|
||||
def test_unrelated_ssid_not_flagged():
|
||||
trusted = [{'ssid': 'HomeWiFi', 'bssid': 'aa:bb:cc:11:22:33', 'security_set': {'wpa2'}}]
|
||||
aps = [_ap('11:22:33:44:55:66', 'NeighborNet', 'open')]
|
||||
assert wificanary.check_trusted_aps(aps, trusted) == []
|
||||
|
||||
|
||||
def test_two_trusted_bssids_same_ssid_no_false_positive():
|
||||
# An AP + range extender pair (same SSID, different BSSID/OUI) -
|
||||
# each listed as its own trusted_aps entry - shouldn't trip anything.
|
||||
trusted = [
|
||||
{'ssid': 'HomeWiFi', 'bssid': 'aa:bb:cc:11:22:33', 'security_set': {'wpa2', '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'),
|
||||
]
|
||||
assert wificanary.check_trusted_aps(aps, trusted) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_duplicate_ssid()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_duplicate_ssid_flags_only_the_impostor():
|
||||
trusted = [{'ssid': 'HomeWiFi', 'bssid': 'aa:bb:cc:11:22:33', 'security_set': {'wpa2'}}]
|
||||
aps = [
|
||||
_ap('aa:bb:cc:11:22:33', 'HomeWiFi', 'wpa2'),
|
||||
_ap('ff:ee:dd:99:88:77', 'HomeWiFi', 'open'),
|
||||
]
|
||||
found = wificanary.check_duplicate_ssid(aps, trusted)
|
||||
assert len(found) == 1
|
||||
assert found[0]['bssid'] == 'ff:ee:dd:99:88:77'
|
||||
|
||||
|
||||
def test_duplicate_ssid_untracked_network_ignored():
|
||||
aps = [
|
||||
_ap('aa:bb:cc:11:22:33', 'CafeWiFi', 'open'),
|
||||
_ap('ff:ee:dd:99:88:77', 'CafeWiFi', 'open'),
|
||||
]
|
||||
assert wificanary.check_duplicate_ssid(aps, []) == []
|
||||
|
||||
|
||||
def test_duplicate_ssid_same_oui_not_flagged():
|
||||
trusted = [{'ssid': 'MeshNet', 'bssid': '', 'security_set': {'wpa2'}}]
|
||||
aps = [
|
||||
_ap('aa:bb:cc:11:22:33', 'MeshNet', 'wpa2'),
|
||||
_ap('aa:bb:cc:44:55:66', 'MeshNet', 'wpa2'),
|
||||
]
|
||||
assert wificanary.check_duplicate_ssid(aps, trusted) == []
|
||||
|
||||
|
||||
def test_duplicate_ssid_multiple_trusted_bssids_not_flagged():
|
||||
# A range extender/mesh node legitimately shares an SSID with the main
|
||||
# AP and often carries a different OUI - each gets its own trusted_aps
|
||||
# entry (same SSID, its own BSSID), and both OUIs should be accepted.
|
||||
trusted = [
|
||||
{'ssid': 'HomeWiFi', 'bssid': 'aa:bb:cc:11:22:33', 'security_set': {'wpa2', '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'),
|
||||
]
|
||||
assert wificanary.check_duplicate_ssid(aps, trusted) == []
|
||||
|
||||
|
||||
def test_duplicate_ssid_flags_oui_not_among_multiple_trusted():
|
||||
trusted = [
|
||||
{'ssid': 'HomeWiFi', 'bssid': 'aa:bb:cc:11:22:33', 'security_set': {'wpa2', '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('11:22:33:44:55:66', 'HomeWiFi', 'wpa2'),
|
||||
]
|
||||
found = wificanary.check_duplicate_ssid(aps, trusted)
|
||||
assert len(found) == 1
|
||||
assert found[0]['bssid'] == '11:22:33:44:55:66'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_security_set()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_parse_security_set_single_value():
|
||||
assert wificanary.parse_security_set('["wpa2"]') == {'wpa2'}
|
||||
|
||||
|
||||
def test_parse_security_set_multi_value_mixed_case():
|
||||
assert wificanary.parse_security_set('["wpa2", "WPA3"]') == {'wpa2', 'wpa3'}
|
||||
|
||||
|
||||
def test_parse_security_set_blank_falls_back_to_wpa2():
|
||||
assert wificanary.parse_security_set('') == {'wpa2'}
|
||||
assert wificanary.parse_security_set(None) == {'wpa2'}
|
||||
|
||||
|
||||
def test_parse_security_set_malformed_json_falls_back_to_wpa2():
|
||||
assert wificanary.parse_security_set('not json') == {'wpa2'}
|
||||
|
||||
|
||||
def test_parse_security_set_empty_list_falls_back_to_wpa2():
|
||||
assert wificanary.parse_security_set('[]') == {'wpa2'}
|
||||
|
||||
|
||||
def test_parse_security_set_already_a_list():
|
||||
# Defensive: works even if a caller ever hands it a real list instead of
|
||||
# the JSON-string form the frontend actually sends.
|
||||
assert wificanary.parse_security_set(['wpa2', 'wpa3']) == {'wpa2', 'wpa3'}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_trusted_aps()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_trusted_aps_decodes_entries():
|
||||
raw = [
|
||||
_encode_trusted_entry('HomeWiFi', 'AA:BB:CC:11:22:33', ('wpa2',)),
|
||||
_encode_trusted_entry('OfficeNet', '', ('wpa2', 'WPA3')), # mixed case, multi-value
|
||||
_encode_trusted_entry('', '', ('wpa2',)), # blank SSID - no usable baseline identity
|
||||
]
|
||||
with patch.object(wificanary, 'get_setting_value', return_value=raw):
|
||||
trusted = wificanary.get_trusted_aps()
|
||||
|
||||
assert trusted == [
|
||||
{'ssid': 'HomeWiFi', 'bssid': 'aa:bb:cc:11:22:33', 'security_set': {'wpa2'}},
|
||||
{'ssid': 'OfficeNet', 'bssid': '', 'security_set': {'wpa2', 'wpa3'}},
|
||||
]
|
||||
|
||||
|
||||
def test_get_trusted_aps_empty_setting():
|
||||
with patch.object(wificanary, 'get_setting_value', return_value=None):
|
||||
assert wificanary.get_trusted_aps() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# escalate_known_devices()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _detection(bssid='ff:ee:dd:99:88:77', motor='evil_twin', reason='original reason'):
|
||||
return {'bssid': bssid, 'ssid': 'HomeWiFi', 'motor': motor, 'reason': reason,
|
||||
'security': 'open', 'signal': '-50.00', 'oui': 'ff:ee:dd'}
|
||||
|
||||
|
||||
def test_no_escalation_when_bssid_is_not_a_known_device():
|
||||
with patch.object(wificanary, 'DeviceInstance') as MockDeviceInstance:
|
||||
MockDeviceInstance.return_value.getAllByMacs.return_value = {}
|
||||
det = _detection()
|
||||
wificanary.escalate_known_devices([det])
|
||||
assert det['motor'] == 'evil_twin'
|
||||
assert det['reason'] == 'original reason'
|
||||
|
||||
|
||||
def test_no_escalation_when_only_prior_wificanary_record_exists():
|
||||
with patch.object(wificanary, 'DeviceInstance') as MockDeviceInstance:
|
||||
MockDeviceInstance.return_value.getAllByMacs.return_value = {
|
||||
'ff:ee:dd:99:88:77': {'devMac': 'ff:ee:dd:99:88:77', 'devName': 'ff:ee:dd:99:88:77',
|
||||
'devSourcePlugin': 'WIFICANARY'},
|
||||
}
|
||||
det = _detection()
|
||||
wificanary.escalate_known_devices([det])
|
||||
assert det['motor'] == 'evil_twin'
|
||||
assert det['reason'] == 'original reason'
|
||||
|
||||
|
||||
def test_escalates_a_real_pre_existing_device():
|
||||
with patch.object(wificanary, 'DeviceInstance') as MockDeviceInstance:
|
||||
MockDeviceInstance.return_value.getAllByMacs.return_value = {
|
||||
'ff:ee:dd:99:88:77': {'devMac': 'ff:ee:dd:99:88:77', 'devName': "Mauricio's laptop",
|
||||
'devSourcePlugin': 'ARPSCAN'},
|
||||
}
|
||||
det = _detection()
|
||||
wificanary.escalate_known_devices([det])
|
||||
assert det['motor'] == 'evil_twin_known_device'
|
||||
assert det['reason'] == "Known device 'Mauricio's laptop' now behaving like a rogue AP: original reason"
|
||||
|
||||
|
||||
def test_escalation_falls_back_to_bssid_when_device_has_no_name():
|
||||
with patch.object(wificanary, 'DeviceInstance') as MockDeviceInstance:
|
||||
MockDeviceInstance.return_value.getAllByMacs.return_value = {
|
||||
'ff:ee:dd:99:88:77': {'devMac': 'ff:ee:dd:99:88:77', 'devName': '', 'devSourcePlugin': 'ARPSCAN'},
|
||||
}
|
||||
det = _detection()
|
||||
wificanary.escalate_known_devices([det])
|
||||
assert "Known device 'ff:ee:dd:99:88:77'" in det['reason']
|
||||
|
||||
|
||||
def test_escalation_is_a_single_batched_query_not_one_per_detection():
|
||||
# The concern this guards against: N detections in one run must not mean
|
||||
# N individual DeviceInstance().getByMac() round-trips - see
|
||||
# server/models/device_instance.py's getAllByMacs() docstring.
|
||||
dets = [
|
||||
_detection(bssid='ff:ee:dd:99:88:77', motor='evil_twin'),
|
||||
_detection(bssid='ff:ee:dd:99:88:77', motor='duplicate_ssid_diff_vendor'),
|
||||
_detection(bssid='11:22:33:44:55:66', motor='security_downgrade'),
|
||||
]
|
||||
with patch.object(wificanary, 'DeviceInstance') as MockDeviceInstance:
|
||||
MockDeviceInstance.return_value.getAllByMacs.return_value = {}
|
||||
wificanary.escalate_known_devices(dets)
|
||||
|
||||
MockDeviceInstance.assert_called_once()
|
||||
MockDeviceInstance.return_value.getAllByMacs.assert_called_once()
|
||||
called_macs = MockDeviceInstance.return_value.getAllByMacs.call_args.args[0]
|
||||
assert set(called_macs) == {'ff:ee:dd:99:88:77', '11:22:33:44:55:66'}
|
||||
MockDeviceInstance.return_value.getByMac.assert_not_called()
|
||||
|
||||
|
||||
def test_escalation_with_no_detections_does_not_query():
|
||||
with patch.object(wificanary, 'DeviceInstance') as MockDeviceInstance:
|
||||
MockDeviceInstance.return_value.getAllByMacs.return_value = {}
|
||||
wificanary.escalate_known_devices([])
|
||||
MockDeviceInstance.return_value.getAllByMacs.assert_called_once_with([])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_main_returns_early_without_iface():
|
||||
with patch.object(wificanary, 'get_setting_value', return_value=''):
|
||||
with patch.object(wificanary, 'scan') as mock_scan:
|
||||
wificanary.plugin_objects.add_object = MagicMock()
|
||||
wificanary.plugin_objects.write_result_file = MagicMock()
|
||||
wificanary.main()
|
||||
mock_scan.assert_not_called()
|
||||
wificanary.plugin_objects.add_object.assert_not_called()
|
||||
|
||||
|
||||
def test_main_end_to_end_one_detection():
|
||||
settings = {
|
||||
'WIFICANARY_IFACE': 'wlan0',
|
||||
'WIFICANARY_RUN_TIMEOUT': 60,
|
||||
'WIFICANARY_trusted_aps': [_encode_trusted_entry('HomeWiFi', 'aa:bb:cc:11:22:33', ('wpa2',))],
|
||||
}
|
||||
aps = [
|
||||
_ap('aa:bb:cc:11:22:33', '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 = {} # no known-device escalation
|
||||
wificanary.plugin_objects.add_object = MagicMock()
|
||||
wificanary.plugin_objects.write_result_file = MagicMock()
|
||||
wificanary.main()
|
||||
|
||||
# The rogue AP trips two independent motors at once (evil-twin clone AND
|
||||
# duplicate-SSID/different-vendor) - both are legitimate, separate rows.
|
||||
assert wificanary.plugin_objects.add_object.call_count == 2
|
||||
calls_by_motor = {c.kwargs['secondaryId']: c.kwargs for c in wificanary.plugin_objects.add_object.call_args_list}
|
||||
assert set(calls_by_motor) == {'evil_twin', 'duplicate_ssid_diff_vendor'}
|
||||
|
||||
call_kwargs = calls_by_motor['evil_twin']
|
||||
assert call_kwargs['primaryId'] == 'ff:ee:dd:99:88:77'
|
||||
assert call_kwargs['helpVal1'] == 'ff:ee:dd:99:88:77'
|
||||
assert call_kwargs['helpVal2'] == '1'
|
||||
assert call_kwargs['helpVal3'] == 'normal'
|
||||
assert call_kwargs['helpVal4'] == '1'
|
||||
wificanary.plugin_objects.write_result_file.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(pytest.main([__file__, '-v']))
|
||||
Reference in new issue
Block a user