mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-10-02 02:35:05 -04:00
9 files changed
+2252
-2
No files matched your search
@@ -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
-1
@@ -131,7 +131,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 && \
|
||||
@@ -175,6 +175,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)" && \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -104,6 +104,19 @@ class DeviceInstance:
|
||||
SELECT * FROM Devices WHERE devMac = ?
|
||||
""", (mac,))
|
||||
|
||||
def getAllByMacs(self, macs):
|
||||
"""Return every Devices row whose devMac is in `macs`, as a dict keyed
|
||||
by lowercased devMac - one query for a batch of MACs instead of one
|
||||
`getByMac()` call per MAC, for a caller that needs to cross-reference
|
||||
several MACs against known devices in a single pass (e.g. WIFICANARY's
|
||||
known-device-turned-rogue check)."""
|
||||
macs = [m for m in dict.fromkeys(macs) if m]
|
||||
if not macs:
|
||||
return {}
|
||||
placeholders = ",".join("?" for _ in macs)
|
||||
rows = self._fetchall(f"SELECT * FROM Devices WHERE devMac IN ({placeholders})", tuple(macs))
|
||||
return {row["devMac"].lower(): row for row in rows}
|
||||
|
||||
def exists(self, devGUID):
|
||||
row = self._fetchone("""
|
||||
SELECT COUNT(*) as count FROM Devices WHERE devGUID = ?
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
## Overview
|
||||
|
||||
Runs a periodic passive WiFi scan (`iw scan`, no monitor mode) and flags rogue APs against a baseline you define: pwnagotchi/WiFi Pineapple signatures, evil-twin/open clones of a protected SSID, a protected AP going missing while a clone is visible, security downgrades, and a protected SSID suddenly broadcast from an unexpected vendor. Originated from [issue #1789](https://github.com/netalertx/NetAlertX/issues/1789), which also covers why deauth/probe-flood/beacon-flood detection is intentionally **not** included here - those need real monitor-mode frame capture, not a scan snapshot. For that, pair this plugin with a dedicated monitor-mode tool such as [ESP32 WiFi Canary](https://github.com/simeononsecurity/esp32-wifi-canary).
|
||||
|
||||
### Requirements
|
||||
|
||||
- A WiFi interface reachable from the NetAlertX host, in station mode (monitor mode is *not* required - a normal onboard or USB WiFi adapter is enough). If your NetAlertX host has no WiFi hardware, this plugin has nothing to scan with.
|
||||
- The image ships `iw` with `cap_net_raw,cap_net_admin` already set (same treatment as `arp-scan`/`nmap`/`nbtscan`/`traceroute`), so the plugin can scan as the non-root runtime user without real `sudo`. You still need a WiFi interface actually visible to the container, e.g. via host networking.
|
||||
|
||||
### Usage
|
||||
|
||||
- Set `WIFICANARY_IFACE` to your wireless interface (e.g. `wlan0`).
|
||||
- Add each network you want protected to `WIFICANARY_trusted_aps` - SSID, optionally its BSSID (recommended: without a BSSID, the evil-twin/absent-baseline checks fall back to matching on SSID alone), and every encryption you'd accept from it (select more than one for a WPA2/WPA3-transition-mode AP).
|
||||
- Have a range extender or mesh node broadcasting the same SSID as your main AP? Add it as its **own** `WIFICANARY_trusted_aps` entry (same SSID, its own BSSID/security) rather than leaving it out - a real extender is very often a different vendor/OUI than the main router, and every trusted BSSID's OUI for a given SSID is treated as legitimate, not just the first one.
|
||||
- Enable the plugin (`WIFICANARY_RUN` → `schedule`) and set a schedule in `WIFICANARY_RUN_SCHD`.
|
||||
- A detection creates a new, dangerous-by-default `Devices` entry for the rogue BSSID (even though it never associated with your network) - turn off `WIFICANARY_IMPORT_ON` if you'd rather tune your trusted-AP list against the plugin's history first, without devices being created yet.
|
||||
- Pwnagotchi and WiFi Pineapple signature checks run unconditionally, regardless of `WIFICANARY_trusted_aps`.
|
||||
- If a detected rogue BSSID turns out to already be a device NetAlertX knows from another source (ARP, DHCP, an importer...), the finding is escalated in place - the reason is rewritten to name the known device, and the motor gets a `_known_device` suffix (e.g. `evil_twin_known_device`) so a [Workflow](https://docs.netalertx.com/WORKFLOWS) rule can route it to a more urgent channel than a stranger's radio.
|
||||
|
||||
### Notes
|
||||
|
||||
- Vendor names for a rogue device do show up in the GUI, but not from this plugin - a `Devices` row it creates gets its `Vendor` field filled in by core's own `VNDRPDT` (vendor_update) plugin on its next run, same as any other device. That lookup is a local OUI-database match, not a network call, so it's deliberately kept out of the scan step itself.
|
||||
- The duplicate-SSID/different-vendor check only looks at SSIDs you've listed in `WIFICANARY_trusted_aps` - an untracked network's own AP diversity (e.g. a cafe chain) is never flagged. For a tracked SSID, every explicitly-trusted BSSID's OUI is whitelisted (see the range-extender note above) - only an OUI that matches *none* of them gets flagged. "Vendor" here means OUI (BSSID's first 3 octets) compared directly between the APs sharing an SSID, not a vendor-name lookup.
|
||||
- `WIFICANARY_TRUSTED_SECURITY` is multi-select. An observed encryption exactly matching any selected value is always accepted; otherwise it's flagged if it's weaker than the *strongest* value you selected - deliberately, not a typo: comparing against the weakest would make selecting more than one value pointless (anything at or above the weakest would silently pass either way, making the rest of the selection meaningless). Worked example for `wep` + `wpa2` selected:
|
||||
|
||||
| Observed | Result |
|
||||
|---|---|
|
||||
| `wep` | OK (listed) |
|
||||
| `wpa2` | OK (listed) |
|
||||
| `wpa` | **Alert** - not listed, and weaker than `wpa2` |
|
||||
| `open` | **Alert** - weaker than everything |
|
||||
|
||||
Select `open` here only for a network you intend to run unencrypted on purpose (e.g. a guest SSID) - otherwise leave it out so an unexpected open clone or downgrade still trips an alert.
|
||||
- Encryption is classified from the `iw scan` IEs into `open` / `wep` / `wpa` / `wpa2` / `wpa3`. A `Privacy`-flagged AP with neither an `RSN` nor a `WPA` information element is reported as `wep` - the closest reasonable guess for that combination, not a certainty.
|
||||
- See the [WIFICANARY addendum on issue #1789](https://github.com/netalertx/NetAlertX/issues/1789#issuecomment-5777023835) for the reasoning behind creating a device for never-associated attacker BSSIDs, and for the "known device turned rogue" idea. The implemented version above only covers the BSSID-identity angle (is the radio itself a device you already trust?) - the addendum's original, richer version (cross-referencing the *source MAC of attack traffic* like deauth/probe floods) still needs monitor-mode data this plugin doesn't have.
|
||||
|
||||
- Author: `mauricio-camayo`
|
||||
@@ -0,0 +1,973 @@
|
||||
{
|
||||
"code_name": "wificanary",
|
||||
"unique_prefix": "WIFICANARY",
|
||||
"plugin_type": "device_scanner",
|
||||
"enabled": true,
|
||||
"data_source": "script",
|
||||
"show_ui": true,
|
||||
"localized": [
|
||||
"display_name",
|
||||
"description",
|
||||
"icon"
|
||||
],
|
||||
"display_name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "WiFi Canary"
|
||||
}
|
||||
],
|
||||
"icon": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "<i class=\"fa-solid fa-tower-broadcast\"></i>"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Flags rogue APs (evil twins, pineapples, pwnagotchis, security downgrades) from a periodic passive WiFi scan against a trusted-AP baseline."
|
||||
}
|
||||
],
|
||||
"params": [],
|
||||
"mapped_to_table": "CurrentScan",
|
||||
"database_column_definitions": [
|
||||
{
|
||||
"column": "index",
|
||||
"css_classes": "col-sm-2",
|
||||
"show": true,
|
||||
"type": "none",
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Index"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"column": "plugin",
|
||||
"css_classes": "col-sm-2",
|
||||
"show": false,
|
||||
"type": "label",
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "N/A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"column": "objectPrimaryId",
|
||||
"css_classes": "col-sm-2",
|
||||
"show": true,
|
||||
"type": "label",
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "BSSID"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"column": "objectSecondaryId",
|
||||
"css_classes": "col-sm-2",
|
||||
"show": true,
|
||||
"type": "label",
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Motor"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"column": "dateTimeCreated",
|
||||
"css_classes": "col-sm-2",
|
||||
"show": true,
|
||||
"type": "label",
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "First seen"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"column": "dateTimeChanged",
|
||||
"css_classes": "col-sm-2",
|
||||
"show": true,
|
||||
"type": "label",
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Changed"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"column": "watchedValue1",
|
||||
"css_classes": "col-sm-4",
|
||||
"show": true,
|
||||
"type": "label",
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Reason"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"column": "watchedValue2",
|
||||
"css_classes": "col-sm-2",
|
||||
"show": true,
|
||||
"type": "label",
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Observed security"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"column": "watchedValue3",
|
||||
"css_classes": "col-sm-2",
|
||||
"show": true,
|
||||
"type": "label",
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Signal (dBm)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"column": "watchedValue4",
|
||||
"css_classes": "col-sm-2",
|
||||
"show": true,
|
||||
"type": "label",
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Vendor OUI"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"column": "extra",
|
||||
"mapped_to_column": "scanSSID",
|
||||
"css_classes": "col-sm-2",
|
||||
"show": true,
|
||||
"type": "label",
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "SSID"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"column": "helpVal1",
|
||||
"mapped_to_column": "scanMac",
|
||||
"css_classes": "col-sm-2",
|
||||
"show": false,
|
||||
"type": "none",
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "N/A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"column": "helpVal2",
|
||||
"mapped_to_column": "scanCreatesDevice",
|
||||
"css_classes": "col-sm-2",
|
||||
"show": false,
|
||||
"type": "none",
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "N/A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"column": "helpVal3",
|
||||
"mapped_to_column": "scanNotificationMode",
|
||||
"css_classes": "col-sm-2",
|
||||
"show": false,
|
||||
"type": "none",
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "N/A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"column": "helpVal4",
|
||||
"mapped_to_column": "scanPresence",
|
||||
"css_classes": "col-sm-2",
|
||||
"show": false,
|
||||
"type": "none",
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "N/A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"column": "Dummy",
|
||||
"mapped_to_column": "scanSourcePlugin",
|
||||
"mapped_to_column_data": {
|
||||
"value": "WIFICANARY"
|
||||
},
|
||||
"css_classes": "col-sm-2",
|
||||
"show": false,
|
||||
"type": "none",
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "N/A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"column": "DummyIP",
|
||||
"mapped_to_column": "scanLastIP",
|
||||
"mapped_to_column_data": {
|
||||
"value": ""
|
||||
},
|
||||
"css_classes": "col-sm-2",
|
||||
"show": false,
|
||||
"type": "none",
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "N/A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"column": "userData",
|
||||
"css_classes": "col-sm-2",
|
||||
"show": false,
|
||||
"type": "textbox_save",
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Comments"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"column": "status",
|
||||
"css_classes": "col-sm-1",
|
||||
"show": false,
|
||||
"type": "replace",
|
||||
"default_value": "",
|
||||
"options": [
|
||||
{
|
||||
"equals": "watched-not-changed",
|
||||
"replacement": "<div style='text-align:center'><i class='fa-solid fa-square-check'></i><div></div>"
|
||||
},
|
||||
{
|
||||
"equals": "watched-changed",
|
||||
"replacement": "<div style='text-align:center'><i class='fa-solid fa-triangle-exclamation'></i></div>"
|
||||
},
|
||||
{
|
||||
"equals": "new",
|
||||
"replacement": "<div style='text-align:center'><i class='fa-solid fa-circle-plus'></i></div>"
|
||||
},
|
||||
{
|
||||
"equals": "missing-in-last-scan",
|
||||
"replacement": "<div style='text-align:center'><i class='fa-solid fa-question'></i></div>"
|
||||
}
|
||||
],
|
||||
"localized": [
|
||||
"name"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Status"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"settings": [
|
||||
{
|
||||
"function": "RUN",
|
||||
"events": [
|
||||
"run"
|
||||
],
|
||||
"type": {
|
||||
"dataType": "string",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": "disabled",
|
||||
"options": [
|
||||
"disabled",
|
||||
"once",
|
||||
"schedule",
|
||||
"always_after_scan"
|
||||
],
|
||||
"localized": [
|
||||
"name",
|
||||
"description"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "When to run"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Enable a regular WiFi scan. <code>schedule</code> uses the scheduling settings below; <code>once</code> runs only on startup."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "IMPORT_ON",
|
||||
"type": {
|
||||
"dataType": "boolean",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "input",
|
||||
"elementOptions": [
|
||||
{
|
||||
"type": "checkbox"
|
||||
}
|
||||
],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": true,
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name",
|
||||
"description"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Create flagged devices for detections"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "On by default. Turn off to log detections without creating any Devices entry - useful while tuning your trusted-AP list before trusting the alerts."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "CMD",
|
||||
"type": {
|
||||
"dataType": "string",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "input",
|
||||
"elementOptions": [
|
||||
{
|
||||
"readonly": "true"
|
||||
}
|
||||
],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": "python3 /app/server/plugins/wificanary/script.py",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name",
|
||||
"description"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Command"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Command to run"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "RUN_SCHD",
|
||||
"type": {
|
||||
"dataType": "string",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "span",
|
||||
"elementOptions": [
|
||||
{
|
||||
"cssClasses": "input-group-addon validityCheck"
|
||||
},
|
||||
{
|
||||
"getStringKey": "Gen_ValidIcon"
|
||||
}
|
||||
],
|
||||
"transformers": []
|
||||
},
|
||||
{
|
||||
"elementType": "input",
|
||||
"elementOptions": [
|
||||
{
|
||||
"focusout": "validateRegex(this)"
|
||||
},
|
||||
{
|
||||
"base64Regex": "Xig/OlwqfCg/OlswLTldfFsxLTVdWzAtOV18WzAtOV0rLVswLTldKyg/Oi9bMC05XSspP3xcKi9bMC05XSspKSg/OiwoPzpbMC05XXxbMS01XVswLTldfFswLTldKy1bMC05XSsoPzovWzAtOV0rKT98XCovWzAtOV0rKSkqXHMrKD86XCp8KD86WzAtOV18MVswLTldfDJbMC0zXXxbMC05XSstWzAtOV0rKD86L1swLTldKyk/fFwqL1swLTldKykpKD86LCg/OlswLTldfDFbMC05XXwyWzAtM118WzAtOV0rLVswLTldKyg/Oi9bMC05XSspP3xcKi9bMC05XSspKSpccysoPzpcKnwoPzpbMS05XXxbMTJdWzAtOV18M1swMV18WzAtOV0rLVswLTldKyg/Oi9bMC05XSspP3xcKi9bMC05XSspKSg/OiwoPzpbMS05XXxbMTJdWzAtOV18M1swMV18WzAtOV0rLVswLTldKyg/Oi9bMC05XSspP3xcKi9bMC05XSspKSpccysoPzpcKnwoPzpbMS05XXwxWzAtMl18WzAtOV0rLVswLTldKyg/Oi9bMC05XSspP3xcKi9bMC05XSspKSg/OiwoPzpbMS05XXwxWzAtMl18WzAtOV0rLVswLTldKyg/Oi9bMC05XSspP3xcKi9bMC05XSspKSpccysoPzpcKnwoPzpbMC02XXxbMC02XS1bMC02XSg/Oi9bMC05XSspP3xcKi9bMC05XSspKSg/OiwoPzpbMC02XXxbMC02XS1bMC02XSg/Oi9bMC05XSspP3xcKi9bMC05XSspKSok"
|
||||
}
|
||||
],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": "*/5 * * * *",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name",
|
||||
"description"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Schedule"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Only used if <code>WIFICANARY_RUN</code> is set to <code>schedule</code>. Cron-like format, e.g. validate at <a href=\"https://crontab.guru/\" target=\"_blank\">crontab.guru</a>."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "RUN_TIMEOUT",
|
||||
"type": {
|
||||
"dataType": "integer",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "input",
|
||||
"elementOptions": [
|
||||
{
|
||||
"type": "number"
|
||||
}
|
||||
],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": 60,
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name",
|
||||
"description"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Run timeout"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Maximum time in seconds to wait for the scan to finish. If exceeded, the script is aborted."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "IFACE",
|
||||
"type": {
|
||||
"dataType": "string",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "input",
|
||||
"elementOptions": [
|
||||
{
|
||||
"placeholder": "wlan0"
|
||||
}
|
||||
],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name",
|
||||
"description"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Wireless interface"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "The WiFi interface to scan with, e.g. <code>wlan0</code>. Station mode is enough - no monitor mode needed. Required."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "trusted_aps",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "button",
|
||||
"elementOptions": [
|
||||
{
|
||||
"sourceSuffixes": []
|
||||
},
|
||||
{
|
||||
"separator": ""
|
||||
},
|
||||
{
|
||||
"cssClasses": "col-xs-12"
|
||||
},
|
||||
{
|
||||
"onClick": "addViaPopupForm(this)"
|
||||
},
|
||||
{
|
||||
"getStringKey": "Gen_Add"
|
||||
}
|
||||
],
|
||||
"transformers": []
|
||||
},
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementHasInputValue": 1,
|
||||
"elementOptions": [
|
||||
{
|
||||
"multiple": "true"
|
||||
},
|
||||
{
|
||||
"readonly": "true"
|
||||
},
|
||||
{
|
||||
"editable": "true"
|
||||
},
|
||||
{
|
||||
"popupForm": [
|
||||
{
|
||||
"function": "WIFICANARY_TRUSTED_SSID",
|
||||
"type": {
|
||||
"dataType": "string",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "input",
|
||||
"elementOptions": [
|
||||
{
|
||||
"placeholder": "HomeWiFi"
|
||||
},
|
||||
{
|
||||
"cssClasses": "col-sm-10"
|
||||
}
|
||||
],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name",
|
||||
"description"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "SSID"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Network name to protect. Required."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "WIFICANARY_TRUSTED_BSSID",
|
||||
"type": {
|
||||
"dataType": "string",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "input",
|
||||
"elementOptions": [
|
||||
{
|
||||
"placeholder": "aa:bb:cc:dd:ee:ff"
|
||||
},
|
||||
{
|
||||
"cssClasses": "col-sm-10"
|
||||
}
|
||||
],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name",
|
||||
"description"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "BSSID (optional)"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Radio MAC of the legitimate AP. Leave blank to match this SSID regardless of BSSID (weaker, but works for roaming/mesh setups). Set it to also enable the absent-baseline-with-clone-present check."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "WIFICANARY_TRUSTED_SECURITY",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [
|
||||
{
|
||||
"multiple": "true"
|
||||
},
|
||||
{
|
||||
"cssClasses": "col-sm-10"
|
||||
}
|
||||
],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": "[]",
|
||||
"options": [
|
||||
"open",
|
||||
"wep",
|
||||
"wpa",
|
||||
"wpa2",
|
||||
"wpa3"
|
||||
],
|
||||
"localized": [
|
||||
"name",
|
||||
"description"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Accepted security"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Every encryption this network legitimately uses - select more than one for a WPA2/WPA3-transition-mode AP. A scan showing anything weaker than the strongest one here (or <code>open</code>, unless selected) triggers a downgrade/evil-twin alert."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "WIFICANARY_TRUSTED_NOTES",
|
||||
"type": {
|
||||
"dataType": "string",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "input",
|
||||
"elementOptions": [
|
||||
{
|
||||
"placeholder": "optional note"
|
||||
},
|
||||
{
|
||||
"cssClasses": "col-sm-10"
|
||||
}
|
||||
],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name",
|
||||
"description"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Notes (optional)"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Free-text, shown only in this settings list."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"transformers": [
|
||||
"name|base64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"elementType": "button",
|
||||
"elementOptions": [
|
||||
{
|
||||
"sourceSuffixes": []
|
||||
},
|
||||
{
|
||||
"separator": ""
|
||||
},
|
||||
{
|
||||
"cssClasses": "col-xs-6"
|
||||
},
|
||||
{
|
||||
"onClick": "removeFromList(this)"
|
||||
},
|
||||
{
|
||||
"getStringKey": "Gen_Remove_Last"
|
||||
}
|
||||
],
|
||||
"transformers": []
|
||||
},
|
||||
{
|
||||
"elementType": "button",
|
||||
"elementOptions": [
|
||||
{
|
||||
"sourceSuffixes": []
|
||||
},
|
||||
{
|
||||
"separator": ""
|
||||
},
|
||||
{
|
||||
"cssClasses": "col-xs-6"
|
||||
},
|
||||
{
|
||||
"onClick": "removeAllOptions(this)"
|
||||
},
|
||||
{
|
||||
"getStringKey": "Gen_Remove_All"
|
||||
}
|
||||
],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name",
|
||||
"description"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Trusted APs"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "One entry per network you want protected. This list is the baseline every scan is compared against - evil-twin, downgrade and duplicate-SSID checks only fire for SSIDs listed here. Pwnagotchi/Pineapple signature checks apply regardless of this list."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "WATCH",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [
|
||||
{
|
||||
"multiple": "true",
|
||||
"orderable": "true"
|
||||
}
|
||||
],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [
|
||||
"watchedValue1",
|
||||
"watchedValue2",
|
||||
"watchedValue3",
|
||||
"watchedValue4"
|
||||
],
|
||||
"localized": [
|
||||
"name",
|
||||
"description"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Watched"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Send a notification if selected values change. <code>watchedValue1</code> is the reason, <code>watchedValue2</code> is observed security, <code>watchedValue3</code> is signal strength, <code>watchedValue4</code> is the vendor OUI."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "REPORT_ON",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [
|
||||
{
|
||||
"multiple": "true",
|
||||
"orderable": "true"
|
||||
}
|
||||
],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [
|
||||
"new",
|
||||
"watched-changed"
|
||||
],
|
||||
"options": [
|
||||
"new",
|
||||
"watched-changed",
|
||||
"watched-not-changed",
|
||||
"missing-in-last-scan"
|
||||
],
|
||||
"localized": [
|
||||
"name",
|
||||
"description"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Report on"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Send a notification only on these statuses. Every detection is a new anomaly, so <code>new</code> is the meaningful one here."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
WIFICANARY - flags rogue APs from a periodic passive WiFi scan.
|
||||
|
||||
Scope (see GitHub issue #1789): only the 6 heuristics that a plain `iw scan`
|
||||
snapshot can see are implemented here, plus the addendum's "known device
|
||||
turned rogue" escalation (cross-referencing a detection's own BSSID against
|
||||
NetAlertX's Devices table - not the deauth/probe/beacon source-MAC version
|
||||
of that idea, which still needs monitor-mode data this plugin doesn't have).
|
||||
Deauth/probe-flood/beacon-flood themselves need real monitor-mode frame
|
||||
capture (rate over time, not a point-in-time scan) and are out of scope for
|
||||
this plugin - see a dedicated monitor-mode tool (e.g. ESP32 WiFi Canary,
|
||||
https://github.com/simeononsecurity/esp32-wifi-canary) for those.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pytz import timezone
|
||||
|
||||
INSTALL_PATH = os.getenv('NETALERTX_APP', '/app')
|
||||
sys.path.extend([f"{INSTALL_PATH}/server/plugins", f"{INSTALL_PATH}/server"])
|
||||
|
||||
from const import logPath # noqa: E402, E261
|
||||
from plugin_helper import Plugin_Objects, normalize_mac, decode_settings_base64 # noqa: E402, E261
|
||||
from logger import mylog, Logger # noqa: E402, E261
|
||||
from helper import get_setting_value # noqa: E402, E261
|
||||
from models.device_instance import DeviceInstance # noqa: E402, E261
|
||||
|
||||
import conf # noqa: E402, E261
|
||||
|
||||
conf.tz = timezone(get_setting_value('TIMEZONE'))
|
||||
Logger(get_setting_value('LOG_LEVEL'))
|
||||
|
||||
pluginName = 'WIFICANARY'
|
||||
|
||||
LOG_PATH = logPath + '/plugins'
|
||||
LOG_FILE = os.path.join(LOG_PATH, f'script.{pluginName}.log')
|
||||
RESULT_FILE = os.path.join(LOG_PATH, f'last_result.{pluginName}.log')
|
||||
|
||||
plugin_objects = Plugin_Objects(RESULT_FILE)
|
||||
|
||||
# Global signatures, independent of any trusted-AP baseline.
|
||||
PWNAGOTCHI_BSSID = 'de:ad:be:ef:de:ad'
|
||||
PINEAPPLE_OUI_MID = ('13', '37') # BSSID octets [1:3] == 13:37
|
||||
|
||||
# Weakest-to-strongest, used to detect a downgrade.
|
||||
SECURITY_RANK = {'open': 0, 'wep': 1, 'wpa': 2, 'wpa2': 3, 'wpa3': 4}
|
||||
|
||||
|
||||
def main():
|
||||
"""Scan once, compare against the configured trusted-AP baseline, and
|
||||
emit one CurrentScan row per anomaly found."""
|
||||
mylog('verbose', [f'[{pluginName}] In script'])
|
||||
|
||||
iface = get_setting_value('WIFICANARY_IFACE')
|
||||
if not iface:
|
||||
mylog('none', [f'[{pluginName}] WIFICANARY_IFACE is not set - nothing to scan'])
|
||||
plugin_objects.write_result_file()
|
||||
return 0
|
||||
|
||||
trusted_aps = get_trusted_aps()
|
||||
timeout = get_setting_value('WIFICANARY_RUN_TIMEOUT') or 60
|
||||
|
||||
aps = scan(iface, timeout)
|
||||
mylog('verbose', [f'[{pluginName}] Parsed {len(aps)} APs from scan on {iface}'])
|
||||
|
||||
detections = []
|
||||
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:
|
||||
plugin_objects.add_object(
|
||||
primaryId=det['bssid'],
|
||||
secondaryId=det['motor'],
|
||||
watched1=det['reason'],
|
||||
watched2=det['security'],
|
||||
watched3=det['signal'],
|
||||
watched4=det['oui'],
|
||||
extra=det['ssid'],
|
||||
foreignKey=det['bssid'],
|
||||
helpVal1=normalize_mac(det['bssid']),
|
||||
helpVal2='1', # scanCreatesDevice - every row here is an anomaly
|
||||
helpVal3='normal', # scanNotificationMode - these are meant to alert
|
||||
helpVal4='1', # scanPresence - detected in this scan cycle
|
||||
)
|
||||
|
||||
mylog('verbose', [f'[{pluginName}] {len(detections)} anomalies'])
|
||||
plugin_objects.write_result_file()
|
||||
return 0
|
||||
|
||||
|
||||
def get_trusted_aps():
|
||||
"""Decode the WIFICANARY_trusted_aps nested setting into a list of dicts
|
||||
with ssid/bssid/security_set keys. security_set is the set of every
|
||||
encryption this network is allowed to legitimately use (e.g. a WPA2/WPA3
|
||||
transition-mode AP would list both)."""
|
||||
raw_entries = get_setting_value('WIFICANARY_trusted_aps') or []
|
||||
trusted = []
|
||||
for raw in raw_entries:
|
||||
cfg = decode_settings_base64(raw)
|
||||
ssid = cfg.get('WIFICANARY_TRUSTED_SSID', '').strip()
|
||||
if not ssid:
|
||||
continue
|
||||
bssid = cfg.get('WIFICANARY_TRUSTED_BSSID', '').strip().lower()
|
||||
trusted.append({
|
||||
'ssid': ssid,
|
||||
'bssid': normalize_mac(bssid) if bssid else '',
|
||||
'security_set': parse_security_set(cfg.get('WIFICANARY_TRUSTED_SECURITY')),
|
||||
})
|
||||
return trusted
|
||||
|
||||
|
||||
def parse_security_set(raw_value):
|
||||
"""WIFICANARY_TRUSTED_SECURITY is a multi-select `array` setting - the
|
||||
frontend sends its value as a JSON-encoded list string (e.g.
|
||||
'["wpa2","wpa3"]'), not a real list, since it travels through the
|
||||
popupForm's generic decode_settings_base64() path rather than the
|
||||
top-level array-setting one. Falls back to {'wpa2'} for a blank/missing/
|
||||
malformed value, matching config.json's own default_value."""
|
||||
if not raw_value:
|
||||
return {'wpa2'}
|
||||
try:
|
||||
values = json.loads(raw_value) if isinstance(raw_value, str) else raw_value
|
||||
except (TypeError, ValueError):
|
||||
return {'wpa2'}
|
||||
security_set = {str(v).strip().lower() for v in values if str(v).strip()}
|
||||
return security_set or {'wpa2'}
|
||||
|
||||
|
||||
def scan(iface, timeout):
|
||||
"""Run `iw dev <iface> scan` and parse the output into a list of AP dicts
|
||||
(bssid/ssid/security/signal/oui)."""
|
||||
cmd = ['sudo', 'iw', 'dev', iface, 'scan']
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
mylog('none', [f'[{pluginName}] scan on {iface} timed out after {timeout}s'])
|
||||
return []
|
||||
except FileNotFoundError:
|
||||
mylog('none', [f'[{pluginName}] `iw` not found - is it installed on this host?'])
|
||||
return []
|
||||
|
||||
if result.returncode != 0:
|
||||
mylog('none', [f'[{pluginName}] scan on {iface} failed: {result.stderr.strip()}'])
|
||||
return []
|
||||
|
||||
return parse_iw_scan(result.stdout)
|
||||
|
||||
|
||||
def parse_iw_scan(output):
|
||||
"""Parse `iw scan` text output into a list of AP dicts."""
|
||||
aps = []
|
||||
current = None
|
||||
|
||||
for line in output.splitlines():
|
||||
bss_match = re.match(r'^BSS ([0-9a-fA-F:]{17})', line)
|
||||
if bss_match:
|
||||
if current and current.get('ssid'):
|
||||
aps.append(current)
|
||||
current = {
|
||||
'bssid': bss_match.group(1).lower(),
|
||||
'ssid': '',
|
||||
'security': 'open',
|
||||
'signal': '',
|
||||
}
|
||||
continue
|
||||
|
||||
if current is None:
|
||||
continue
|
||||
|
||||
stripped = line.strip()
|
||||
|
||||
m = re.match(r'^SSID:\s?(.*)$', stripped)
|
||||
if m:
|
||||
current['ssid'] = m.group(1)
|
||||
continue
|
||||
|
||||
if stripped.startswith('capability:') and 'Privacy' in stripped:
|
||||
if current['security'] == 'open':
|
||||
# Privacy bit set but no RSN/WPA IE found below -> most likely WEP
|
||||
# (or a TKIP-only WPA1 network with no separate IE, rare in practice).
|
||||
current['security'] = 'wep'
|
||||
|
||||
if stripped.startswith('RSN:'):
|
||||
current['security'] = 'wpa2'
|
||||
continue
|
||||
|
||||
if stripped.startswith('WPA:'):
|
||||
if current['security'] not in ('wpa2', 'wpa3'):
|
||||
current['security'] = 'wpa'
|
||||
continue
|
||||
|
||||
if 'Authentication suites' in stripped and 'SAE' in stripped:
|
||||
current['security'] = 'wpa3'
|
||||
continue
|
||||
|
||||
m = re.match(r'^signal:\s*(-?\d+(?:\.\d+)?)\s*dBm', stripped)
|
||||
if m:
|
||||
current['signal'] = m.group(1)
|
||||
|
||||
if current and current.get('ssid'):
|
||||
aps.append(current)
|
||||
|
||||
for ap in aps:
|
||||
parts = ap['bssid'].split(':')
|
||||
ap['oui'] = ':'.join(parts[0:3]) if len(parts) >= 3 else ''
|
||||
|
||||
return aps
|
||||
|
||||
|
||||
def check_global_signatures(aps):
|
||||
"""Motors 1-2: absolute signatures that don't depend on any baseline -
|
||||
a known pwnagotchi BSSID, or a WiFi Pineapple's default OUI pattern."""
|
||||
found = []
|
||||
for ap in aps:
|
||||
parts = ap['bssid'].split(':')
|
||||
|
||||
if ap['bssid'] == PWNAGOTCHI_BSSID:
|
||||
found.append(make_detection(ap, 'pwnagotchi_nearby',
|
||||
f"Pwnagotchi signature BSSID seen ({ap['bssid']})"))
|
||||
|
||||
if len(parts) >= 3 and (parts[1], parts[2]) == PINEAPPLE_OUI_MID:
|
||||
found.append(make_detection(ap, 'pineapple_oui',
|
||||
f"WiFi Pineapple default OUI pattern on BSSID {ap['bssid']}"))
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def is_downgrade(observed_security, accepted):
|
||||
"""True if `observed_security` is neither explicitly accepted nor at
|
||||
least as strong as the strongest accepted value - the shared threshold
|
||||
check_trusted_aps() uses for both a known radio weakening over time and
|
||||
a different radio cloning the SSID with lesser security."""
|
||||
if observed_security in accepted:
|
||||
return False
|
||||
observed_rank = SECURITY_RANK.get(observed_security, 0)
|
||||
strongest_accepted_rank = max(SECURITY_RANK.get(s, 0) for s in accepted)
|
||||
return observed_rank < strongest_accepted_rank
|
||||
|
||||
|
||||
def check_trusted_aps(aps, trusted_aps):
|
||||
"""Motors 3-5: evil twin / weaker-security clone, baseline AP absent
|
||||
while a clone is present, and security downgrade - all evaluated
|
||||
against the user's trusted-AP baseline (WIFICANARY_trusted_aps)."""
|
||||
found = []
|
||||
|
||||
# Every explicitly-trusted BSSID, grouped by SSID - lets a match get
|
||||
# excluded from another entry's evaluation below (each trusted radio
|
||||
# is judged only against its own entry's accepted set, not a sibling
|
||||
# entry's - e.g. a main AP requiring wpa3 must not flag a legitimately
|
||||
# separately-trusted extender that only accepts wpa2).
|
||||
trusted_bssids_by_ssid = {}
|
||||
for t in trusted_aps:
|
||||
if t['bssid']:
|
||||
trusted_bssids_by_ssid.setdefault(t['ssid'], set()).add(t['bssid'])
|
||||
|
||||
for trust in trusted_aps:
|
||||
matches = [ap for ap in aps if ap['ssid'] == trust['ssid']]
|
||||
other_trusted_bssids = trusted_bssids_by_ssid.get(trust['ssid'], set()) - {trust['bssid']}
|
||||
baseline_bssid_seen = any(ap['bssid'] == trust['bssid'] for ap in matches) if trust['bssid'] else True
|
||||
accepted = trust['security_set']
|
||||
expected_desc = ' or '.join(sorted(accepted))
|
||||
|
||||
for ap in matches:
|
||||
if ap['bssid'] in other_trusted_bssids:
|
||||
continue # evaluated against its own trusted entry instead
|
||||
|
||||
if not is_downgrade(ap['security'], accepted):
|
||||
continue
|
||||
|
||||
same_radio = trust['bssid'] and ap['bssid'] == trust['bssid']
|
||||
|
||||
if same_radio or not trust['bssid']:
|
||||
# The radio we already trust for this SSID (or, with no
|
||||
# BSSID configured, the only radio we have to go on).
|
||||
found.append(make_detection(ap, 'security_downgrade',
|
||||
f"'{trust['ssid']}' now broadcasting {ap['security']}, "
|
||||
f"expected {expected_desc}"))
|
||||
continue
|
||||
|
||||
# A different BSSID broadcasting the same protected SSID with
|
||||
# weaker-than-accepted security. A same- or stronger-encrypted
|
||||
# different radio is check_duplicate_ssid's job instead, via
|
||||
# OUI mismatch, not this one's.
|
||||
if trust['bssid'] and not baseline_bssid_seen:
|
||||
found.append(make_detection(ap, 'absent_baseline_clone',
|
||||
f"'{trust['ssid']}' baseline AP ({trust['bssid']}) missing, "
|
||||
f"weaker clone ({ap['security']}) seen on {ap['bssid']}"))
|
||||
else:
|
||||
found.append(make_detection(ap, 'evil_twin',
|
||||
f"'{trust['ssid']}' cloned with weaker security ({ap['security']}) "
|
||||
f"by {ap['bssid']} (expected {expected_desc})"))
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def check_duplicate_ssid(aps, trusted_aps):
|
||||
"""Motor 6: a trusted SSID broadcast by more than one OUI at once - a
|
||||
plausible impostor sharing a protected network's name. Restricted to
|
||||
SSIDs the user has explicitly claimed via the trusted-AP list, so an
|
||||
untracked network's own AP diversity (e.g. a cafe chain) never triggers
|
||||
this. A real multi-radio setup for the *same* trusted SSID (a range
|
||||
extender, a mesh kit - often a different OUI than the main AP) is
|
||||
expected to be listed as its own WIFICANARY_trusted_aps entry (same
|
||||
SSID, its own BSSID) - every trusted BSSID's OUI for a given SSID is
|
||||
whitelisted, not just one."""
|
||||
trusted_ssids = {t['ssid'] for t in trusted_aps}
|
||||
trusted_ouis_by_ssid = {}
|
||||
for t in trusted_aps:
|
||||
if t['bssid']:
|
||||
trusted_ouis_by_ssid.setdefault(t['ssid'], set()).add(':'.join(t['bssid'].split(':')[:3]))
|
||||
|
||||
found = []
|
||||
|
||||
by_ssid = {}
|
||||
for ap in aps:
|
||||
if ap['ssid'] in trusted_ssids:
|
||||
by_ssid.setdefault(ap['ssid'], []).append(ap)
|
||||
|
||||
for ssid, group in by_ssid.items():
|
||||
ouis = {ap['oui'] for ap in group}
|
||||
if len(ouis) < 2:
|
||||
continue
|
||||
|
||||
trusted_ouis = trusted_ouis_by_ssid.get(ssid)
|
||||
if trusted_ouis:
|
||||
# One or more explicit trusted BSSIDs exist for this SSID -
|
||||
# their OUIs are the whitelist. Anything else sharing the SSID
|
||||
# is suspect regardless of how common it is in this scan.
|
||||
for ap in group:
|
||||
if ap['oui'] not in trusted_ouis:
|
||||
found.append(make_detection(ap, 'duplicate_ssid_diff_vendor',
|
||||
f"'{ssid}' also seen from OUI {ap['oui']} on {ap['bssid']} "
|
||||
f"(trusted OUIs for this SSID are {', '.join(sorted(trusted_ouis))})"))
|
||||
continue
|
||||
|
||||
# No trusted BSSID configured for this SSID (wildcard-only entry) -
|
||||
# fall back to majority OUI as the presumed "expected" one.
|
||||
primary_oui = max(ouis, key=lambda o: sum(1 for ap in group if ap['oui'] == o))
|
||||
for ap in group:
|
||||
if ap['oui'] != primary_oui:
|
||||
found.append(make_detection(ap, 'duplicate_ssid_diff_vendor',
|
||||
f"'{ssid}' also seen from OUI {ap['oui']} on {ap['bssid']} "
|
||||
f"(other APs for this SSID are {primary_oui})"))
|
||||
|
||||
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
|
||||
NetAlertX already knows and trusts, now behaving like an attacker
|
||||
(compromised firmware, a misconfigured AP mode, etc). That's a much
|
||||
more urgent signal than "unknown pineapple nearby", so it's called out
|
||||
separately - mutates each matching detection's motor/reason in place
|
||||
rather than returning a new list.
|
||||
|
||||
One DeviceInstance().getAllByMacs() call for every distinct BSSID in this
|
||||
run, not one getByMac() per detection - a run can easily produce several
|
||||
detections (multiple motors firing on the same BSSID, or several rogue
|
||||
APs at once), and each would otherwise be its own DB round-trip.
|
||||
|
||||
Only escalates when the existing Devices row was NOT itself created by
|
||||
a previous WIFICANARY run - otherwise every anomaly would trivially
|
||||
"escalate" against its own prior detection from run 2 onward."""
|
||||
bssids = [det['bssid'] for det in detections]
|
||||
known_by_mac = DeviceInstance().getAllByMacs(bssids)
|
||||
|
||||
for det in detections:
|
||||
existing = known_by_mac.get(det['bssid'].lower())
|
||||
if not existing or (existing.get('devSourcePlugin') or '') == 'WIFICANARY':
|
||||
continue
|
||||
|
||||
device_label = existing.get('devName') or det['bssid']
|
||||
det['motor'] = f"{det['motor']}_known_device"
|
||||
det['reason'] = f"Known device '{device_label}' now behaving like a rogue AP: {det['reason']}"
|
||||
|
||||
|
||||
def make_detection(ap, motor, reason):
|
||||
"""Build the dict consumed by main()'s add_object() call for one AP anomaly."""
|
||||
return {
|
||||
'bssid': ap['bssid'],
|
||||
'ssid': ap['ssid'] or 'null',
|
||||
'motor': motor,
|
||||
'reason': reason,
|
||||
'security': ap['security'],
|
||||
'signal': ap['signal'] or 'null',
|
||||
'oui': ap['oui'] or 'null',
|
||||
}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -3,6 +3,8 @@ Unit tests for server/models/device_instance.py's DeviceInstance model methods.
|
||||
|
||||
Covers:
|
||||
- DeviceInstance.getAllByName()
|
||||
- DeviceInstance.getByMac()
|
||||
- DeviceInstance.getAllByMacs()
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -92,5 +94,53 @@ class TestGetByMac(unittest.TestCase):
|
||||
self.assertIsNone(inst.getByMac("00:00:00:00:00:00"))
|
||||
|
||||
|
||||
class TestGetAllByMacs(unittest.TestCase):
|
||||
"""One query for a batch of MACs - added for callers (e.g. WIFICANARY's
|
||||
known-device-turned-rogue check) that would otherwise call getByMac()
|
||||
once per item in a loop, one DB round-trip each."""
|
||||
|
||||
def setUp(self):
|
||||
self.conn = make_db()
|
||||
insert_device_from_dict(self.conn, make_device_dict("aa:bb:cc:dd:ee:01", devName="host-1"))
|
||||
insert_device_from_dict(self.conn, make_device_dict("aa:bb:cc:dd:ee:02", devName="host-2"))
|
||||
self.conn.commit()
|
||||
|
||||
def _instance(self):
|
||||
from models.device_instance import DeviceInstance
|
||||
inst = DeviceInstance()
|
||||
|
||||
def _fetchall(q, p=()):
|
||||
rows = self.conn.execute(q, p).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
inst._fetchall = _fetchall
|
||||
return inst
|
||||
|
||||
def test_returns_dict_keyed_by_lowercased_mac(self):
|
||||
inst = self._instance()
|
||||
result = inst.getAllByMacs(["AA:BB:CC:DD:EE:01", "aa:bb:cc:dd:ee:02"])
|
||||
self.assertEqual(set(result.keys()), {"aa:bb:cc:dd:ee:01", "aa:bb:cc:dd:ee:02"})
|
||||
self.assertEqual(result["aa:bb:cc:dd:ee:01"]["devName"], "host-1")
|
||||
|
||||
def test_unmatched_mac_simply_absent_from_result(self):
|
||||
inst = self._instance()
|
||||
result = inst.getAllByMacs(["aa:bb:cc:dd:ee:01", "00:00:00:00:00:00"])
|
||||
self.assertEqual(set(result.keys()), {"aa:bb:cc:dd:ee:01"})
|
||||
|
||||
def test_duplicate_macs_collapsed_to_one_query_param(self):
|
||||
inst = self._instance()
|
||||
result = inst.getAllByMacs(["aa:bb:cc:dd:ee:01", "aa:bb:cc:dd:ee:01"])
|
||||
self.assertEqual(set(result.keys()), {"aa:bb:cc:dd:ee:01"})
|
||||
|
||||
def test_empty_input_returns_empty_dict_without_querying(self):
|
||||
inst = self._instance()
|
||||
inst._fetchall = lambda q, p=(): (_ for _ in ()).throw(AssertionError("should not query"))
|
||||
self.assertEqual(inst.getAllByMacs([]), {})
|
||||
|
||||
def test_blank_entries_are_filtered_out(self):
|
||||
inst = self._instance()
|
||||
result = inst.getAllByMacs(["aa:bb:cc:dd:ee:01", "", None])
|
||||
self.assertEqual(set(result.keys()), {"aa:bb:cc:dd:ee:01"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,757 @@
|
||||
"""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) == []
|
||||
|
||||
|
||||
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)
|
||||
# must not flag the extender - it was being matched against the main
|
||||
# AP's accepted set instead of its own, both when the main AP is
|
||||
# present and when it's out of range.
|
||||
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'),
|
||||
]
|
||||
assert wificanary.check_trusted_aps(aps, trusted) == []
|
||||
# Main AP out of range - the extender alone must still be clean.
|
||||
assert wificanary.check_trusted_aps([aps[1]], 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'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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']))
|
||||
Reference in new issue
Block a user