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 <noreply@anthropic.com>
This commit is contained in:
parent
fa1f6ddbb8
commit
aeb4825b25
63
package/secubox/mac-guardian/Makefile
Normal file
63
package/secubox/mac-guardian/Makefile
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
include $(TOPDIR)/rules.mk
|
||||||
|
|
||||||
|
PKG_NAME:=mac-guardian
|
||||||
|
PKG_VERSION:=0.5.0
|
||||||
|
PKG_RELEASE:=1
|
||||||
|
|
||||||
|
PKG_MAINTAINER:=Gandalf <contact@cybermind.fr>
|
||||||
|
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))
|
||||||
27
package/secubox/mac-guardian/files/etc/config/mac-guardian
Normal file
27
package/secubox/mac-guardian/files/etc/config/mac-guardian
Normal file
@ -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'
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
filenames:
|
||||||
|
- /var/log/mac-guardian.log
|
||||||
|
labels:
|
||||||
|
type: mac-guardian
|
||||||
@ -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')"
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
|
} &
|
||||||
26
package/secubox/mac-guardian/files/etc/init.d/mac-guardian
Normal file
26
package/secubox/mac-guardian/files/etc/init.d/mac-guardian
Normal file
@ -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"
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
248
package/secubox/mac-guardian/files/usr/sbin/mac-guardian
Normal file
248
package/secubox/mac-guardian/files/usr/sbin/mac-guardian
Normal file
@ -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 <MAC>"
|
||||||
|
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 <MAC>"
|
||||||
|
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 <MAC> Add MAC to trusted whitelist"
|
||||||
|
echo " block <MAC> Block and deauthenticate MAC"
|
||||||
|
echo " list [filter] List known clients (trusted|blocked|suspect|unknown|all)"
|
||||||
|
echo " version Print version"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
189
package/secubox/mac-guardian/tests/test_detection.sh
Normal file
189
package/secubox/mac-guardian/tests/test_detection.sh
Normal file
@ -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
|
||||||
159
package/secubox/mac-guardian/tests/test_functions.sh
Normal file
159
package/secubox/mac-guardian/tests/test_functions.sh
Normal file
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user