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:
Mauricio CamayoandClaude Sonnet 5 committed 2026-09-23 15:56:47 -05:00
1 parent cd1d0ed11e
commit d0a3a5416b
7 files changed
+2129 -1

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
## 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 container needs `iw` installed and enough privilege to run `sudo iw dev <iface> scan` (same style of requirement as `ARPSCAN`'s `sudo arp-scan`) - host networking and `NET_ADMIN`, typically.
#### `iw` isn't in the published image yet
The official NetAlertX image doesn't ship `iw` (it has `arp-scan`/`nmap`/etc., but nothing WiFi-specific), so `WIFICANARY_IFACE` will fail to scan until it's added. Same fix `Dockerfile` already applies to `arp-scan`/`nmap`/`nbtscan`/`traceroute`: install the package, then `setcap` it so it works for the non-root runtime user without needing real `sudo` (this hardened image's `sudo` is a no-op passthrough, not a privilege escalation - see the `Dockerfile`'s final stage).
```dockerfile
# In the apk add line that already installs arp-scan/nmap/nbtscan/etc.:
RUN apk add --no-cache ... iw ...
# Alongside the existing setcap lines for nmap/arp-scan/nbtscan/traceroute:
setcap cap_net_raw,cap_net_admin+eip /usr/sbin/iw && \
```
Until this lands in the published `ghcr.io/jokob-sk/netalertx` image, build your own from this repo's `Dockerfile` (`docker compose build`) rather than pulling the tag - pulling the published image will have `WIFICANARY_IFACE` scans fail with "command not found."
### 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`
+973
View File
@@ -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."
}
]
}
]
}
+385
View File
@@ -0,0 +1,385 @@
#!/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)
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 = []
for trust in trusted_aps:
matches = [ap for ap in aps if ap['ssid'] == trust['ssid']]
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 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 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()