From aeb4825b255bb41618b330fcb244c923f77a15fc Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Tue, 3 Feb 2026 15:26:18 +0100 Subject: [PATCH] feat(mac-guardian): Add WiFi MAC security monitor Pure-shell WiFi MAC address security monitor detecting randomized MACs, OUI anomalies, MAC floods, and spoofing. Integrates with CrowdSec via JSON log parsing and provides real-time hostapd hotplug detection. Co-Authored-By: Claude Opus 4.5 --- package/secubox/mac-guardian/Makefile | 63 ++ .../files/etc/config/mac-guardian | 27 + .../acquis.d/secubox-mac-guardian.yaml | 4 + .../s01-parse/secubox-mac-guardian.yaml | 27 + .../crowdsec/scenarios/secubox-mac-flood.yaml | 12 + .../crowdsec/scenarios/secubox-mac-spoof.yaml | 9 + .../etc/hotplug.d/hostapd/20-mac-guardian | 64 ++ .../files/etc/init.d/mac-guardian | 26 + .../usr/lib/secubox/mac-guardian/functions.sh | 664 ++++++++++++++++++ .../usr/lib/secubox/mac-guardian/oui.tsv | 32 + .../mac-guardian/files/usr/sbin/mac-guardian | 248 +++++++ .../mac-guardian/tests/test_detection.sh | 189 +++++ .../mac-guardian/tests/test_functions.sh | 159 +++++ 13 files changed, 1524 insertions(+) create mode 100644 package/secubox/mac-guardian/Makefile create mode 100644 package/secubox/mac-guardian/files/etc/config/mac-guardian create mode 100644 package/secubox/mac-guardian/files/etc/crowdsec/acquis.d/secubox-mac-guardian.yaml create mode 100644 package/secubox/mac-guardian/files/etc/crowdsec/parsers/s01-parse/secubox-mac-guardian.yaml create mode 100644 package/secubox/mac-guardian/files/etc/crowdsec/scenarios/secubox-mac-flood.yaml create mode 100644 package/secubox/mac-guardian/files/etc/crowdsec/scenarios/secubox-mac-spoof.yaml create mode 100644 package/secubox/mac-guardian/files/etc/hotplug.d/hostapd/20-mac-guardian create mode 100644 package/secubox/mac-guardian/files/etc/init.d/mac-guardian create mode 100644 package/secubox/mac-guardian/files/usr/lib/secubox/mac-guardian/functions.sh create mode 100644 package/secubox/mac-guardian/files/usr/lib/secubox/mac-guardian/oui.tsv create mode 100644 package/secubox/mac-guardian/files/usr/sbin/mac-guardian create mode 100644 package/secubox/mac-guardian/tests/test_detection.sh create mode 100644 package/secubox/mac-guardian/tests/test_functions.sh diff --git a/package/secubox/mac-guardian/Makefile b/package/secubox/mac-guardian/Makefile new file mode 100644 index 00000000..35c71a95 --- /dev/null +++ b/package/secubox/mac-guardian/Makefile @@ -0,0 +1,63 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=mac-guardian +PKG_VERSION:=0.5.0 +PKG_RELEASE:=1 + +PKG_MAINTAINER:=Gandalf +PKG_LICENSE:=GPL-3.0-or-later + +include $(INCLUDE_DIR)/package.mk + +define Package/mac-guardian + SECTION:=net + CATEGORY:=Network + SUBMENU:=SecuBox + TITLE:=WiFi MAC Security Monitor + DEPENDS:=+hostapd-common +iwinfo +ip-full + PKGARCH:=all +endef + +define Package/mac-guardian/description + WiFi MAC address security monitor for SecuBox. + Detects randomized MACs, OUI anomalies, MAC floods, + and spoofing. Integrates with CrowdSec and provides + real-time hostapd hotplug detection. +endef + +define Package/mac-guardian/conffiles +/etc/config/mac-guardian +endef + +define Build/Compile +endef + +define Package/mac-guardian/install + $(INSTALL_DIR) $(1)/usr/sbin + $(INSTALL_BIN) ./files/usr/sbin/mac-guardian $(1)/usr/sbin/ + + $(INSTALL_DIR) $(1)/usr/lib/secubox/mac-guardian + $(INSTALL_BIN) ./files/usr/lib/secubox/mac-guardian/functions.sh $(1)/usr/lib/secubox/mac-guardian/ + $(INSTALL_DATA) ./files/usr/lib/secubox/mac-guardian/oui.tsv $(1)/usr/lib/secubox/mac-guardian/ + + $(INSTALL_DIR) $(1)/etc/config + $(INSTALL_CONF) ./files/etc/config/mac-guardian $(1)/etc/config/ + + $(INSTALL_DIR) $(1)/etc/init.d + $(INSTALL_BIN) ./files/etc/init.d/mac-guardian $(1)/etc/init.d/ + + $(INSTALL_DIR) $(1)/etc/hotplug.d/hostapd + $(INSTALL_BIN) ./files/etc/hotplug.d/hostapd/20-mac-guardian $(1)/etc/hotplug.d/hostapd/ + + $(INSTALL_DIR) $(1)/etc/crowdsec/acquis.d + $(INSTALL_DATA) ./files/etc/crowdsec/acquis.d/secubox-mac-guardian.yaml $(1)/etc/crowdsec/acquis.d/ + + $(INSTALL_DIR) $(1)/etc/crowdsec/parsers/s01-parse + $(INSTALL_DATA) ./files/etc/crowdsec/parsers/s01-parse/secubox-mac-guardian.yaml $(1)/etc/crowdsec/parsers/s01-parse/ + + $(INSTALL_DIR) $(1)/etc/crowdsec/scenarios + $(INSTALL_DATA) ./files/etc/crowdsec/scenarios/secubox-mac-flood.yaml $(1)/etc/crowdsec/scenarios/ + $(INSTALL_DATA) ./files/etc/crowdsec/scenarios/secubox-mac-spoof.yaml $(1)/etc/crowdsec/scenarios/ +endef + +$(eval $(call BuildPackage,mac-guardian)) diff --git a/package/secubox/mac-guardian/files/etc/config/mac-guardian b/package/secubox/mac-guardian/files/etc/config/mac-guardian new file mode 100644 index 00000000..ef613b94 --- /dev/null +++ b/package/secubox/mac-guardian/files/etc/config/mac-guardian @@ -0,0 +1,27 @@ +config mac-guardian 'main' + option enabled '0' + option debug '0' + option scan_interval '30' + +config detection 'detection' + option random_mac '1' + option oui_duplicates '1' + option oui_dup_threshold '5' + option mac_flip '1' + option flip_window '300' + option flip_threshold '10' + option spoof_detection '1' + +config enforcement 'enforcement' + option policy 'alert' + option quarantine_vlan '' + option notify_crowdsec '1' + +config whitelist 'whitelist' + # list mac 'aa:bb:cc:dd:ee:ff' + # list oui '00:50:E4' + +config reporting 'reporting' + option stats_file '/var/run/mac-guardian/stats.json' + option stats_interval '60' + option max_log_size '524288' diff --git a/package/secubox/mac-guardian/files/etc/crowdsec/acquis.d/secubox-mac-guardian.yaml b/package/secubox/mac-guardian/files/etc/crowdsec/acquis.d/secubox-mac-guardian.yaml new file mode 100644 index 00000000..6ec24501 --- /dev/null +++ b/package/secubox/mac-guardian/files/etc/crowdsec/acquis.d/secubox-mac-guardian.yaml @@ -0,0 +1,4 @@ +filenames: + - /var/log/mac-guardian.log +labels: + type: mac-guardian diff --git a/package/secubox/mac-guardian/files/etc/crowdsec/parsers/s01-parse/secubox-mac-guardian.yaml b/package/secubox/mac-guardian/files/etc/crowdsec/parsers/s01-parse/secubox-mac-guardian.yaml new file mode 100644 index 00000000..93a1d884 --- /dev/null +++ b/package/secubox/mac-guardian/files/etc/crowdsec/parsers/s01-parse/secubox-mac-guardian.yaml @@ -0,0 +1,27 @@ +onsuccess: next_stage +name: secubox/mac-guardian +description: "Parse SecuBox mac-guardian JSON events" +filter: "evt.Line.Labels.type == 'mac-guardian'" +nodes: + - grok: + apply_on: evt.Line.Raw + expression: "^%{GREEDYDATA:json_raw}$" + statics: + - parsed: json_data + expression: "JsonExtract(evt.Parsed.json_raw, '')" + - filter: "evt.Parsed.json_data != ''" + statics: + - meta: log_type + value: mac_guardian + - meta: service + value: mac-guardian + - meta: source_mac + expression: "JsonExtract(evt.Parsed.json_raw, 'mac')" + - parsed: event_type + expression: "JsonExtract(evt.Parsed.json_raw, 'event')" + - parsed: iface + expression: "JsonExtract(evt.Parsed.json_raw, 'iface')" + - parsed: details + expression: "JsonExtract(evt.Parsed.json_raw, 'details')" + - parsed: timestamp + expression: "JsonExtract(evt.Parsed.json_raw, 'ts')" diff --git a/package/secubox/mac-guardian/files/etc/crowdsec/scenarios/secubox-mac-flood.yaml b/package/secubox/mac-guardian/files/etc/crowdsec/scenarios/secubox-mac-flood.yaml new file mode 100644 index 00000000..19d06c1f --- /dev/null +++ b/package/secubox/mac-guardian/files/etc/crowdsec/scenarios/secubox-mac-flood.yaml @@ -0,0 +1,12 @@ +type: leaky +name: secubox/mac-flood +description: "Detect MAC address flood on a WiFi interface" +filter: "evt.Parsed.event_type in ['randomized_mac', 'new_station']" +groupby: "evt.Parsed.iface" +capacity: 10 +leakspeed: 15s +blackhole: 5m +remediation: false +labels: + service: mac-guardian + type: wifi_mac_flood diff --git a/package/secubox/mac-guardian/files/etc/crowdsec/scenarios/secubox-mac-spoof.yaml b/package/secubox/mac-guardian/files/etc/crowdsec/scenarios/secubox-mac-spoof.yaml new file mode 100644 index 00000000..078c69a2 --- /dev/null +++ b/package/secubox/mac-guardian/files/etc/crowdsec/scenarios/secubox-mac-spoof.yaml @@ -0,0 +1,9 @@ +type: trigger +name: secubox/mac-spoof +description: "Detect MAC address spoofing on WiFi" +filter: "evt.Parsed.event_type == 'spoof_detected'" +groupby: "evt.Meta.source_mac" +remediation: false +labels: + service: mac-guardian + type: wifi_mac_spoof diff --git a/package/secubox/mac-guardian/files/etc/hotplug.d/hostapd/20-mac-guardian b/package/secubox/mac-guardian/files/etc/hotplug.d/hostapd/20-mac-guardian new file mode 100644 index 00000000..1c7a0e52 --- /dev/null +++ b/package/secubox/mac-guardian/files/etc/hotplug.d/hostapd/20-mac-guardian @@ -0,0 +1,64 @@ +#!/bin/sh +# mac-guardian hotplug handler for hostapd events +# Provides real-time detection on station connect/disconnect + +# Exit early for irrelevant events or missing data +[ -n "$ACTION" ] || exit 0 +[ -n "$MACADDR" ] || exit 0 + +# Only handle station events +case "$ACTION" in + AP-STA-CONNECTED|AP-STA-DISCONNECTED) ;; + *) exit 0 ;; +esac + +# Check if enabled +. /lib/functions.sh +config_load mac-guardian +config_get enabled main enabled 0 +[ "$enabled" = "1" ] || exit 0 + +# Fork to background for fast return to hostapd +{ + . /usr/lib/secubox/mac-guardian/functions.sh + mg_load_config + mg_init + + mac=$(mg_normalize_mac "$MACADDR") + iface="${INTERFACE:-unknown}" + + case "$ACTION" in + AP-STA-CONNECTED) + if mg_validate_mac "$mac"; then + if ! mg_is_whitelisted "$mac"; then + mg_lock && { + mg_check_station "$mac" "" "$iface" + mg_unlock + } + else + mg_lock && { + local hostname + hostname=$(mg_resolve_hostname "$mac") + mg_db_upsert "$mac" "$iface" "$hostname" + mg_unlock + } + fi + fi + ;; + AP-STA-DISCONNECTED) + # Lightweight: just update last_seen + if mg_validate_mac "$mac"; then + mg_lock && { + local existing + existing=$(mg_db_lookup "$mac") + if [ -n "$existing" ]; then + local hostname + hostname=$(mg_resolve_hostname "$mac") + mg_db_upsert "$mac" "$iface" "$hostname" + fi + mg_unlock + } + fi + ;; + esac +} & diff --git a/package/secubox/mac-guardian/files/etc/init.d/mac-guardian b/package/secubox/mac-guardian/files/etc/init.d/mac-guardian new file mode 100644 index 00000000..27f632e9 --- /dev/null +++ b/package/secubox/mac-guardian/files/etc/init.d/mac-guardian @@ -0,0 +1,26 @@ +#!/bin/sh /etc/rc.common + +START=95 +STOP=10 +USE_PROCD=1 + +PROG="/usr/sbin/mac-guardian" + +start_service() { + local enabled + config_load mac-guardian + config_get enabled main enabled 0 + [ "$enabled" = "1" ] || return 0 + + procd_open_instance + procd_set_param command "$PROG" start + procd_set_param pidfile /var/run/mac-guardian.pid + procd_set_param respawn 3600 5 5 + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance +} + +service_triggers() { + procd_add_reload_trigger "mac-guardian" "wireless" +} diff --git a/package/secubox/mac-guardian/files/usr/lib/secubox/mac-guardian/functions.sh b/package/secubox/mac-guardian/files/usr/lib/secubox/mac-guardian/functions.sh new file mode 100644 index 00000000..ce670ac1 --- /dev/null +++ b/package/secubox/mac-guardian/files/usr/lib/secubox/mac-guardian/functions.sh @@ -0,0 +1,664 @@ +#!/bin/sh +# mac-guardian core functions library +# All functions prefixed mg_ + +MG_VERSION="0.5.0" +MG_RUNDIR="/var/run/mac-guardian" +MG_DBFILE="$MG_RUNDIR/known.db" +MG_LOCKDIR="$MG_RUNDIR/lock" +MG_LOGFILE="/var/log/mac-guardian.log" +MG_EVENTS_LOG="/var/log/mac-guardian.log" +MG_OUI_FILE="/usr/lib/secubox/mac-guardian/oui.tsv" + +# --- Config variables (populated by mg_load_config) --- +MG_ENABLED=0 +MG_DEBUG=0 +MG_SCAN_INTERVAL=30 +MG_DETECT_RANDOM=1 +MG_DETECT_OUI_DUP=1 +MG_OUI_DUP_THRESHOLD=5 +MG_DETECT_FLIP=1 +MG_FLIP_WINDOW=300 +MG_FLIP_THRESHOLD=10 +MG_DETECT_SPOOF=1 +MG_POLICY="alert" +MG_QUARANTINE_VLAN="" +MG_NOTIFY_CROWDSEC=1 +MG_STATS_FILE="/var/run/mac-guardian/stats.json" +MG_STATS_INTERVAL=60 +MG_MAX_LOG_SIZE=524288 + +# Whitelist arrays stored as newline-separated strings +MG_WL_MACS="" +MG_WL_OUIS="" + +# Runtime counters +MG_START_TIME="" +MG_TOTAL_SCANS=0 +MG_TOTAL_ALERTS=0 + +# ============================================================ +# Init / Config +# ============================================================ + +mg_init() { + mkdir -p "$MG_RUNDIR" + touch "$MG_DBFILE" + touch "$MG_EVENTS_LOG" + MG_START_TIME=$(date +%s) + MG_TOTAL_SCANS=0 + MG_TOTAL_ALERTS=0 +} + +mg_load_config() { + . /lib/functions.sh + + config_load mac-guardian + + # main section + config_get MG_ENABLED main enabled 0 + config_get MG_DEBUG main debug 0 + config_get MG_SCAN_INTERVAL main scan_interval 30 + + # detection section + config_get MG_DETECT_RANDOM detection random_mac 1 + config_get MG_DETECT_OUI_DUP detection oui_duplicates 1 + config_get MG_OUI_DUP_THRESHOLD detection oui_dup_threshold 5 + config_get MG_DETECT_FLIP detection mac_flip 1 + config_get MG_FLIP_WINDOW detection flip_window 300 + config_get MG_FLIP_THRESHOLD detection flip_threshold 10 + config_get MG_DETECT_SPOOF detection spoof_detection 1 + + # enforcement section + config_get MG_POLICY enforcement policy "alert" + config_get MG_QUARANTINE_VLAN enforcement quarantine_vlan "" + config_get MG_NOTIFY_CROWDSEC enforcement notify_crowdsec 1 + + # reporting section + config_get MG_STATS_FILE reporting stats_file "/var/run/mac-guardian/stats.json" + config_get MG_STATS_INTERVAL reporting stats_interval 60 + config_get MG_MAX_LOG_SIZE reporting max_log_size 524288 + + # whitelist lists + MG_WL_MACS="" + MG_WL_OUIS="" + + _mg_add_wl_mac() { + local val="$1" + val=$(mg_normalize_mac "$val") + if [ -z "$MG_WL_MACS" ]; then + MG_WL_MACS="$val" + else + MG_WL_MACS="$MG_WL_MACS +$val" + fi + } + + _mg_add_wl_oui() { + local val="$1" + val=$(echo "$val" | tr 'a-f' 'A-F') + if [ -z "$MG_WL_OUIS" ]; then + MG_WL_OUIS="$val" + else + MG_WL_OUIS="$MG_WL_OUIS +$val" + fi + } + + config_list_foreach whitelist mac _mg_add_wl_mac + config_list_foreach whitelist oui _mg_add_wl_oui +} + +# ============================================================ +# Locking (mkdir-based) +# ============================================================ + +mg_lock() { + local attempts=0 + local max_attempts=20 + + while [ $attempts -lt $max_attempts ]; do + if mkdir "$MG_LOCKDIR" 2>/dev/null; then + echo $$ > "$MG_LOCKDIR/pid" + return 0 + fi + + # Check for stale lock + if [ -f "$MG_LOCKDIR/pid" ]; then + local lock_pid + lock_pid=$(cat "$MG_LOCKDIR/pid" 2>/dev/null) + if [ -n "$lock_pid" ] && ! kill -0 "$lock_pid" 2>/dev/null; then + mg_log "warn" "Removing stale lock from PID $lock_pid" + rm -rf "$MG_LOCKDIR" + continue + fi + fi + + attempts=$((attempts + 1)) + sleep 0.5 + done + + mg_log "err" "Failed to acquire lock after ${max_attempts} attempts" + return 1 +} + +mg_unlock() { + rm -rf "$MG_LOCKDIR" +} + +# ============================================================ +# MAC analysis +# ============================================================ + +mg_validate_mac() { + local mac="$1" + echo "$mac" | grep -qE '^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$' +} + +mg_normalize_mac() { + echo "$1" | tr 'A-F' 'a-f' +} + +mg_is_randomized() { + local mac="$1" + local first_octet + first_octet=$(echo "$mac" | cut -d: -f1) + [ $((0x$first_octet & 0x02)) -ne 0 ] +} + +mg_get_oui() { + local mac="$1" + echo "$mac" | cut -d: -f1-3 | tr 'a-f' 'A-F' +} + +mg_oui_lookup() { + local mac="$1" + local oui + oui=$(mg_get_oui "$mac") + if [ -f "$MG_OUI_FILE" ]; then + grep -i "^${oui} " "$MG_OUI_FILE" 2>/dev/null | head -1 + fi +} + +# ============================================================ +# Whitelist +# ============================================================ + +mg_is_whitelisted() { + local mac="$1" + mac=$(mg_normalize_mac "$mac") + + # Check UCI mac whitelist + if [ -n "$MG_WL_MACS" ]; then + echo "$MG_WL_MACS" | grep -qFx "$mac" && return 0 + fi + + # Check UCI OUI whitelist + local oui + oui=$(mg_get_oui "$mac") + if [ -n "$MG_WL_OUIS" ]; then + echo "$MG_WL_OUIS" | grep -qFx "$oui" && return 0 + fi + + # Check /etc/ethers + if [ -f /etc/ethers ]; then + grep -qi "^${mac}" /etc/ethers 2>/dev/null && return 0 + fi + + # Check known.db for trusted status + local db_entry + db_entry=$(mg_db_lookup "$mac") + if [ -n "$db_entry" ]; then + local status + status=$(echo "$db_entry" | cut -d'|' -f7) + [ "$status" = "trusted" ] && return 0 + fi + + return 1 +} + +# ============================================================ +# known.db operations +# Format: MAC|OUI|FIRST_SEEN|LAST_SEEN|IFACE|HOSTNAME|STATUS +# ============================================================ + +mg_db_lookup() { + local mac="$1" + mac=$(mg_normalize_mac "$mac") + grep -i "^${mac}|" "$MG_DBFILE" 2>/dev/null | head -1 +} + +mg_db_upsert() { + local mac="$1" + local iface="$2" + local hostname="$3" + + mac=$(mg_normalize_mac "$mac") + local oui + oui=$(mg_get_oui "$mac") + local now + now=$(date +%s) + local existing + existing=$(mg_db_lookup "$mac") + + local tmpfile="${MG_DBFILE}.tmp.$$" + + if [ -z "$existing" ]; then + # New entry + local status="unknown" + if mg_is_whitelisted "$mac"; then + status="trusted" + fi + cp "$MG_DBFILE" "$tmpfile" + echo "${mac}|${oui}|${now}|${now}|${iface}|${hostname}|${status}" >> "$tmpfile" + mv "$tmpfile" "$MG_DBFILE" + else + # Update existing -- preserve first_seen and don't downgrade trusted/blocked + local old_first old_status + old_first=$(echo "$existing" | cut -d'|' -f3) + old_status=$(echo "$existing" | cut -d'|' -f7) + + # Never downgrade trusted or blocked + local new_status="$old_status" + + # Update hostname if we got a new one + local old_hostname + old_hostname=$(echo "$existing" | cut -d'|' -f6) + [ -z "$hostname" ] && hostname="$old_hostname" + + local new_line="${mac}|${oui}|${old_first}|${now}|${iface}|${hostname}|${new_status}" + + grep -iv "^${mac}|" "$MG_DBFILE" > "$tmpfile" 2>/dev/null || true + echo "$new_line" >> "$tmpfile" + mv "$tmpfile" "$MG_DBFILE" + fi +} + +mg_db_set_status() { + local mac="$1" + local status="$2" + + mac=$(mg_normalize_mac "$mac") + local existing + existing=$(mg_db_lookup "$mac") + [ -z "$existing" ] && return 1 + + local old_status + old_status=$(echo "$existing" | cut -d'|' -f7) + + # Never downgrade trusted to unknown, never change blocked + if [ "$old_status" = "trusted" ] && [ "$status" = "unknown" ]; then + return 0 + fi + if [ "$old_status" = "blocked" ] && [ "$status" != "trusted" ]; then + return 0 + fi + + local tmpfile="${MG_DBFILE}.tmp.$$" + local f1 f2 f3 f4 f5 f6 + + f1=$(echo "$existing" | cut -d'|' -f1) + f2=$(echo "$existing" | cut -d'|' -f2) + f3=$(echo "$existing" | cut -d'|' -f3) + f4=$(echo "$existing" | cut -d'|' -f4) + f5=$(echo "$existing" | cut -d'|' -f5) + f6=$(echo "$existing" | cut -d'|' -f6) + + grep -iv "^${mac}|" "$MG_DBFILE" > "$tmpfile" 2>/dev/null || true + echo "${f1}|${f2}|${f3}|${f4}|${f5}|${f6}|${status}" >> "$tmpfile" + mv "$tmpfile" "$MG_DBFILE" +} + +# ============================================================ +# Hostname resolution +# ============================================================ + +mg_resolve_hostname() { + local mac="$1" + mac=$(mg_normalize_mac "$mac") + local hostname="" + + # Check DHCP leases + if [ -f /tmp/dhcp.leases ]; then + hostname=$(awk -v m="$mac" 'tolower($2)==m {print $4; exit}' /tmp/dhcp.leases) + fi + + # Check hosts files + if [ -z "$hostname" ] || [ "$hostname" = "*" ]; then + hostname="" + for hfile in /tmp/hosts/*; do + [ -f "$hfile" ] || continue + local h + h=$(awk -v m="$mac" 'tolower($0)~m {print $2; exit}' "$hfile" 2>/dev/null) + if [ -n "$h" ]; then + hostname="$h" + break + fi + done + fi + + echo "$hostname" +} + +# ============================================================ +# Logging +# ============================================================ + +mg_log() { + local level="$1" + local msg="$2" + + logger -t mac-guardian -p "daemon.${level}" "$msg" + + if [ "$MG_DEBUG" = "1" ] && [ "$level" = "debug" ]; then + echo "[DEBUG] $msg" >&2 + fi +} + +mg_log_event() { + local event="$1" + local mac="$2" + local iface="$3" + local details="$4" + + local ts + ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date +%s) + + # Sanitize fields for JSON safety (remove quotes and backslashes) + mac=$(echo "$mac" | tr -d '"\\') + iface=$(echo "$iface" | tr -d '"\\') + details=$(echo "$details" | tr -d '"\\') + event=$(echo "$event" | tr -d '"\\') + + local json_line="{\"ts\":\"${ts}\",\"event\":\"${event}\",\"mac\":\"${mac}\",\"iface\":\"${iface}\",\"details\":\"${details}\"}" + + echo "$json_line" >> "$MG_EVENTS_LOG" + mg_log "notice" "EVENT ${event} mac=${mac} iface=${iface} ${details}" + + MG_TOTAL_ALERTS=$((MG_TOTAL_ALERTS + 1)) +} + +mg_log_rotate() { + if [ ! -f "$MG_EVENTS_LOG" ]; then + return 0 + fi + + local size + size=$(wc -c < "$MG_EVENTS_LOG" 2>/dev/null || echo 0) + + if [ "$size" -gt "$MG_MAX_LOG_SIZE" ]; then + cp "$MG_EVENTS_LOG" "${MG_EVENTS_LOG}.1" + : > "$MG_EVENTS_LOG" + mg_log "info" "Log rotated (was ${size} bytes)" + fi +} + +# ============================================================ +# Detection +# ============================================================ + +mg_check_station() { + local mac="$1" + local signal="$2" + local iface="$3" + + mac=$(mg_normalize_mac "$mac") + + # Validate MAC format + if ! mg_validate_mac "$mac"; then + mg_log "warn" "Invalid MAC format: $mac" + return 1 + fi + + # Skip whitelisted + if mg_is_whitelisted "$mac"; then + mg_log "debug" "Whitelisted MAC: $mac" + return 0 + fi + + local hostname + hostname=$(mg_resolve_hostname "$mac") + + # Check existing status + local existing + existing=$(mg_db_lookup "$mac") + if [ -n "$existing" ]; then + local old_status + old_status=$(echo "$existing" | cut -d'|' -f7) + if [ "$old_status" = "blocked" ]; then + mg_log "debug" "Blocked MAC seen again: $mac" + mg_enforce "$mac" "$iface" "blocked_reappeared" + return 0 + fi + fi + + local is_new=0 + if [ -z "$existing" ]; then + is_new=1 + mg_log_event "new_station" "$mac" "$iface" "hostname=${hostname}" + fi + + # Update database + mg_db_upsert "$mac" "$iface" "$hostname" + + # Randomized MAC detection + if [ "$MG_DETECT_RANDOM" = "1" ] && mg_is_randomized "$mac"; then + mg_log_event "randomized_mac" "$mac" "$iface" "locally_administered_bit_set" + mg_db_set_status "$mac" "suspect" + if [ "$MG_POLICY" != "alert" ]; then + mg_enforce "$mac" "$iface" "randomized_mac" + fi + fi + + # Spoof detection: signal strength anomaly for known MAC + if [ "$MG_DETECT_SPOOF" = "1" ] && [ -n "$signal" ] && [ -n "$existing" ]; then + local old_iface + old_iface=$(echo "$existing" | cut -d'|' -f5) + if [ "$old_iface" != "$iface" ] && [ -n "$old_iface" ]; then + mg_log_event "spoof_detected" "$mac" "$iface" "iface_change=${old_iface}->${iface}" + mg_db_set_status "$mac" "suspect" + mg_enforce "$mac" "$iface" "spoof_detected" + fi + fi +} + +mg_detect_oui_anomaly() { + local iface="$1" + + [ "$MG_DETECT_OUI_DUP" != "1" ] && return 0 + + # Count STAs per OUI on this interface + local oui_counts_file="${MG_RUNDIR}/oui_counts.$$" + + grep "|${iface}|" "$MG_DBFILE" 2>/dev/null | \ + cut -d'|' -f2 | sort | uniq -c | sort -rn > "$oui_counts_file" + + while read -r count oui; do + if [ "$count" -gt "$MG_OUI_DUP_THRESHOLD" ]; then + mg_log_event "oui_anomaly" "" "$iface" "oui=${oui} count=${count} threshold=${MG_OUI_DUP_THRESHOLD}" + fi + done < "$oui_counts_file" + + rm -f "$oui_counts_file" +} + +mg_detect_mac_flip() { + local iface="$1" + + [ "$MG_DETECT_FLIP" != "1" ] && return 0 + + local now + now=$(date +%s) + local window_start=$((now - MG_FLIP_WINDOW)) + + # Count MACs first seen within the window on this interface + local count=0 + + while IFS='|' read -r _mac _oui first_seen _last _if _host _status; do + [ -z "$first_seen" ] && continue + [ "$_if" != "$iface" ] && continue + if [ "$first_seen" -ge "$window_start" ] 2>/dev/null; then + count=$((count + 1)) + fi + done < "$MG_DBFILE" + + if [ "$count" -gt "$MG_FLIP_THRESHOLD" ]; then + mg_log_event "mac_flood" "" "$iface" "new_macs=${count} window=${MG_FLIP_WINDOW}s threshold=${MG_FLIP_THRESHOLD}" + fi +} + +# ============================================================ +# Enforcement +# ============================================================ + +mg_enforce() { + local mac="$1" + local iface="$2" + local reason="$3" + + mg_log "warn" "Enforcing policy=${MG_POLICY} on mac=${mac} iface=${iface} reason=${reason}" + + case "$MG_POLICY" in + alert) + # Log only -- already logged by caller + ;; + quarantine|deny) + # Try ebtables first + if command -v ebtables >/dev/null 2>&1; then + # Check if rule already exists + if ! ebtables -L INPUT 2>/dev/null | grep -qi "$mac"; then + ebtables -A INPUT -s "$mac" -j DROP 2>/dev/null + ebtables -A FORWARD -s "$mac" -j DROP 2>/dev/null + mg_log "notice" "ebtables DROP added for $mac" + fi + else + # Fallback: hostapd deny ACL + local hostapd_deny="/var/run/hostapd-${iface}.deny" + if [ ! -f "$hostapd_deny" ] || ! grep -qi "$mac" "$hostapd_deny" 2>/dev/null; then + echo "$mac" >> "$hostapd_deny" + mg_log "notice" "Added $mac to hostapd deny ACL for $iface" + fi + fi + + # For deny policy, also deauthenticate + if [ "$MG_POLICY" = "deny" ]; then + if command -v hostapd_cli >/dev/null 2>&1; then + hostapd_cli -i "$iface" deauthenticate "$mac" 2>/dev/null + mg_log "notice" "Deauthenticated $mac from $iface" + fi + fi + + mg_db_set_status "$mac" "blocked" + ;; + esac +} + +mg_unenforce() { + local mac="$1" + mac=$(mg_normalize_mac "$mac") + + # Remove ebtables rules + if command -v ebtables >/dev/null 2>&1; then + ebtables -D INPUT -s "$mac" -j DROP 2>/dev/null + ebtables -D FORWARD -s "$mac" -j DROP 2>/dev/null + fi + + # Remove from hostapd deny ACLs + local dfile + for dfile in /var/run/hostapd-*.deny; do + [ -f "$dfile" ] || continue + local tmpf="${dfile}.tmp.$$" + grep -iv "^${mac}$" "$dfile" > "$tmpf" 2>/dev/null || true + mv "$tmpf" "$dfile" + done +} + +# ============================================================ +# Stats +# ============================================================ + +mg_stats_generate() { + local now + now=$(date +%s) + local uptime=0 + [ -n "$MG_START_TIME" ] && uptime=$((now - MG_START_TIME)) + + local total_known=0 total_trusted=0 total_suspect=0 total_blocked=0 total_unknown=0 + + if [ -f "$MG_DBFILE" ] && [ -s "$MG_DBFILE" ]; then + total_known=$(wc -l < "$MG_DBFILE") + total_trusted=$(grep -c '|trusted$' "$MG_DBFILE" 2>/dev/null || echo 0) + total_suspect=$(grep -c '|suspect$' "$MG_DBFILE" 2>/dev/null || echo 0) + total_blocked=$(grep -c '|blocked$' "$MG_DBFILE" 2>/dev/null || echo 0) + total_unknown=$(grep -c '|unknown$' "$MG_DBFILE" 2>/dev/null || echo 0) + fi + + local last_alert="" + if [ -f "$MG_EVENTS_LOG" ] && [ -s "$MG_EVENTS_LOG" ]; then + last_alert=$(tail -1 "$MG_EVENTS_LOG" 2>/dev/null) + fi + + # Sanitize last_alert for JSON embedding + last_alert=$(echo "$last_alert" | tr -d '\n') + + local stats_dir + stats_dir=$(dirname "$MG_STATS_FILE") + mkdir -p "$stats_dir" + + cat > "$MG_STATS_FILE" <<-STATS_EOF + {"version":"${MG_VERSION}","uptime":${uptime},"scans":${MG_TOTAL_SCANS},"alerts":${MG_TOTAL_ALERTS},"clients":{"total":${total_known},"trusted":${total_trusted},"suspect":${total_suspect},"blocked":${total_blocked},"unknown":${total_unknown}},"last_alert":${last_alert:-null}} + STATS_EOF +} + +# ============================================================ +# WiFi interface scanning +# ============================================================ + +mg_get_wifi_ifaces() { + iwinfo 2>/dev/null | grep "ESSID" | awk '{print $1}' +} + +mg_scan_iface() { + local iface="$1" + + mg_log "debug" "Scanning interface: $iface" + + # Get associated stations + local assoclist + assoclist=$(iwinfo "$iface" assoclist 2>/dev/null) || return 0 + + echo "$assoclist" | while read -r line; do + # iwinfo assoclist format: "MAC_ADDR SIGNAL ..." + local mac signal + mac=$(echo "$line" | grep -oE '[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}' | head -1) + [ -z "$mac" ] && continue + + signal=$(echo "$line" | grep -oE '[-][0-9]+ dBm' | grep -oE '[-][0-9]+' | head -1) + + mg_check_station "$mac" "$signal" "$iface" + done + + # Run interface-level detections + mg_detect_oui_anomaly "$iface" + mg_detect_mac_flip "$iface" +} + +mg_scan_all() { + local ifaces + ifaces=$(mg_get_wifi_ifaces) + + if [ -z "$ifaces" ]; then + mg_log "debug" "No WiFi interfaces found" + return 0 + fi + + mg_lock || return 1 + + for iface in $ifaces; do + mg_scan_iface "$iface" + done + + MG_TOTAL_SCANS=$((MG_TOTAL_SCANS + 1)) + + mg_unlock +} diff --git a/package/secubox/mac-guardian/files/usr/lib/secubox/mac-guardian/oui.tsv b/package/secubox/mac-guardian/files/usr/lib/secubox/mac-guardian/oui.tsv new file mode 100644 index 00000000..ebe46447 --- /dev/null +++ b/package/secubox/mac-guardian/files/usr/lib/secubox/mac-guardian/oui.tsv @@ -0,0 +1,32 @@ +# OUI Vendor Flags +# Format: first 3 octets (uppercase, colon-separated) Vendor name whitelist|iot|suspect +00:50:E4 Apple whitelist +3C:22:FB Apple whitelist +A4:83:E7 Apple whitelist +DC:A6:32 Raspberry Pi whitelist +B8:27:EB Raspberry Pi whitelist +00:1A:11 Google whitelist +F4:F5:D8 Google whitelist +94:65:2D Samsung whitelist +8C:F5:A3 Samsung whitelist +FC:A1:83 Amazon whitelist +44:07:0B Amazon whitelist +40:B0:76 ASUS whitelist +18:FE:34 Espressif iot +24:0A:C4 Espressif iot +30:AE:A4 Espressif iot +D8:F1:5B Tuya iot +84:0D:8E Tuya iot +E8:68:E7 Shelly iot +98:CD:AC Shelly iot +50:C7:BF TP-Link iot +60:E3:27 TP-Link iot +00:C0:CA Alfa Network suspect +00:0E:8E Ralink suspect +00:0C:43 Ralink suspect +74:DA:38 MediaTek suspect +00:0C:E7 MediaTek suspect +48:2C:A0 Xiaomi whitelist +64:09:80 Xiaomi whitelist +E4:AB:89 Huawei whitelist +04:F9:38 Huawei whitelist diff --git a/package/secubox/mac-guardian/files/usr/sbin/mac-guardian b/package/secubox/mac-guardian/files/usr/sbin/mac-guardian new file mode 100644 index 00000000..ba8a66cd --- /dev/null +++ b/package/secubox/mac-guardian/files/usr/sbin/mac-guardian @@ -0,0 +1,248 @@ +#!/bin/sh +# mac-guardian -- WiFi MAC Security Monitor for SecuBox +# Usage: mac-guardian {start|scan|status|trust|block|list|version} + +. /usr/lib/secubox/mac-guardian/functions.sh + +cmd_start() { + mg_load_config + + if [ "$MG_ENABLED" != "1" ]; then + echo "mac-guardian is disabled. Enable with: uci set mac-guardian.main.enabled=1" + exit 1 + fi + + mg_init + mg_log "info" "mac-guardian v${MG_VERSION} starting (interval=${MG_SCAN_INTERVAL}s policy=${MG_POLICY})" + + trap 'mg_log "info" "Shutting down"; mg_unlock 2>/dev/null; exit 0' INT TERM + trap 'mg_load_config; mg_log "info" "Configuration reloaded"' HUP + + local stats_counter=0 + + while true; do + mg_scan_all + + stats_counter=$((stats_counter + MG_SCAN_INTERVAL)) + if [ "$stats_counter" -ge "$MG_STATS_INTERVAL" ]; then + mg_stats_generate + stats_counter=0 + fi + + mg_log_rotate + + sleep "$MG_SCAN_INTERVAL" + done +} + +cmd_scan() { + mg_load_config + mg_init + mg_scan_all + echo "Scan complete. $(wc -l < "$MG_DBFILE" 2>/dev/null || echo 0) clients in database." +} + +cmd_status() { + mg_load_config + + echo "mac-guardian v${MG_VERSION}" + echo "=========================" + + if [ "$MG_ENABLED" != "1" ]; then + echo "Status: DISABLED" + echo "" + echo "Enable with: uci set mac-guardian.main.enabled=1 && uci commit mac-guardian" + return + fi + + local pid + pid=$(cat /var/run/mac-guardian.pid 2>/dev/null) + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + echo "Status: RUNNING (PID $pid)" + else + echo "Status: STOPPED" + fi + + echo "Policy: $MG_POLICY" + echo "" + + # Interfaces + echo "WiFi Interfaces:" + local ifaces + ifaces=$(mg_get_wifi_ifaces) + if [ -z "$ifaces" ]; then + echo " (none detected)" + else + for iface in $ifaces; do + local essid + essid=$(iwinfo "$iface" info 2>/dev/null | grep "ESSID" | sed 's/.*ESSID: "\(.*\)"/\1/') + local sta_count + sta_count=$(iwinfo "$iface" assoclist 2>/dev/null | grep -cE '[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}') + echo " $iface ($essid) - $sta_count stations" + done + fi + + echo "" + + # Database stats + if [ -f "$MG_DBFILE" ] && [ -s "$MG_DBFILE" ]; then + local total trusted suspect blocked unknown + total=$(wc -l < "$MG_DBFILE") + trusted=$(grep -c '|trusted$' "$MG_DBFILE" 2>/dev/null || echo 0) + suspect=$(grep -c '|suspect$' "$MG_DBFILE" 2>/dev/null || echo 0) + blocked=$(grep -c '|blocked$' "$MG_DBFILE" 2>/dev/null || echo 0) + unknown=$(grep -c '|unknown$' "$MG_DBFILE" 2>/dev/null || echo 0) + + echo "Known Clients: $total" + echo " Trusted: $trusted" + echo " Unknown: $unknown" + echo " Suspect: $suspect" + echo " Blocked: $blocked" + else + echo "Known Clients: 0" + fi + + echo "" + + # Last alerts + if [ -f "$MG_EVENTS_LOG" ] && [ -s "$MG_EVENTS_LOG" ]; then + echo "Last 5 Alerts:" + tail -5 "$MG_EVENTS_LOG" | while read -r line; do + echo " $line" + done + else + echo "No alerts recorded." + fi +} + +cmd_trust() { + local mac="$1" + if [ -z "$mac" ]; then + echo "Usage: mac-guardian trust " + exit 1 + fi + + if ! mg_validate_mac "$mac"; then + echo "Error: Invalid MAC address format: $mac" + exit 1 + fi + + mac=$(mg_normalize_mac "$mac") + mg_load_config + mg_init + + # Add to UCI whitelist + uci add_list mac-guardian.whitelist.mac="$mac" + uci commit mac-guardian + + # Update database + mg_db_set_status "$mac" "trusted" + + # Remove any enforcement + mg_unenforce "$mac" + + mg_log "notice" "MAC $mac marked as trusted" + echo "MAC $mac is now trusted." +} + +cmd_block() { + local mac="$1" + if [ -z "$mac" ]; then + echo "Usage: mac-guardian block " + exit 1 + fi + + if ! mg_validate_mac "$mac"; then + echo "Error: Invalid MAC address format: $mac" + exit 1 + fi + + mac=$(mg_normalize_mac "$mac") + mg_load_config + mg_init + + # Get the interface this MAC is on + local iface="" + local existing + existing=$(mg_db_lookup "$mac") + if [ -n "$existing" ]; then + iface=$(echo "$existing" | cut -d'|' -f5) + fi + + # Force deny policy for this action + local saved_policy="$MG_POLICY" + MG_POLICY="deny" + mg_enforce "$mac" "${iface:-unknown}" "manual_block" + MG_POLICY="$saved_policy" + + mg_db_set_status "$mac" "blocked" + mg_log_event "manual_block" "$mac" "${iface:-unknown}" "blocked_by_admin" + + echo "MAC $mac is now blocked." +} + +cmd_list() { + local filter="${1:-all}" + + mg_load_config + mg_init + + if [ ! -f "$MG_DBFILE" ] || [ ! -s "$MG_DBFILE" ]; then + echo "No clients in database." + return + fi + + printf "%-19s %-10s %-12s %-8s %-15s %s\n" "MAC" "OUI" "FIRST SEEN" "IFACE" "HOSTNAME" "STATUS" + printf "%-19s %-10s %-12s %-8s %-15s %s\n" "---" "---" "----------" "-----" "--------" "------" + + while IFS='|' read -r mac oui first_seen last_seen iface hostname status; do + [ -z "$mac" ] && continue + + # Apply filter + case "$filter" in + all) ;; + trusted|blocked|suspect|unknown) + [ "$status" != "$filter" ] && continue + ;; + *) + echo "Unknown filter: $filter (use: trusted, blocked, suspect, unknown, all)" + return 1 + ;; + esac + + # Format first_seen as date + local date_str + date_str=$(date -d "@${first_seen}" "+%Y-%m-%d" 2>/dev/null || echo "$first_seen") + + printf "%-19s %-10s %-12s %-8s %-15s %s\n" \ + "$mac" "$oui" "$date_str" "$iface" "${hostname:--}" "$status" + done < "$MG_DBFILE" +} + +cmd_version() { + echo "mac-guardian v${MG_VERSION}" +} + +# --- Main dispatcher --- +case "${1:-}" in + start) cmd_start ;; + scan) cmd_scan ;; + status) cmd_status ;; + trust) cmd_trust "$2" ;; + block) cmd_block "$2" ;; + list) cmd_list "$2" ;; + version) cmd_version ;; + *) + echo "Usage: mac-guardian {start|scan|status|trust|block|list|version}" + echo "" + echo "Commands:" + echo " start Start the daemon" + echo " scan Run a single scan pass" + echo " status Show service status" + echo " trust Add MAC to trusted whitelist" + echo " block Block and deauthenticate MAC" + echo " list [filter] List known clients (trusted|blocked|suspect|unknown|all)" + echo " version Print version" + exit 1 + ;; +esac diff --git a/package/secubox/mac-guardian/tests/test_detection.sh b/package/secubox/mac-guardian/tests/test_detection.sh new file mode 100644 index 00000000..c96b33c8 --- /dev/null +++ b/package/secubox/mac-guardian/tests/test_detection.sh @@ -0,0 +1,189 @@ +#!/bin/sh +# TAP test suite for mac-guardian detection logic +# Run: sh tests/test_detection.sh + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +TEST_TMPDIR=$(mktemp -d) +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +# Override paths for testing +MG_RUNDIR="$TEST_TMPDIR/run" +MG_DBFILE="$MG_RUNDIR/known.db" +MG_LOCKDIR="$MG_RUNDIR/lock" +MG_LOGFILE="$TEST_TMPDIR/mac-guardian.log" +MG_EVENTS_LOG="$TEST_TMPDIR/mac-guardian.log" +MG_OUI_FILE="$SCRIPT_DIR/files/usr/lib/secubox/mac-guardian/oui.tsv" +MG_STATS_FILE="$TEST_TMPDIR/stats.json" +MG_MAX_LOG_SIZE=524288 + +mkdir -p "$MG_RUNDIR" +touch "$MG_DBFILE" +touch "$MG_EVENTS_LOG" + +# Stub logger and UCI +logger() { :; } +config_load() { :; } +config_get() { + local var="$1" section="$2" option="$3" default="$4" + eval "$var=\"\${$var:-$default}\"" +} +config_list_foreach() { :; } + +# Source functions +. "$SCRIPT_DIR/files/usr/lib/secubox/mac-guardian/functions.sh" + +# Re-apply path overrides (sourcing functions.sh resets them) +MG_RUNDIR="$TEST_TMPDIR/run" +MG_DBFILE="$MG_RUNDIR/known.db" +MG_LOCKDIR="$MG_RUNDIR/lock" +MG_LOGFILE="$TEST_TMPDIR/mac-guardian.log" +MG_EVENTS_LOG="$TEST_TMPDIR/mac-guardian.log" +MG_OUI_FILE="$SCRIPT_DIR/files/usr/lib/secubox/mac-guardian/oui.tsv" +MG_STATS_FILE="$TEST_TMPDIR/stats.json" +MG_MAX_LOG_SIZE=524288 + +# Reset config +MG_ENABLED=1 +MG_DEBUG=0 +MG_DETECT_RANDOM=1 +MG_DETECT_OUI_DUP=1 +MG_OUI_DUP_THRESHOLD=3 +MG_DETECT_FLIP=1 +MG_FLIP_WINDOW=300 +MG_FLIP_THRESHOLD=3 +MG_DETECT_SPOOF=1 +MG_POLICY="alert" +MG_NOTIFY_CROWDSEC=0 +MG_WL_MACS="" +MG_WL_OUIS="" +MG_START_TIME=$(date +%s) +MG_TOTAL_SCANS=0 +MG_TOTAL_ALERTS=0 + +# --- TAP output --- +TESTS=0 +PASS=0 +FAIL=0 + +ok() { + TESTS=$((TESTS + 1)) + PASS=$((PASS + 1)) + echo "ok $TESTS - $1" +} + +not_ok() { + TESTS=$((TESTS + 1)) + FAIL=$((FAIL + 1)) + echo "not ok $TESTS - $1" +} + +echo "TAP version 13" +echo "1..8" + +# --- Test 1: Randomized MAC generates alert --- +: > "$MG_EVENTS_LOG" +: > "$MG_DBFILE" +mg_check_station "02:aa:bb:cc:dd:ee" "-65" "wlan0" +if grep -q "randomized_mac" "$MG_EVENTS_LOG"; then + ok "Randomized MAC (02:xx) triggers alert" +else + not_ok "Randomized MAC (02:xx) triggers alert" +fi + +# --- Test 2: Non-randomized MAC does NOT generate randomized alert --- +: > "$MG_EVENTS_LOG" +: > "$MG_DBFILE" +mg_check_station "00:11:22:33:44:55" "-60" "wlan0" +if grep -q "randomized_mac" "$MG_EVENTS_LOG"; then + not_ok "Non-randomized MAC (00:xx) does not trigger randomized alert" +else + ok "Non-randomized MAC (00:xx) does not trigger randomized alert" +fi + +# --- Test 3: New station event logged --- +: > "$MG_EVENTS_LOG" +: > "$MG_DBFILE" +mg_check_station "00:aa:bb:cc:dd:01" "-55" "wlan0" +if grep -q "new_station" "$MG_EVENTS_LOG"; then + ok "New station event logged for unknown MAC" +else + not_ok "New station event logged for unknown MAC" +fi + +# --- Test 4: OUI anomaly detection --- +: > "$MG_EVENTS_LOG" +: > "$MG_DBFILE" +now=$(date +%s) +# Seed database with 4 MACs sharing same OUI on wlan0 (threshold is 3) +echo "aa:bb:cc:00:00:01|AA:BB:CC|$now|$now|wlan0|host1|unknown" >> "$MG_DBFILE" +echo "aa:bb:cc:00:00:02|AA:BB:CC|$now|$now|wlan0|host2|unknown" >> "$MG_DBFILE" +echo "aa:bb:cc:00:00:03|AA:BB:CC|$now|$now|wlan0|host3|unknown" >> "$MG_DBFILE" +echo "aa:bb:cc:00:00:04|AA:BB:CC|$now|$now|wlan0|host4|unknown" >> "$MG_DBFILE" +mg_detect_oui_anomaly "wlan0" +if grep -q "oui_anomaly" "$MG_EVENTS_LOG"; then + ok "OUI anomaly triggered when count exceeds threshold" +else + not_ok "OUI anomaly triggered when count exceeds threshold" +fi + +# --- Test 5: OUI anomaly NOT triggered below threshold --- +: > "$MG_EVENTS_LOG" +: > "$MG_DBFILE" +echo "aa:bb:cc:00:00:01|AA:BB:CC|$now|$now|wlan0|host1|unknown" >> "$MG_DBFILE" +echo "aa:bb:cc:00:00:02|AA:BB:CC|$now|$now|wlan0|host2|unknown" >> "$MG_DBFILE" +mg_detect_oui_anomaly "wlan0" +if grep -q "oui_anomaly" "$MG_EVENTS_LOG"; then + not_ok "OUI anomaly not triggered below threshold" +else + ok "OUI anomaly not triggered below threshold" +fi + +# --- Test 6: MAC flood detection --- +: > "$MG_EVENTS_LOG" +: > "$MG_DBFILE" +now=$(date +%s) +# Seed with many recent MACs (threshold is 3) +echo "00:11:22:33:44:01|00:11:22|$now|$now|wlan0||unknown" >> "$MG_DBFILE" +echo "00:11:22:33:44:02|00:11:22|$now|$now|wlan0||unknown" >> "$MG_DBFILE" +echo "00:11:22:33:44:03|00:11:22|$now|$now|wlan0||unknown" >> "$MG_DBFILE" +echo "00:11:22:33:44:04|00:11:22|$now|$now|wlan0||unknown" >> "$MG_DBFILE" +mg_detect_mac_flip "wlan0" +if grep -q "mac_flood" "$MG_EVENTS_LOG"; then + ok "MAC flood detected when new MACs exceed threshold in window" +else + not_ok "MAC flood detected when new MACs exceed threshold in window" +fi + +# --- Test 7: Spoof detection (interface change) --- +: > "$MG_EVENTS_LOG" +: > "$MG_DBFILE" +now=$(date +%s) +# Seed a MAC on wlan0 +echo "00:aa:bb:cc:dd:99|00:AA:BB|$((now - 60))|$((now - 10))|wlan0|testhost|unknown" >> "$MG_DBFILE" +# Check same MAC on wlan1 -> should trigger spoof +mg_check_station "00:aa:bb:cc:dd:99" "-70" "wlan1" +if grep -q "spoof_detected" "$MG_EVENTS_LOG"; then + ok "Spoof detected when MAC appears on different interface" +else + not_ok "Spoof detected when MAC appears on different interface" +fi + +# --- Test 8: Stats generation --- +: > "$MG_DBFILE" +echo "aa:bb:cc:00:00:01|AA:BB:CC|$now|$now|wlan0|host1|trusted" >> "$MG_DBFILE" +echo "aa:bb:cc:00:00:02|AA:BB:CC|$now|$now|wlan0|host2|suspect" >> "$MG_DBFILE" +echo "aa:bb:cc:00:00:03|AA:BB:CC|$now|$now|wlan0|host3|unknown" >> "$MG_DBFILE" +mg_stats_generate +if [ -f "$MG_STATS_FILE" ] && grep -q '"trusted":1' "$MG_STATS_FILE"; then + ok "Stats generation produces valid JSON with correct counts" +else + not_ok "Stats generation produces valid JSON with correct counts" +fi + +# --- Summary --- +echo "" +echo "# Tests: $TESTS, Passed: $PASS, Failed: $FAIL" +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/package/secubox/mac-guardian/tests/test_functions.sh b/package/secubox/mac-guardian/tests/test_functions.sh new file mode 100644 index 00000000..3a9394ed --- /dev/null +++ b/package/secubox/mac-guardian/tests/test_functions.sh @@ -0,0 +1,159 @@ +#!/bin/sh +# TAP test suite for mac-guardian functions +# Run: sh tests/test_functions.sh + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +TEST_TMPDIR=$(mktemp -d) +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +# Override paths for testing +MG_RUNDIR="$TEST_TMPDIR/run" +MG_DBFILE="$MG_RUNDIR/known.db" +MG_LOCKDIR="$MG_RUNDIR/lock" +MG_LOGFILE="$TEST_TMPDIR/mac-guardian.log" +MG_EVENTS_LOG="$TEST_TMPDIR/mac-guardian.log" +MG_OUI_FILE="$SCRIPT_DIR/files/usr/lib/secubox/mac-guardian/oui.tsv" +MG_STATS_FILE="$TEST_TMPDIR/stats.json" +MG_MAX_LOG_SIZE=524288 + +mkdir -p "$MG_RUNDIR" +touch "$MG_DBFILE" +touch "$MG_EVENTS_LOG" + +# Stub logger for test environment +logger() { :; } + +# Stub UCI functions for testing +config_load() { :; } +config_get() { + local var="$1" section="$2" option="$3" default="$4" + eval "$var=\"\${$var:-$default}\"" +} +config_list_foreach() { :; } + +# Source functions +. "$SCRIPT_DIR/files/usr/lib/secubox/mac-guardian/functions.sh" + +# Re-apply path overrides (sourcing functions.sh resets them) +MG_RUNDIR="$TEST_TMPDIR/run" +MG_DBFILE="$MG_RUNDIR/known.db" +MG_LOCKDIR="$MG_RUNDIR/lock" +MG_LOGFILE="$TEST_TMPDIR/mac-guardian.log" +MG_EVENTS_LOG="$TEST_TMPDIR/mac-guardian.log" +MG_OUI_FILE="$SCRIPT_DIR/files/usr/lib/secubox/mac-guardian/oui.tsv" +MG_STATS_FILE="$TEST_TMPDIR/stats.json" +MG_MAX_LOG_SIZE=524288 + +# Reset config defaults for tests +MG_ENABLED=1 +MG_DEBUG=0 +MG_DETECT_RANDOM=1 +MG_DETECT_OUI_DUP=1 +MG_OUI_DUP_THRESHOLD=5 +MG_DETECT_FLIP=1 +MG_FLIP_WINDOW=300 +MG_FLIP_THRESHOLD=10 +MG_DETECT_SPOOF=1 +MG_POLICY="alert" +MG_NOTIFY_CROWDSEC=0 +MG_WL_MACS="" +MG_WL_OUIS="" + +# --- TAP output --- +TESTS=0 +PASS=0 +FAIL=0 + +ok() { + TESTS=$((TESTS + 1)) + PASS=$((PASS + 1)) + echo "ok $TESTS - $1" +} + +not_ok() { + TESTS=$((TESTS + 1)) + FAIL=$((FAIL + 1)) + echo "not ok $TESTS - $1" +} + +assert_true() { + if eval "$1"; then + ok "$2" + else + not_ok "$2" + fi +} + +assert_false() { + if eval "$1"; then + not_ok "$2" + else + ok "$2" + fi +} + +assert_eq() { + if [ "$1" = "$2" ]; then + ok "$3" + else + not_ok "$3 (got '$1', expected '$2')" + fi +} + +echo "TAP version 13" +echo "1..19" + +# --- Test mg_is_randomized --- +assert_true 'mg_is_randomized "02:11:22:33:44:55"' "mg_is_randomized: 02:xx is randomized" +assert_false 'mg_is_randomized "00:11:22:33:44:55"' "mg_is_randomized: 00:xx is not randomized" +assert_true 'mg_is_randomized "da:11:22:33:44:55"' "mg_is_randomized: da:xx is randomized (bit1 set)" +assert_false 'mg_is_randomized "dc:11:22:33:44:55"' "mg_is_randomized: dc:xx is not randomized (bit1 clear)" +assert_true 'mg_is_randomized "fe:11:22:33:44:55"' "mg_is_randomized: fe:xx is randomized" +assert_false 'mg_is_randomized "fc:11:22:33:44:55"' "mg_is_randomized: fc:xx is not randomized" + +# --- Test mg_validate_mac --- +assert_true 'mg_validate_mac "aa:bb:cc:dd:ee:ff"' "mg_validate_mac: valid lowercase MAC" +assert_true 'mg_validate_mac "AA:BB:CC:DD:EE:FF"' "mg_validate_mac: valid uppercase MAC" +assert_false 'mg_validate_mac "aa:bb:cc:dd:ee"' "mg_validate_mac: short MAC rejected" +assert_false 'mg_validate_mac "aabbccddeeff"' "mg_validate_mac: no colons rejected" +assert_false 'mg_validate_mac "gg:hh:ii:jj:kk:ll"' "mg_validate_mac: hex overflow rejected" + +# --- Test mg_get_oui --- +result=$(mg_get_oui "aa:bb:cc:dd:ee:ff") +assert_eq "$result" "AA:BB:CC" "mg_get_oui: extracts first 3 octets uppercase" + +# --- Test mg_db_upsert + mg_db_lookup --- + +# Insert new entry +mg_db_upsert "aa:bb:cc:11:22:33" "wlan0" "test-host" +entry=$(mg_db_lookup "aa:bb:cc:11:22:33") +assert_true '[ -n "$entry" ]' "mg_db_upsert: inserts new entry" + +# Update existing (no duplicate) +mg_db_upsert "aa:bb:cc:11:22:33" "wlan0" "test-host-updated" +count=$(grep -c "aa:bb:cc:11:22:33" "$MG_DBFILE") +assert_eq "$count" "1" "mg_db_upsert: update does not create duplicate" + +# Lookup missing +missing=$(mg_db_lookup "ff:ff:ff:ff:ff:ff") +assert_true '[ -z "$missing" ]' "mg_db_lookup: missing MAC returns empty" + +# --- Test mg_is_whitelisted --- +MG_WL_MACS="aa:bb:cc:11:22:33" +assert_true 'mg_is_whitelisted "aa:bb:cc:11:22:33"' "mg_is_whitelisted: whitelisted MAC matches" +assert_false 'mg_is_whitelisted "ff:ee:dd:cc:bb:aa"' "mg_is_whitelisted: non-whitelisted MAC rejected" +MG_WL_MACS="" + +# --- Test lock acquire/release --- +mg_lock +assert_true '[ -d "$MG_LOCKDIR" ]' "mg_lock: lock directory created" +mg_unlock +assert_false '[ -d "$MG_LOCKDIR" ]' "mg_unlock: lock directory removed" + +# --- Summary --- +echo "" +echo "# Tests: $TESTS, Passed: $PASS, Failed: $FAIL" +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0