diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index dfc6e7296..3c0288a09 100755 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -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)" && \ diff --git a/Dockerfile b/Dockerfile index 1024ce5ad..6d606f766 100755 --- a/Dockerfile +++ b/Dockerfile @@ -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)" && \ diff --git a/Dockerfile.debian b/Dockerfile.debian index 0755f4a96..9767cdac7 100755 --- a/Dockerfile.debian +++ b/Dockerfile.debian @@ -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 diff --git a/server/models/device_instance.py b/server/models/device_instance.py index 415e2e253..df2e07d53 100755 --- a/server/models/device_instance.py +++ b/server/models/device_instance.py @@ -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 = ? diff --git a/server/plugins/wificanary/README.md b/server/plugins/wificanary/README.md new file mode 100644 index 000000000..146f59fbc --- /dev/null +++ b/server/plugins/wificanary/README.md @@ -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` diff --git a/server/plugins/wificanary/config.json b/server/plugins/wificanary/config.json new file mode 100644 index 000000000..4e51758e3 --- /dev/null +++ b/server/plugins/wificanary/config.json @@ -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": "" + } + ], + "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": "
schedule uses the scheduling settings below; once 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 WIFICANARY_RUN is set to schedule. Cron-like format, e.g. validate at crontab.guru."
+ }
+ ]
+ },
+ {
+ "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. wlan0. 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 open, 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. watchedValue1 is the reason, watchedValue2 is observed security, watchedValue3 is signal strength, watchedValue4 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 new is the meaningful one here."
+ }
+ ]
+ }
+ ]
+}
diff --git a/server/plugins/wificanary/script.py b/server/plugins/wificanary/script.py
new file mode 100644
index 000000000..59cb9f357
--- /dev/null
+++ b/server/plugins/wificanary/script.py
@@ -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