Sync + WIFICANARY: attribute synced detections to their node

Fixes found running WIFICANARY on a WiFi satellite node that pushes its
detections to a hub with no WiFi of its own.

Synced plugin objects were stored with syncHubNodeName = "decoded"
instead of the node's name. plugin.py took filename.split(".")[2], but by
the time the hub processes the file decode_and_rename_files() has renamed
it to last_result.<PREFIX>.decoded.<NodeName>.<N>.log, so index 2 is the
literal marker. The parsing now lives in
utils.plugin_utils.get_sync_hub_node_name(), next to the function that
produces these names; it keeps node names that contain dots and returns
"" for local result files.

WIFICANARY's absent_baseline_clone was evaluated per trusted_aps entry,
so with a main AP + range extender pair, a weak clone seen while only the
main AP was in range also raised absent_baseline_clone through the
extender's entry, on top of the correct evil_twin. The baseline now
counts as present if any trusted radio for that SSID is visible;
single-entry behaviour is unchanged.

Leaving WIFICANARY_IFACE blank is how such a hub ingests the node's
detections (core processes the synced files after the plugin runs), but
it logged an unconditional "not set - nothing to scan" every cycle. It is
now a verbose "hub-only mode" message, and the node + hub setup is
documented in the setting description and the plugin README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PzCQWQVz9b2UzqLiGp2mPM
This commit is contained in:
Mauricio CamayoandClaude Opus 5.5 committed 2026-10-22 12:09:53 -05:00
1 parent 32e2ddd322
commit ca4e92c32d
7 files changed
+113 -8

No files matched your search

+30
View File
@@ -407,6 +407,34 @@ def test_two_trusted_bssids_same_ssid_no_false_positive():
assert wificanary.check_trusted_aps(aps, trusted) == []
def test_out_of_range_extender_does_not_make_clone_absent_baseline():
# Main AP + extender, each its own entry; only the main AP is in range.
# An open clone is a plain evil twin - the protected network is up, so
# the out-of-range extender must not also raise absent_baseline_clone.
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', 'wpa3'}},
]
aps = [
_ap('aa:bb:cc:11:22:33', 'HomeWiFi', 'wpa3'),
_ap('ff:ee:dd:99:88:77', 'HomeWiFi', 'open'),
]
found = wificanary.check_trusted_aps(aps, trusted)
assert {d['motor'] for d in found} == {'evil_twin'}
def test_clone_with_every_trusted_radio_absent_is_absent_baseline():
# Same pair, neither radio in range - now the clone really is standing
# in for a missing baseline.
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', 'wpa3'}},
]
aps = [_ap('ff:ee:dd:99:88:77', 'HomeWiFi', 'open')]
found = wificanary.check_trusted_aps(aps, trusted)
assert {d['motor'] for d in found} == {'absent_baseline_clone'}
def test_trusted_extender_with_stricter_main_ap_not_flagged_as_clone():
# Regression (CodeRabbit): a main AP entry requiring a *stronger*
# accepted set (wpa3 only) than a separately-trusted extender (wpa2)
@@ -679,6 +707,8 @@ def test_main_returns_early_without_iface():
wificanary.main()
mock_scan.assert_not_called()
wificanary.plugin_objects.add_object.assert_not_called()
# hub-only mode still writes an (empty) result file so the run completes normally
wificanary.plugin_objects.write_result_file.assert_called_once()
def test_main_end_to_end_one_detection():
+56
View File
@@ -0,0 +1,56 @@
"""
Tests for utils.plugin_utils.get_sync_hub_node_name().
Regression coverage: server/plugin.py used filename.split(".")[2] to tag
plugin objects received from a sync node, but by the time the hub processes
them decode_and_rename_files() has renamed the file to
last_result.<PREFIX>.decoded.<NodeName>.<N>.log - so index 2 is the literal
"decoded", and every synced plugin object (e.g. a WIFICANARY detection from a
WiFi satellite) was stored with syncHubNodeName = "decoded" instead of the
node's name.
Run from inside the NetAlertX container (utils.plugin_utils imports conf,
logger and helper):
pytest "test/server/test_sync_hub_node_name.py" -v
"""
import os
import sys
import pytest
INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
sys.path.extend([f"{INSTALL_PATH}/server/plugins", f"{INSTALL_PATH}/server"])
from utils.plugin_utils import get_sync_hub_node_name # noqa: E402
@pytest.mark.parametrize(
"filename, expected",
[
# What the hub actually processes: already decrypted and renamed
("last_result.WIFICANARY.decoded.satelite.6.log", "satelite"),
# Same file before decode_and_rename_files() touches it
("last_result.WIFICANARY.encoded.satelite.6.log", "satelite"),
("last_result.ARPSCAN.decoded.Node_1.1.log", "Node_1"),
# Node names containing dots must survive intact
("last_result.ARPSCAN.decoded.sat.office.12.log", "sat.office"),
],
)
def test_node_name_from_received_file(filename, expected):
"""A received node file yields the node's name, never the encoded/decoded marker."""
assert get_sync_hub_node_name(filename) == expected
@pytest.mark.parametrize(
"filename",
[
"last_result.ARPSCAN.log", # local plugin result
"last_result.log", # sync's own progress file
"last_result.satelite.log", # PULL-mode device dump (handled by sync.py, not here)
],
)
def test_local_result_file_has_no_node_name(filename):
"""Local result files are not tagged with any sync node."""
assert get_sync_hub_node_name(filename) == ""