style: apply ruff-format to entire codebase

First-time run of ruff-format via pre-commit hook normalises quote
style, trailing commas, and whitespace across 188 Python files.
No logic changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
James Smith
2026-07-05 14:48:11 +01:00
parent 82e64104fe
commit 96172ca593
189 changed files with 19883 additions and 19552 deletions
+63 -75
View File
@@ -93,91 +93,79 @@ from .scanner import (
__all__ = [
# Main scanner
'UnifiedWiFiScanner',
'get_wifi_scanner',
'reset_wifi_scanner',
"UnifiedWiFiScanner",
"get_wifi_scanner",
"reset_wifi_scanner",
# Models
'WiFiObservation',
'WiFiAccessPoint',
'WiFiClient',
'WiFiProbeRequest',
'WiFiScanResult',
'WiFiScanStatus',
'WiFiCapabilities',
'ChannelStats',
'ChannelRecommendation',
"WiFiObservation",
"WiFiAccessPoint",
"WiFiClient",
"WiFiProbeRequest",
"WiFiScanResult",
"WiFiScanStatus",
"WiFiCapabilities",
"ChannelStats",
"ChannelRecommendation",
# Channel analysis
'ChannelAnalyzer',
'analyze_channels',
"ChannelAnalyzer",
"analyze_channels",
# Hidden SSID correlation
'HiddenSSIDCorrelator',
'get_hidden_correlator',
"HiddenSSIDCorrelator",
"get_hidden_correlator",
# Constants - Bands
'BAND_2_4_GHZ',
'BAND_5_GHZ',
'BAND_6_GHZ',
'BAND_UNKNOWN',
"BAND_2_4_GHZ",
"BAND_5_GHZ",
"BAND_6_GHZ",
"BAND_UNKNOWN",
# Constants - Channels
'CHANNELS_2_4_GHZ',
'CHANNELS_5_GHZ',
'CHANNELS_6_GHZ',
'NON_OVERLAPPING_2_4_GHZ',
'NON_OVERLAPPING_5_GHZ',
"CHANNELS_2_4_GHZ",
"CHANNELS_5_GHZ",
"CHANNELS_6_GHZ",
"NON_OVERLAPPING_2_4_GHZ",
"NON_OVERLAPPING_5_GHZ",
# Constants - Security
'SECURITY_OPEN',
'SECURITY_WEP',
'SECURITY_WPA',
'SECURITY_WPA2',
'SECURITY_WPA3',
'SECURITY_WPA_WPA2',
'SECURITY_WPA2_WPA3',
'SECURITY_ENTERPRISE',
'SECURITY_UNKNOWN',
"SECURITY_OPEN",
"SECURITY_WEP",
"SECURITY_WPA",
"SECURITY_WPA2",
"SECURITY_WPA3",
"SECURITY_WPA_WPA2",
"SECURITY_WPA2_WPA3",
"SECURITY_ENTERPRISE",
"SECURITY_UNKNOWN",
# Constants - Cipher
'CIPHER_NONE',
'CIPHER_WEP',
'CIPHER_TKIP',
'CIPHER_CCMP',
'CIPHER_GCMP',
'CIPHER_UNKNOWN',
"CIPHER_NONE",
"CIPHER_WEP",
"CIPHER_TKIP",
"CIPHER_CCMP",
"CIPHER_GCMP",
"CIPHER_UNKNOWN",
# Constants - Auth
'AUTH_OPEN',
'AUTH_PSK',
'AUTH_SAE',
'AUTH_EAP',
'AUTH_OWE',
'AUTH_UNKNOWN',
"AUTH_OPEN",
"AUTH_PSK",
"AUTH_SAE",
"AUTH_EAP",
"AUTH_OWE",
"AUTH_UNKNOWN",
# Constants - Signal bands
'SIGNAL_STRONG',
'SIGNAL_MEDIUM',
'SIGNAL_WEAK',
'SIGNAL_VERY_WEAK',
'SIGNAL_UNKNOWN',
"SIGNAL_STRONG",
"SIGNAL_MEDIUM",
"SIGNAL_WEAK",
"SIGNAL_VERY_WEAK",
"SIGNAL_UNKNOWN",
# Constants - Proximity bands
'PROXIMITY_IMMEDIATE',
'PROXIMITY_NEAR',
'PROXIMITY_FAR',
'PROXIMITY_UNKNOWN',
"PROXIMITY_IMMEDIATE",
"PROXIMITY_NEAR",
"PROXIMITY_FAR",
"PROXIMITY_UNKNOWN",
# Constants - Scan modes
'SCAN_MODE_QUICK',
'SCAN_MODE_DEEP',
"SCAN_MODE_QUICK",
"SCAN_MODE_DEEP",
# Helper functions
'get_band_from_channel',
'get_band_from_frequency',
'get_channel_from_frequency',
'get_signal_band',
'get_proximity_band',
'get_vendor_from_mac',
"get_band_from_channel",
"get_band_from_frequency",
"get_channel_from_frequency",
"get_signal_band",
"get_proximity_band",
"get_vendor_from_mac",
]
+11 -10
View File
@@ -36,6 +36,7 @@ DFS_CHANNELS_5_GHZ = list(range(52, 65)) + list(range(100, 145))
@dataclass
class ChannelScore:
"""Internal scoring for a channel."""
channel: int
band: str
ap_count: int = 0
@@ -118,9 +119,7 @@ class ChannelAnalyzer:
channel_stats = self._build_channel_stats(scores)
# Generate recommendations
recommendations = self._generate_recommendations(
scores, access_points, include_dfs
)
recommendations = self._generate_recommendations(scores, access_points, include_dfs)
return channel_stats, recommendations
@@ -256,13 +255,15 @@ class ChannelAnalyzer:
if is_dfs:
reason += " (DFS - radar detection required)"
recommendations.append(ChannelRecommendation(
channel=channel,
band=band,
score=utilization,
reason=reason,
is_dfs=is_dfs,
))
recommendations.append(
ChannelRecommendation(
channel=channel,
band=band,
score=utilization,
reason=reason,
is_dfs=is_dfs,
)
)
# Sort by score (lower is better), then prefer non-DFS
recommendations.sort(key=lambda r: (r.score, r.is_dfs, r.channel))
+318 -209
View File
@@ -30,10 +30,10 @@ PROBE_REQUEST_RETENTION = 600 # 10 minutes
# WIFI BANDS
# =============================================================================
BAND_2_4_GHZ = '2.4GHz'
BAND_5_GHZ = '5GHz'
BAND_6_GHZ = '6GHz'
BAND_UNKNOWN = 'unknown'
BAND_2_4_GHZ = "2.4GHz"
BAND_5_GHZ = "5GHz"
BAND_6_GHZ = "6GHz"
BAND_UNKNOWN = "unknown"
# =============================================================================
# WIFI BAND CHANNEL MAPPINGS
@@ -45,21 +45,97 @@ CHANNELS_2_4_GHZ = list(range(1, 15))
# 5 GHz channels (UNII-1, UNII-2A, UNII-2C, UNII-3)
CHANNELS_5_GHZ = [
# UNII-1 (5150-5250 MHz)
36, 40, 44, 48,
36,
40,
44,
48,
# UNII-2A (5250-5350 MHz) - DFS
52, 56, 60, 64,
52,
56,
60,
64,
# UNII-2C (5470-5725 MHz) - DFS
100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144,
100,
104,
108,
112,
116,
120,
124,
128,
132,
136,
140,
144,
# UNII-3 (5725-5850 MHz)
149, 153, 157, 161, 165,
149,
153,
157,
161,
165,
]
# 6 GHz channels (Wi-Fi 6E)
CHANNELS_6_GHZ = [
1, 5, 9, 13, 17, 21, 25, 29, 33, 37, 41, 45, 49, 53, 57, 61, 65, 69, 73,
77, 81, 85, 89, 93, 97, 101, 105, 109, 113, 117, 121, 125, 129, 133, 137,
141, 145, 149, 153, 157, 161, 165, 169, 173, 177, 181, 185, 189, 193, 197,
201, 205, 209, 213, 217, 221, 225, 229, 233
1,
5,
9,
13,
17,
21,
25,
29,
33,
37,
41,
45,
49,
53,
57,
61,
65,
69,
73,
77,
81,
85,
89,
93,
97,
101,
105,
109,
113,
117,
121,
125,
129,
133,
137,
141,
145,
149,
153,
157,
161,
165,
169,
173,
177,
181,
185,
189,
193,
197,
201,
205,
209,
213,
217,
221,
225,
229,
233,
]
# Non-overlapping channels for recommendations
@@ -69,14 +145,46 @@ NON_OVERLAPPING_5_GHZ = [36, 40, 44, 48, 149, 153, 157, 161, 165] # Non-DFS
# Channel to frequency mappings (MHz)
CHANNEL_FREQUENCIES = {
# 2.4 GHz
1: 2412, 2: 2417, 3: 2422, 4: 2427, 5: 2432, 6: 2437, 7: 2442,
8: 2447, 9: 2452, 10: 2457, 11: 2462, 12: 2467, 13: 2472, 14: 2484,
1: 2412,
2: 2417,
3: 2422,
4: 2427,
5: 2432,
6: 2437,
7: 2442,
8: 2447,
9: 2452,
10: 2457,
11: 2462,
12: 2467,
13: 2472,
14: 2484,
# 5 GHz
36: 5180, 40: 5200, 44: 5220, 48: 5240,
52: 5260, 56: 5280, 60: 5300, 64: 5320,
100: 5500, 104: 5520, 108: 5540, 112: 5560, 116: 5580,
120: 5600, 124: 5620, 128: 5640, 132: 5660, 136: 5680, 140: 5700, 144: 5720,
149: 5745, 153: 5765, 157: 5785, 161: 5805, 165: 5825,
36: 5180,
40: 5200,
44: 5220,
48: 5240,
52: 5260,
56: 5280,
60: 5300,
64: 5320,
100: 5500,
104: 5520,
108: 5540,
112: 5560,
116: 5580,
120: 5600,
124: 5620,
128: 5640,
132: 5660,
136: 5680,
140: 5700,
144: 5720,
149: 5745,
153: 5765,
157: 5785,
161: 5805,
165: 5825,
}
# Frequency to channel reverse mapping
@@ -114,15 +222,15 @@ def get_channel_from_frequency(frequency_mhz: int) -> int | None:
# SECURITY TYPES
# =============================================================================
SECURITY_OPEN = 'Open'
SECURITY_WEP = 'WEP'
SECURITY_WPA = 'WPA'
SECURITY_WPA2 = 'WPA2'
SECURITY_WPA3 = 'WPA3'
SECURITY_WPA_WPA2 = 'WPA/WPA2'
SECURITY_WPA2_WPA3 = 'WPA2/WPA3'
SECURITY_ENTERPRISE = 'Enterprise'
SECURITY_UNKNOWN = 'Unknown'
SECURITY_OPEN = "Open"
SECURITY_WEP = "WEP"
SECURITY_WPA = "WPA"
SECURITY_WPA2 = "WPA2"
SECURITY_WPA3 = "WPA3"
SECURITY_WPA_WPA2 = "WPA/WPA2"
SECURITY_WPA2_WPA3 = "WPA2/WPA3"
SECURITY_ENTERPRISE = "Enterprise"
SECURITY_UNKNOWN = "Unknown"
# Security type priority (higher = more secure)
SECURITY_PRIORITY = {
@@ -141,44 +249,44 @@ SECURITY_PRIORITY = {
# CIPHER TYPES
# =============================================================================
CIPHER_NONE = 'None'
CIPHER_WEP = 'WEP'
CIPHER_TKIP = 'TKIP'
CIPHER_CCMP = 'CCMP'
CIPHER_GCMP = 'GCMP'
CIPHER_UNKNOWN = 'Unknown'
CIPHER_NONE = "None"
CIPHER_WEP = "WEP"
CIPHER_TKIP = "TKIP"
CIPHER_CCMP = "CCMP"
CIPHER_GCMP = "GCMP"
CIPHER_UNKNOWN = "Unknown"
# =============================================================================
# AUTHENTICATION TYPES
# =============================================================================
AUTH_OPEN = 'Open'
AUTH_PSK = 'PSK'
AUTH_SAE = 'SAE'
AUTH_EAP = 'EAP'
AUTH_OWE = 'OWE'
AUTH_UNKNOWN = 'Unknown'
AUTH_OPEN = "Open"
AUTH_PSK = "PSK"
AUTH_SAE = "SAE"
AUTH_EAP = "EAP"
AUTH_OWE = "OWE"
AUTH_UNKNOWN = "Unknown"
# =============================================================================
# CHANNEL WIDTH
# =============================================================================
WIDTH_20_MHZ = '20MHz'
WIDTH_40_MHZ = '40MHz'
WIDTH_80_MHZ = '80MHz'
WIDTH_160_MHZ = '160MHz'
WIDTH_320_MHZ = '320MHz'
WIDTH_UNKNOWN = 'Unknown'
WIDTH_20_MHZ = "20MHz"
WIDTH_40_MHZ = "40MHz"
WIDTH_80_MHZ = "80MHz"
WIDTH_160_MHZ = "160MHz"
WIDTH_320_MHZ = "320MHz"
WIDTH_UNKNOWN = "Unknown"
# =============================================================================
# SIGNAL STRENGTH BANDS (for proximity radar)
# =============================================================================
SIGNAL_STRONG = 'strong' # >= -50 dBm
SIGNAL_MEDIUM = 'medium' # -50 to -70 dBm
SIGNAL_WEAK = 'weak' # -70 to -85 dBm
SIGNAL_VERY_WEAK = 'very_weak' # < -85 dBm
SIGNAL_UNKNOWN = 'unknown'
SIGNAL_STRONG = "strong" # >= -50 dBm
SIGNAL_MEDIUM = "medium" # -50 to -70 dBm
SIGNAL_WEAK = "weak" # -70 to -85 dBm
SIGNAL_VERY_WEAK = "very_weak" # < -85 dBm
SIGNAL_UNKNOWN = "unknown"
# RSSI thresholds for signal bands
RSSI_STRONG = -50
@@ -203,14 +311,14 @@ def get_signal_band(rssi: int | None) -> str:
# PROXIMITY BANDS (consistent with Bluetooth)
# =============================================================================
PROXIMITY_IMMEDIATE = 'immediate' # < 3m
PROXIMITY_NEAR = 'near' # 3-10m
PROXIMITY_FAR = 'far' # > 10m
PROXIMITY_UNKNOWN = 'unknown'
PROXIMITY_IMMEDIATE = "immediate" # < 3m
PROXIMITY_NEAR = "near" # 3-10m
PROXIMITY_FAR = "far" # > 10m
PROXIMITY_UNKNOWN = "unknown"
# RSSI thresholds for proximity band classification
PROXIMITY_RSSI_IMMEDIATE = -55 # >= -55 dBm -> immediate
PROXIMITY_RSSI_NEAR = -70 # >= -70 dBm -> near
PROXIMITY_RSSI_NEAR = -70 # >= -70 dBm -> near
def get_proximity_band(rssi: int | None) -> str:
@@ -241,22 +349,22 @@ WIFI_EMA_ALPHA = 0.3
# SCAN MODES
# =============================================================================
SCAN_MODE_QUICK = 'quick' # Uses system tools (no monitor mode)
SCAN_MODE_DEEP = 'deep' # Uses airodump-ng (monitor mode required)
SCAN_MODE_QUICK = "quick" # Uses system tools (no monitor mode)
SCAN_MODE_DEEP = "deep" # Uses airodump-ng (monitor mode required)
# =============================================================================
# TOOL DETECTION
# =============================================================================
# Quick scan tools (by platform priority)
QUICK_SCAN_TOOLS_LINUX = ['nmcli', 'iw', 'iwlist']
QUICK_SCAN_TOOLS_DARWIN = ['airport']
QUICK_SCAN_TOOLS_LINUX = ["nmcli", "iw", "iwlist"]
QUICK_SCAN_TOOLS_DARWIN = ["airport"]
# Deep scan tools
DEEP_SCAN_TOOLS = ['airodump-ng']
DEEP_SCAN_TOOLS = ["airodump-ng"]
# Monitor mode tools
MONITOR_MODE_TOOLS = ['airmon-ng', 'iw']
MONITOR_MODE_TOOLS = ["airmon-ng", "iw"]
# Tool command timeouts (seconds)
TOOL_TIMEOUT_QUICK = 30.0
@@ -266,22 +374,22 @@ TOOL_TIMEOUT_DETECT = 5.0
# AIRODUMP-NG SETTINGS
# =============================================================================
AIRODUMP_OUTPUT_PREFIX = 'airodump_wifi'
AIRODUMP_OUTPUT_PREFIX = "airodump_wifi"
AIRODUMP_POLL_INTERVAL = 1.0 # seconds between CSV reads
# =============================================================================
# HEURISTIC FLAGS
# =============================================================================
HEURISTIC_HIDDEN = 'hidden'
HEURISTIC_ROGUE_AP = 'rogue_ap'
HEURISTIC_EVIL_TWIN = 'evil_twin'
HEURISTIC_BEACON_FLOOD = 'beacon_flood'
HEURISTIC_WEAK_SECURITY = 'weak_security'
HEURISTIC_DEAUTH_DETECTED = 'deauth_detected'
HEURISTIC_NEW = 'new'
HEURISTIC_PERSISTENT = 'persistent'
HEURISTIC_STRONG_STABLE = 'strong_stable'
HEURISTIC_HIDDEN = "hidden"
HEURISTIC_ROGUE_AP = "rogue_ap"
HEURISTIC_EVIL_TWIN = "evil_twin"
HEURISTIC_BEACON_FLOOD = "beacon_flood"
HEURISTIC_WEAK_SECURITY = "weak_security"
HEURISTIC_DEAUTH_DETECTED = "deauth_detected"
HEURISTIC_NEW = "new"
HEURISTIC_PERSISTENT = "persistent"
HEURISTIC_STRONG_STABLE = "strong_stable"
# Thresholds
BEACON_FLOOD_THRESHOLD = 50 # Same BSSID seen > 50 times/minute
@@ -295,146 +403,147 @@ STABLE_VARIANCE_THRESHOLD = 5.0
# =============================================================================
VENDOR_OUIS = {
'00:00:5E': 'IANA',
'00:03:93': 'Apple',
'00:0A:95': 'Apple',
'00:0D:93': 'Apple',
'00:11:24': 'Apple',
'00:14:51': 'Apple',
'00:16:CB': 'Apple',
'00:17:F2': 'Apple',
'00:19:E3': 'Apple',
'00:1B:63': 'Apple',
'00:1C:B3': 'Apple',
'00:1D:4F': 'Apple',
'00:1E:52': 'Apple',
'00:1E:C2': 'Apple',
'00:1F:5B': 'Apple',
'00:1F:F3': 'Apple',
'00:21:E9': 'Apple',
'00:22:41': 'Apple',
'00:23:12': 'Apple',
'00:23:32': 'Apple',
'00:23:6C': 'Apple',
'00:23:DF': 'Apple',
'00:24:36': 'Apple',
'00:25:00': 'Apple',
'00:25:4B': 'Apple',
'00:25:BC': 'Apple',
'00:26:08': 'Apple',
'00:26:4A': 'Apple',
'00:26:B0': 'Apple',
'00:26:BB': 'Apple',
'00:50:F2': 'Microsoft',
'00:15:5D': 'Microsoft',
'00:17:FA': 'Microsoft',
'00:1D:D8': 'Microsoft',
'00:50:56': 'VMware',
'00:0C:29': 'VMware',
'00:05:69': 'VMware',
'08:00:27': 'VirtualBox',
'00:1C:42': 'Parallels',
'00:16:3E': 'Xen',
'DC:A6:32': 'Raspberry Pi',
'B8:27:EB': 'Raspberry Pi',
'E4:5F:01': 'Raspberry Pi',
'28:CD:C1': 'Raspberry Pi',
'00:1A:11': 'Google',
'00:1A:22': 'Google',
'3C:5A:B4': 'Google',
'54:60:09': 'Google',
'94:EB:2C': 'Google',
'F4:F5:D8': 'Google',
'00:17:C4': 'Netgear',
'00:1B:2F': 'Netgear',
'00:1E:2A': 'Netgear',
'00:22:3F': 'Netgear',
'00:24:B2': 'Netgear',
'00:26:F2': 'Netgear',
'00:18:F8': 'Cisco',
'00:1A:A1': 'Cisco',
'00:1B:0C': 'Cisco',
'00:1B:D4': 'Cisco',
'00:1C:0E': 'Cisco',
'00:1C:57': 'Cisco',
'00:40:96': 'Cisco',
'00:50:54': 'Cisco',
'00:60:5C': 'Cisco',
'E8:65:D4': 'Ubiquiti',
'FC:EC:DA': 'Ubiquiti',
'00:27:22': 'Ubiquiti',
'04:18:D6': 'Ubiquiti',
'18:E8:29': 'Ubiquiti',
'24:A4:3C': 'Ubiquiti',
'44:D9:E7': 'Ubiquiti',
'68:72:51': 'Ubiquiti',
'74:83:C2': 'Ubiquiti',
'78:8A:20': 'Ubiquiti',
'B4:FB:E4': 'Ubiquiti',
'F0:9F:C2': 'Ubiquiti',
'00:0C:F1': 'Intel',
'00:13:02': 'Intel',
'00:13:20': 'Intel',
'00:13:CE': 'Intel',
'00:13:E8': 'Intel',
'00:15:00': 'Intel',
'00:15:17': 'Intel',
'00:16:6F': 'Intel',
'00:16:76': 'Intel',
'00:16:EA': 'Intel',
'00:16:EB': 'Intel',
'00:18:DE': 'Intel',
'00:19:D1': 'Intel',
'00:19:D2': 'Intel',
'00:1B:21': 'Intel',
'00:1B:77': 'Intel',
'00:1C:BF': 'Intel',
'00:1D:E0': 'Intel',
'00:1D:E1': 'Intel',
'00:1E:64': 'Intel',
'00:1E:65': 'Intel',
'00:1E:67': 'Intel',
'00:1F:3B': 'Intel',
'00:1F:3C': 'Intel',
'00:20:E0': 'TP-Link',
'00:23:CD': 'TP-Link',
'00:25:86': 'TP-Link',
'00:27:19': 'TP-Link',
'14:CC:20': 'TP-Link',
'14:CF:92': 'TP-Link',
'18:A6:F7': 'TP-Link',
'1C:3B:F3': 'TP-Link',
'30:B5:C2': 'TP-Link',
'50:C7:BF': 'TP-Link',
'54:C8:0F': 'TP-Link',
'60:E3:27': 'TP-Link',
'64:56:01': 'TP-Link',
'64:66:B3': 'TP-Link',
'64:70:02': 'TP-Link',
"00:00:5E": "IANA",
"00:03:93": "Apple",
"00:0A:95": "Apple",
"00:0D:93": "Apple",
"00:11:24": "Apple",
"00:14:51": "Apple",
"00:16:CB": "Apple",
"00:17:F2": "Apple",
"00:19:E3": "Apple",
"00:1B:63": "Apple",
"00:1C:B3": "Apple",
"00:1D:4F": "Apple",
"00:1E:52": "Apple",
"00:1E:C2": "Apple",
"00:1F:5B": "Apple",
"00:1F:F3": "Apple",
"00:21:E9": "Apple",
"00:22:41": "Apple",
"00:23:12": "Apple",
"00:23:32": "Apple",
"00:23:6C": "Apple",
"00:23:DF": "Apple",
"00:24:36": "Apple",
"00:25:00": "Apple",
"00:25:4B": "Apple",
"00:25:BC": "Apple",
"00:26:08": "Apple",
"00:26:4A": "Apple",
"00:26:B0": "Apple",
"00:26:BB": "Apple",
"00:50:F2": "Microsoft",
"00:15:5D": "Microsoft",
"00:17:FA": "Microsoft",
"00:1D:D8": "Microsoft",
"00:50:56": "VMware",
"00:0C:29": "VMware",
"00:05:69": "VMware",
"08:00:27": "VirtualBox",
"00:1C:42": "Parallels",
"00:16:3E": "Xen",
"DC:A6:32": "Raspberry Pi",
"B8:27:EB": "Raspberry Pi",
"E4:5F:01": "Raspberry Pi",
"28:CD:C1": "Raspberry Pi",
"00:1A:11": "Google",
"00:1A:22": "Google",
"3C:5A:B4": "Google",
"54:60:09": "Google",
"94:EB:2C": "Google",
"F4:F5:D8": "Google",
"00:17:C4": "Netgear",
"00:1B:2F": "Netgear",
"00:1E:2A": "Netgear",
"00:22:3F": "Netgear",
"00:24:B2": "Netgear",
"00:26:F2": "Netgear",
"00:18:F8": "Cisco",
"00:1A:A1": "Cisco",
"00:1B:0C": "Cisco",
"00:1B:D4": "Cisco",
"00:1C:0E": "Cisco",
"00:1C:57": "Cisco",
"00:40:96": "Cisco",
"00:50:54": "Cisco",
"00:60:5C": "Cisco",
"E8:65:D4": "Ubiquiti",
"FC:EC:DA": "Ubiquiti",
"00:27:22": "Ubiquiti",
"04:18:D6": "Ubiquiti",
"18:E8:29": "Ubiquiti",
"24:A4:3C": "Ubiquiti",
"44:D9:E7": "Ubiquiti",
"68:72:51": "Ubiquiti",
"74:83:C2": "Ubiquiti",
"78:8A:20": "Ubiquiti",
"B4:FB:E4": "Ubiquiti",
"F0:9F:C2": "Ubiquiti",
"00:0C:F1": "Intel",
"00:13:02": "Intel",
"00:13:20": "Intel",
"00:13:CE": "Intel",
"00:13:E8": "Intel",
"00:15:00": "Intel",
"00:15:17": "Intel",
"00:16:6F": "Intel",
"00:16:76": "Intel",
"00:16:EA": "Intel",
"00:16:EB": "Intel",
"00:18:DE": "Intel",
"00:19:D1": "Intel",
"00:19:D2": "Intel",
"00:1B:21": "Intel",
"00:1B:77": "Intel",
"00:1C:BF": "Intel",
"00:1D:E0": "Intel",
"00:1D:E1": "Intel",
"00:1E:64": "Intel",
"00:1E:65": "Intel",
"00:1E:67": "Intel",
"00:1F:3B": "Intel",
"00:1F:3C": "Intel",
"00:20:E0": "TP-Link",
"00:23:CD": "TP-Link",
"00:25:86": "TP-Link",
"00:27:19": "TP-Link",
"14:CC:20": "TP-Link",
"14:CF:92": "TP-Link",
"18:A6:F7": "TP-Link",
"1C:3B:F3": "TP-Link",
"30:B5:C2": "TP-Link",
"50:C7:BF": "TP-Link",
"54:C8:0F": "TP-Link",
"60:E3:27": "TP-Link",
"64:56:01": "TP-Link",
"64:66:B3": "TP-Link",
"64:70:02": "TP-Link",
}
def get_vendor_from_mac(mac: str) -> str | None:
"""Get vendor name from MAC address OUI."""
if not mac:
return None
# Normalize MAC format
mac_upper = mac.upper().replace('-', ':')
oui = mac_upper[:8]
vendor = VENDOR_OUIS.get(oui)
if vendor:
return vendor
# Fallback to expanded OUI database if available
try:
from data.oui import get_manufacturer
manufacturer = get_manufacturer(mac_upper)
if manufacturer and manufacturer != 'Unknown':
return manufacturer
except Exception:
return None
return None
def get_vendor_from_mac(mac: str) -> str | None:
"""Get vendor name from MAC address OUI."""
if not mac:
return None
# Normalize MAC format
mac_upper = mac.upper().replace("-", ":")
oui = mac_upper[:8]
vendor = VENDOR_OUIS.get(oui)
if vendor:
return vendor
# Fallback to expanded OUI database if available
try:
from data.oui import get_manufacturer
manufacturer = get_manufacturer(mac_upper)
if manufacturer and manufacturer != "Unknown":
return manufacturer
except Exception:
return None
return None
# =============================================================================
+97 -82
View File
@@ -57,6 +57,7 @@ DEAUTH_REASON_CODES = {
@dataclass
class DeauthPacketInfo:
"""Information about a captured deauth/disassoc packet."""
timestamp: float
frame_type: str # 'deauth' or 'disassoc'
src_mac: str
@@ -69,6 +70,7 @@ class DeauthPacketInfo:
@dataclass
class DeauthTracker:
"""Tracks deauth packets for a specific source/dest/bssid combination."""
packets: list[DeauthPacketInfo] = field(default_factory=list)
first_seen: float = 0.0
last_seen: float = 0.0
@@ -100,6 +102,7 @@ class DeauthTracker:
@dataclass
class DeauthAlert:
"""A deauthentication attack alert."""
id: str
timestamp: float
severity: str # 'low', 'medium', 'high'
@@ -136,38 +139,38 @@ class DeauthAlert:
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
'id': self.id,
'type': 'deauth_alert',
'timestamp': self.timestamp,
'severity': self.severity,
'attacker': {
'mac': self.attacker_mac,
'vendor': self.attacker_vendor,
'signal_dbm': self.attacker_signal_dbm,
'is_spoofed_ap': self.is_spoofed_ap,
"id": self.id,
"type": "deauth_alert",
"timestamp": self.timestamp,
"severity": self.severity,
"attacker": {
"mac": self.attacker_mac,
"vendor": self.attacker_vendor,
"signal_dbm": self.attacker_signal_dbm,
"is_spoofed_ap": self.is_spoofed_ap,
},
'target': {
'mac': self.target_mac,
'vendor': self.target_vendor,
'type': self.target_type,
'known_from_scan': self.target_known_from_scan,
"target": {
"mac": self.target_mac,
"vendor": self.target_vendor,
"type": self.target_type,
"known_from_scan": self.target_known_from_scan,
},
'access_point': {
'bssid': self.ap_bssid,
'essid': self.ap_essid,
'channel': self.ap_channel,
"access_point": {
"bssid": self.ap_bssid,
"essid": self.ap_essid,
"channel": self.ap_channel,
},
'attack_info': {
'frame_type': self.frame_type,
'reason_code': self.reason_code,
'reason_text': self.reason_text,
'packet_count': self.packet_count,
'window_seconds': self.window_seconds,
'packets_per_second': self.packets_per_second,
"attack_info": {
"frame_type": self.frame_type,
"reason_code": self.reason_code,
"reason_text": self.reason_text,
"packet_count": self.packet_count,
"window_seconds": self.window_seconds,
"packets_per_second": self.packets_per_second,
},
'analysis': {
'attack_type': self.attack_type,
'description': self.description,
"analysis": {
"attack_type": self.attack_type,
"description": self.description,
},
}
@@ -226,12 +229,12 @@ class DeauthDetector:
def stats(self) -> dict:
"""Get detector statistics."""
return {
'is_running': self.is_running,
'interface': self.interface,
'started_at': self._started_at,
'packets_captured': self._packets_captured,
'alerts_generated': self._alerts_generated,
'active_trackers': len(self._trackers),
"is_running": self.is_running,
"interface": self.interface,
"started_at": self._started_at,
"packets_captured": self._packets_captured,
"alerts_generated": self._alerts_generated,
"active_trackers": len(self._trackers),
}
def start(self) -> bool:
@@ -299,10 +302,12 @@ class DeauthDetector:
from scapy.all import Dot11, Dot11Deauth, Dot11Disas, sniff
except ImportError:
logger.error("scapy not installed. Install with: pip install scapy")
self.event_callback({
'type': 'deauth_error',
'error': 'scapy not installed',
})
self.event_callback(
{
"type": "deauth_error",
"error": "scapy not installed",
}
)
return
logger.info(f"Starting deauth sniff on {self.interface}")
@@ -341,22 +346,28 @@ class DeauthDetector:
except OSError as e:
if "No such device" in str(e):
logger.error(f"Interface {self.interface} not found")
self.event_callback({
'type': 'deauth_error',
'error': f'Interface {self.interface} not found',
})
self.event_callback(
{
"type": "deauth_error",
"error": f"Interface {self.interface} not found",
}
)
else:
logger.exception(f"Sniff error: {e}")
self.event_callback({
'type': 'deauth_error',
'error': str(e),
})
self.event_callback(
{
"type": "deauth_error",
"error": str(e),
}
)
except Exception as e:
logger.exception(f"Sniff error: {e}")
self.event_callback({
'type': 'deauth_error',
'error': str(e),
})
self.event_callback(
{
"type": "deauth_error",
"error": str(e),
}
)
def _process_deauth_packet(self, pkt):
"""Process a deauth/disassoc packet and emit alert if threshold exceeded."""
@@ -367,19 +378,19 @@ class DeauthDetector:
# Determine frame type
if pkt.haslayer(Dot11Deauth):
frame_type = 'deauth'
frame_type = "deauth"
reason_code = pkt[Dot11Deauth].reason
elif pkt.haslayer(Dot11Disas):
frame_type = 'disassoc'
frame_type = "disassoc"
reason_code = pkt[Dot11Disas].reason
else:
return
# Extract addresses from Dot11 layer
dot11 = pkt[Dot11]
dst_mac = (dot11.addr1 or '').upper()
src_mac = (dot11.addr2 or '').upper()
bssid = (dot11.addr3 or '').upper()
dst_mac = (dot11.addr1 or "").upper()
src_mac = (dot11.addr2 or "").upper()
bssid = (dot11.addr3 or "").upper()
# Skip if addresses are missing
if not src_mac or not dst_mac:
@@ -448,11 +459,11 @@ class DeauthDetector:
# Determine severity
if packet_count >= DEAUTH_CRITICAL_THRESHOLD:
severity = 'high'
severity = "high"
elif packet_count >= DEAUTH_ALERT_THRESHOLD * 2.5:
severity = 'medium'
severity = "medium"
else:
severity = 'low'
severity = "low"
# Lookup AP info
ap_info = self._lookup_ap(bssid)
@@ -461,12 +472,12 @@ class DeauthDetector:
target_info = self._lookup_device(dst_mac)
# Determine target type
if dst_mac == 'FF:FF:FF:FF:FF:FF':
target_type = 'broadcast'
if dst_mac == "FF:FF:FF:FF:FF:FF":
target_type = "broadcast"
elif dst_mac in self._get_known_aps():
target_type = 'ap'
target_type = "ap"
else:
target_type = 'client'
target_type = "client"
# Check if source is spoofed (matches known AP)
is_spoofed = self._check_spoofed_source(src_mac)
@@ -482,15 +493,17 @@ class DeauthDetector:
pps = 0.0
# Determine attack type and description
if dst_mac == 'FF:FF:FF:FF:FF:FF':
attack_type = 'broadcast'
if dst_mac == "FF:FF:FF:FF:FF:FF":
attack_type = "broadcast"
description = "Broadcast deauth flood targeting all clients on the network"
elif target_type == 'ap':
attack_type = 'ap_flood'
elif target_type == "ap":
attack_type = "ap_flood"
description = "Deauth flood targeting access point"
else:
attack_type = 'targeted'
description = f"Targeted deauth flood against {'known' if target_info.get('known_from_scan') else 'unknown'} client"
attack_type = "targeted"
description = (
f"Targeted deauth flood against {'known' if target_info.get('known_from_scan') else 'unknown'} client"
)
# Get reason code info
reason_code = latest_pkt.reason_code if latest_pkt else 0
@@ -516,13 +529,13 @@ class DeauthDetector:
attacker_signal_dbm=signal_dbm,
is_spoofed_ap=is_spoofed,
target_mac=dst_mac,
target_vendor=target_info.get('vendor'),
target_vendor=target_info.get("vendor"),
target_type=target_type,
target_known_from_scan=target_info.get('known_from_scan', False),
target_known_from_scan=target_info.get("known_from_scan", False),
ap_bssid=bssid,
ap_essid=ap_info.get('essid'),
ap_channel=ap_info.get('channel'),
frame_type=latest_pkt.frame_type if latest_pkt else 'deauth',
ap_essid=ap_info.get("essid"),
ap_channel=ap_info.get("channel"),
frame_type=latest_pkt.frame_type if latest_pkt else "deauth",
reason_code=reason_code,
reason_text=reason_text,
packet_count=packet_count,
@@ -535,21 +548,21 @@ class DeauthDetector:
def _lookup_ap(self, bssid: str) -> dict:
"""Get AP info from current scan data."""
if not self.get_networks:
return {'bssid': bssid, 'essid': None, 'channel': None}
return {"bssid": bssid, "essid": None, "channel": None}
try:
networks = self.get_networks()
ap = networks.get(bssid.upper())
if ap:
return {
'bssid': bssid,
'essid': ap.get('essid') or ap.get('ssid'),
'channel': ap.get('channel'),
"bssid": bssid,
"essid": ap.get("essid") or ap.get("ssid"),
"channel": ap.get("channel"),
}
except Exception as e:
logger.debug(f"Error looking up AP {bssid}: {e}")
return {'bssid': bssid, 'essid': None, 'channel': None}
return {"bssid": bssid, "essid": None, "channel": None}
def _lookup_device(self, mac: str) -> dict:
"""Get device info and vendor from MAC."""
@@ -565,9 +578,9 @@ class DeauthDetector:
pass
return {
'mac': mac,
'vendor': vendor,
'known_from_scan': known_from_scan,
"mac": mac,
"vendor": vendor,
"known_from_scan": known_from_scan,
}
def _get_known_aps(self) -> set[str]:
@@ -589,14 +602,16 @@ class DeauthDetector:
"""Get vendor from MAC OUI."""
try:
from data.oui import get_manufacturer
vendor = get_manufacturer(mac)
return vendor if vendor != 'Unknown' else None
return vendor if vendor != "Unknown" else None
except Exception:
pass
# Fallback to wifi constants
try:
from utils.wifi.constants import get_vendor_from_mac
return get_vendor_from_mac(mac)
except Exception:
return None
+25 -28
View File
@@ -36,6 +36,7 @@ _correlator_lock = threading.Lock()
@dataclass
class ProbeRecord:
"""Record of a probe request."""
timestamp: datetime
client_mac: str
probed_ssid: str
@@ -44,6 +45,7 @@ class ProbeRecord:
@dataclass
class AssociationRecord:
"""Record of a client association."""
timestamp: datetime
client_mac: str
bssid: str
@@ -52,6 +54,7 @@ class AssociationRecord:
@dataclass
class CorrelationResult:
"""Result of an SSID correlation."""
bssid: str
revealed_ssid: str
client_mac: str
@@ -110,11 +113,13 @@ class HiddenSSIDCorrelator:
client_mac = client_mac.upper()
with self._lock:
self._probe_records.append(ProbeRecord(
timestamp=timestamp,
client_mac=client_mac,
probed_ssid=probed_ssid,
))
self._probe_records.append(
ProbeRecord(
timestamp=timestamp,
client_mac=client_mac,
probed_ssid=probed_ssid,
)
)
# Prune old records
self._prune_records()
@@ -139,11 +144,13 @@ class HiddenSSIDCorrelator:
bssid = bssid.upper()
with self._lock:
self._association_records.append(AssociationRecord(
timestamp=timestamp,
client_mac=client_mac,
bssid=bssid,
))
self._association_records.append(
AssociationRecord(
timestamp=timestamp,
client_mac=client_mac,
bssid=bssid,
)
)
# Prune old records
self._prune_records()
@@ -206,10 +213,7 @@ class HiddenSSIDCorrelator:
Dict of BSSID -> revealed SSID.
"""
with self._lock:
return {
bssid: result.revealed_ssid
for bssid, result in self._revealed.items()
}
return {bssid: result.revealed_ssid for bssid, result in self._revealed.items()}
def set_callback(self, callback: Callable[[CorrelationResult], None]):
"""Set callback for when an SSID is revealed."""
@@ -219,15 +223,9 @@ class HiddenSSIDCorrelator:
"""Remove records older than the correlation window."""
cutoff = datetime.now() - timedelta(seconds=self.correlation_window * 2)
self._probe_records = [
r for r in self._probe_records
if r.timestamp > cutoff
]
self._probe_records = [r for r in self._probe_records if r.timestamp > cutoff]
self._association_records = [
r for r in self._association_records
if r.timestamp > cutoff
]
self._association_records = [r for r in self._association_records if r.timestamp > cutoff]
def _check_correlations(self):
"""Check for new SSID correlations."""
@@ -241,8 +239,7 @@ class HiddenSSIDCorrelator:
# Find associations with this hidden AP
relevant_associations = [
a for a in self._association_records
if a.bssid == bssid and (now - a.timestamp) <= window
a for a in self._association_records if a.bssid == bssid and (now - a.timestamp) <= window
]
if not relevant_associations:
@@ -251,7 +248,8 @@ class HiddenSSIDCorrelator:
# For each associated client, look for recent probes
for assoc in relevant_associations:
client_probes = [
p for p in self._probe_records
p
for p in self._probe_records
if p.client_mac == assoc.client_mac
and abs((p.timestamp - assoc.timestamp).total_seconds()) <= self.correlation_window
]
@@ -274,14 +272,13 @@ class HiddenSSIDCorrelator:
client_mac=assoc.client_mac,
confidence=confidence,
correlation_time=now,
method='probe_association',
method="probe_association",
)
self._revealed[bssid] = result
logger.info(
f"Hidden SSID revealed: {bssid} -> '{latest_probe.probed_ssid}' "
f"(confidence: {confidence:.2f})"
f"Hidden SSID revealed: {bssid} -> '{latest_probe.probed_ssid}' (confidence: {confidence:.2f})"
)
# Callback
+183 -224
View File
@@ -45,7 +45,7 @@ class WiFiObservation:
@property
def is_hidden(self) -> bool:
"""Check if this is a hidden network."""
return not self.essid or self.essid.strip() == ''
return not self.essid or self.essid.strip() == ""
@property
def band(self) -> str:
@@ -62,21 +62,21 @@ class WiFiObservation:
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
'timestamp': self.timestamp.isoformat(),
'bssid': self.bssid,
'essid': self.essid,
'is_hidden': self.is_hidden,
'channel': self.channel,
'frequency_mhz': self.frequency_mhz,
'band': self.band,
'rssi': self.rssi,
'security': self.security,
'cipher': self.cipher,
'auth': self.auth,
'width': self.width,
'beacon_count': self.beacon_count,
'data_count': self.data_count,
'vendor': self.vendor,
"timestamp": self.timestamp.isoformat(),
"bssid": self.bssid,
"essid": self.essid,
"is_hidden": self.is_hidden,
"channel": self.channel,
"frequency_mhz": self.frequency_mhz,
"band": self.band,
"rssi": self.rssi,
"security": self.security,
"cipher": self.cipher,
"auth": self.auth,
"width": self.width,
"beacon_count": self.beacon_count,
"data_count": self.data_count,
"vendor": self.vendor,
}
@@ -164,111 +164,99 @@ class WiFiAccessPoint:
if not self.rssi_samples:
return []
samples = self.rssi_samples[-max_points:]
return [
{'timestamp': ts.isoformat(), 'rssi': rssi}
for ts, rssi in samples
]
return [{"timestamp": ts.isoformat(), "rssi": rssi} for ts, rssi in samples]
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
# Identity
'bssid': self.bssid,
'essid': self.essid,
'display_name': self.display_name,
'is_hidden': self.is_hidden,
'revealed_essid': self.revealed_essid,
"bssid": self.bssid,
"essid": self.essid,
"display_name": self.display_name,
"is_hidden": self.is_hidden,
"revealed_essid": self.revealed_essid,
# Radio
'channel': self.channel,
'frequency_mhz': self.frequency_mhz,
'band': self.band,
'width': self.width,
"channel": self.channel,
"frequency_mhz": self.frequency_mhz,
"band": self.band,
"width": self.width,
# Signal
'rssi_current': self.rssi_current,
'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None,
'rssi_min': self.rssi_min,
'rssi_max': self.rssi_max,
'rssi_variance': round(self.rssi_variance, 2) if self.rssi_variance else None,
'rssi_ema': round(self.rssi_ema, 1) if self.rssi_ema else None,
'rssi_history': self.get_rssi_history(),
"rssi_current": self.rssi_current,
"rssi_median": round(self.rssi_median, 1) if self.rssi_median else None,
"rssi_min": self.rssi_min,
"rssi_max": self.rssi_max,
"rssi_variance": round(self.rssi_variance, 2) if self.rssi_variance else None,
"rssi_ema": round(self.rssi_ema, 1) if self.rssi_ema else None,
"rssi_history": self.get_rssi_history(),
# Proximity
'signal_band': self.signal_band,
'proximity_band': self.proximity_band,
'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
'distance_confidence': round(self.distance_confidence, 2),
"signal_band": self.signal_band,
"proximity_band": self.proximity_band,
"estimated_distance_m": round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
"distance_confidence": round(self.distance_confidence, 2),
# Security
'security': self.security,
'cipher': self.cipher,
'auth': self.auth,
"security": self.security,
"cipher": self.cipher,
"auth": self.auth,
# Timestamps
'first_seen': self.first_seen.isoformat(),
'last_seen': self.last_seen.isoformat(),
'age_seconds': round(self.age_seconds, 1),
'duration_seconds': round(self.duration_seconds, 1),
'seen_count': self.seen_count,
'seen_rate': round(self.seen_rate, 2),
"first_seen": self.first_seen.isoformat(),
"last_seen": self.last_seen.isoformat(),
"age_seconds": round(self.age_seconds, 1),
"duration_seconds": round(self.duration_seconds, 1),
"seen_count": self.seen_count,
"seen_rate": round(self.seen_rate, 2),
# Traffic
'beacon_count': self.beacon_count,
'data_count': self.data_count,
'client_count': self.client_count,
"beacon_count": self.beacon_count,
"data_count": self.data_count,
"client_count": self.client_count,
# Metadata
'vendor': self.vendor,
"vendor": self.vendor,
# Heuristics
'heuristic_flags': self.heuristic_flags,
'heuristics': {
'is_new': self.is_new,
'is_persistent': self.is_persistent,
'is_strong_stable': self.is_strong_stable,
"heuristic_flags": self.heuristic_flags,
"heuristics": {
"is_new": self.is_new,
"is_persistent": self.is_persistent,
"is_strong_stable": self.is_strong_stable,
},
# Baseline
'in_baseline': self.in_baseline,
'baseline_id': self.baseline_id,
"in_baseline": self.in_baseline,
"baseline_id": self.baseline_id,
}
def to_summary_dict(self) -> dict:
"""Compact dictionary for list views."""
return {
'bssid': self.bssid,
'essid': self.essid,
'display_name': self.display_name,
'is_hidden': self.is_hidden,
'channel': self.channel,
'band': self.band,
'rssi_current': self.rssi_current,
'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None,
'signal_band': self.signal_band,
'proximity_band': self.proximity_band,
'security': self.security,
'vendor': self.vendor,
'client_count': self.client_count,
'last_seen': self.last_seen.isoformat(),
'age_seconds': round(self.age_seconds, 1),
'heuristic_flags': self.heuristic_flags,
'in_baseline': self.in_baseline,
"bssid": self.bssid,
"essid": self.essid,
"display_name": self.display_name,
"is_hidden": self.is_hidden,
"channel": self.channel,
"band": self.band,
"rssi_current": self.rssi_current,
"rssi_median": round(self.rssi_median, 1) if self.rssi_median else None,
"signal_band": self.signal_band,
"proximity_band": self.proximity_band,
"security": self.security,
"vendor": self.vendor,
"client_count": self.client_count,
"last_seen": self.last_seen.isoformat(),
"age_seconds": round(self.age_seconds, 1),
"heuristic_flags": self.heuristic_flags,
"in_baseline": self.in_baseline,
}
def to_legacy_dict(self) -> dict:
"""Convert to legacy format for TSCM compatibility."""
return {
'bssid': self.bssid,
'essid': self.essid or '',
'vendor': self.vendor,
'power': str(self.rssi_current) if self.rssi_current else '-100',
'channel': str(self.channel) if self.channel else '',
'privacy': self.security,
'first_seen': self.first_seen.isoformat() if self.first_seen else '',
'last_seen': self.last_seen.isoformat() if self.last_seen else '',
'beacon_count': str(self.beacon_count),
'lan_ip': '', # Not tracked in new system
def to_legacy_dict(self) -> dict:
"""Convert to legacy format for TSCM compatibility."""
return {
"bssid": self.bssid,
"essid": self.essid or "",
"vendor": self.vendor,
"power": str(self.rssi_current) if self.rssi_current else "-100",
"channel": str(self.channel) if self.channel else "",
"privacy": self.security,
"first_seen": self.first_seen.isoformat() if self.first_seen else "",
"last_seen": self.last_seen.isoformat() if self.last_seen else "",
"beacon_count": str(self.beacon_count),
"lan_ip": "", # Not tracked in new system
}
@@ -323,50 +311,40 @@ class WiFiClient:
if not self.rssi_samples:
return []
samples = self.rssi_samples[-max_points:]
return [
{'timestamp': ts.isoformat(), 'rssi': rssi}
for ts, rssi in samples
]
return [{"timestamp": ts.isoformat(), "rssi": rssi} for ts, rssi in samples]
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
'mac': self.mac,
'vendor': self.vendor,
"mac": self.mac,
"vendor": self.vendor,
# Signal
'rssi_current': self.rssi_current,
'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None,
'rssi_min': self.rssi_min,
'rssi_max': self.rssi_max,
'rssi_ema': round(self.rssi_ema, 1) if self.rssi_ema else None,
'rssi_history': self.get_rssi_history(),
"rssi_current": self.rssi_current,
"rssi_median": round(self.rssi_median, 1) if self.rssi_median else None,
"rssi_min": self.rssi_min,
"rssi_max": self.rssi_max,
"rssi_ema": round(self.rssi_ema, 1) if self.rssi_ema else None,
"rssi_history": self.get_rssi_history(),
# Proximity
'signal_band': self.signal_band,
'proximity_band': self.proximity_band,
'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
"signal_band": self.signal_band,
"proximity_band": self.proximity_band,
"estimated_distance_m": round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
# Association
'associated_bssid': self.associated_bssid,
'is_associated': self.is_associated,
"associated_bssid": self.associated_bssid,
"is_associated": self.is_associated,
# Probes
'probed_ssids': self.probed_ssids,
'probe_count': len(self.probed_ssids),
"probed_ssids": self.probed_ssids,
"probe_count": len(self.probed_ssids),
# Timestamps
'first_seen': self.first_seen.isoformat(),
'last_seen': self.last_seen.isoformat(),
'age_seconds': round(self.age_seconds, 1),
'seen_count': self.seen_count,
"first_seen": self.first_seen.isoformat(),
"last_seen": self.last_seen.isoformat(),
"age_seconds": round(self.age_seconds, 1),
"seen_count": self.seen_count,
# Traffic
'packets_sent': self.packets_sent,
'packets_received': self.packets_received,
"packets_sent": self.packets_sent,
"packets_received": self.packets_received,
# Heuristics
'heuristic_flags': self.heuristic_flags,
"heuristic_flags": self.heuristic_flags,
}
@@ -383,11 +361,11 @@ class WiFiProbeRequest:
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
'timestamp': self.timestamp.isoformat(),
'client_mac': self.client_mac,
'probed_ssid': self.probed_ssid,
'rssi': self.rssi,
'client_vendor': self.client_vendor,
"timestamp": self.timestamp.isoformat(),
"client_mac": self.client_mac,
"probed_ssid": self.probed_ssid,
"rssi": self.rssi,
"client_vendor": self.client_vendor,
}
@@ -417,16 +395,16 @@ class ChannelStats:
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
'channel': self.channel,
'band': self.band,
'frequency_mhz': self.frequency_mhz,
'ap_count': self.ap_count,
'client_count': self.client_count,
'rssi_avg': round(self.rssi_avg, 1) if self.rssi_avg else None,
'rssi_min': self.rssi_min,
'rssi_max': self.rssi_max,
'utilization_score': round(self.utilization_score, 3),
'recommendation_rank': self.recommendation_rank,
"channel": self.channel,
"band": self.band,
"frequency_mhz": self.frequency_mhz,
"ap_count": self.ap_count,
"client_count": self.client_count,
"rssi_avg": round(self.rssi_avg, 1) if self.rssi_avg else None,
"rssi_min": self.rssi_min,
"rssi_max": self.rssi_max,
"utilization_score": round(self.utilization_score, 3),
"recommendation_rank": self.recommendation_rank,
}
@@ -444,12 +422,12 @@ class ChannelRecommendation:
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
'channel': self.channel,
'band': self.band,
'score': round(self.score, 3),
'reason': self.reason,
'is_dfs': self.is_dfs,
'rank': self.recommendation_rank,
"channel": self.channel,
"band": self.band,
"score": round(self.score, 3),
"reason": self.reason,
"is_dfs": self.is_dfs,
"rank": self.recommendation_rank,
}
@@ -497,42 +475,38 @@ class WiFiScanResult:
"""Convert to dictionary for JSON serialization."""
return {
# Entities
'access_points': [ap.to_dict() for ap in self.access_points],
'clients': [c.to_dict() for c in self.clients],
'probe_requests': [p.to_dict() for p in self.probe_requests],
"access_points": [ap.to_dict() for ap in self.access_points],
"clients": [c.to_dict() for c in self.clients],
"probe_requests": [p.to_dict() for p in self.probe_requests],
# Channel analysis
'channel_stats': [cs.to_dict() for cs in self.channel_stats],
'recommendations': [r.to_dict() for r in self.recommendations],
"channel_stats": [cs.to_dict() for cs in self.channel_stats],
"recommendations": [r.to_dict() for r in self.recommendations],
# Metadata
'scan_mode': self.scan_mode,
'interface': self.interface,
'started_at': self.started_at.isoformat() if self.started_at else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
'duration_seconds': round(self.duration_seconds, 2) if self.duration_seconds else None,
"scan_mode": self.scan_mode,
"interface": self.interface,
"started_at": self.started_at.isoformat() if self.started_at else None,
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
"duration_seconds": round(self.duration_seconds, 2) if self.duration_seconds else None,
# Stats
'network_count': self.network_count,
'client_count': self.client_count,
'hidden_count': self.hidden_count,
"network_count": self.network_count,
"client_count": self.client_count,
"hidden_count": self.hidden_count,
# Status
'is_complete': self.is_complete,
'error': self.error,
'warnings': self.warnings,
"is_complete": self.is_complete,
"error": self.error,
"warnings": self.warnings,
}
def to_summary_dict(self) -> dict:
"""Compact summary for status endpoints."""
return {
'scan_mode': self.scan_mode,
'interface': self.interface,
'network_count': self.network_count,
'client_count': self.client_count,
'hidden_count': self.hidden_count,
'is_complete': self.is_complete,
'error': self.error,
"scan_mode": self.scan_mode,
"interface": self.interface,
"network_count": self.network_count,
"client_count": self.client_count,
"hidden_count": self.hidden_count,
"is_complete": self.is_complete,
"error": self.error,
}
@@ -558,14 +532,14 @@ class WiFiScanStatus:
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
'is_scanning': self.is_scanning,
'scan_mode': self.scan_mode,
'interface': self.interface,
'started_at': self.started_at.isoformat() if self.started_at else None,
'elapsed_seconds': round(self.elapsed_seconds, 1) if self.elapsed_seconds else None,
'networks_found': self.networks_found,
'clients_found': self.clients_found,
'error': self.error,
"is_scanning": self.is_scanning,
"scan_mode": self.scan_mode,
"interface": self.interface,
"started_at": self.started_at.isoformat() if self.started_at else None,
"elapsed_seconds": round(self.elapsed_seconds, 1) if self.elapsed_seconds else None,
"networks_found": self.networks_found,
"clients_found": self.clients_found,
"error": self.error,
}
@@ -574,7 +548,7 @@ class WiFiCapabilities:
"""WiFi system capabilities check result."""
# Platform
platform: str = 'unknown' # 'linux', 'darwin', 'windows'
platform: str = "unknown" # 'linux', 'darwin', 'windows'
is_root: bool = False
# Interfaces
@@ -600,54 +574,39 @@ class WiFiCapabilities:
@property
def can_quick_scan(self) -> bool:
"""Whether quick scanning is available."""
return (
self.has_nmcli or
self.has_iw or
self.has_iwlist or
self.has_airport
) and len(self.interfaces) > 0
return (self.has_nmcli or self.has_iw or self.has_iwlist or self.has_airport) and len(self.interfaces) > 0
@property
def can_deep_scan(self) -> bool:
"""Whether deep scanning is available."""
return (
self.has_airmon_ng and
self.has_airodump_ng and
self.has_monitor_capable_interface and
self.is_root
)
return self.has_airmon_ng and self.has_airodump_ng and self.has_monitor_capable_interface and self.is_root
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
# Status
'available': self.can_quick_scan,
'can_quick_scan': self.can_quick_scan,
'can_deep_scan': self.can_deep_scan,
"available": self.can_quick_scan,
"can_quick_scan": self.can_quick_scan,
"can_deep_scan": self.can_deep_scan,
# Platform
'platform': self.platform,
'is_root': self.is_root,
"platform": self.platform,
"is_root": self.is_root,
# Interfaces
'interfaces': self.interfaces,
'default_interface': self.default_interface,
"interfaces": self.interfaces,
"default_interface": self.default_interface,
# Quick scan tools
'tools': {
'nmcli': self.has_nmcli,
'iw': self.has_iw,
'iwlist': self.has_iwlist,
'airport': self.has_airport,
'airmon_ng': self.has_airmon_ng,
'airodump_ng': self.has_airodump_ng,
"tools": {
"nmcli": self.has_nmcli,
"iw": self.has_iw,
"iwlist": self.has_iwlist,
"airport": self.has_airport,
"airmon_ng": self.has_airmon_ng,
"airodump_ng": self.has_airodump_ng,
},
'preferred_quick_tool': self.preferred_quick_tool,
"preferred_quick_tool": self.preferred_quick_tool,
# Deep scan
'has_monitor_capable_interface': self.has_monitor_capable_interface,
'monitor_interface': self.monitor_interface,
"has_monitor_capable_interface": self.has_monitor_capable_interface,
"monitor_interface": self.monitor_interface,
# Issues
'issues': self.issues,
"issues": self.issues,
}
+5 -5
View File
@@ -11,9 +11,9 @@ from .iwlist import parse_iwlist_scan
from .nmcli import parse_nmcli_scan
__all__ = [
'parse_airport_scan',
'parse_nmcli_scan',
'parse_iw_scan',
'parse_iwlist_scan',
'parse_airodump_csv',
"parse_airport_scan",
"parse_nmcli_scan",
"parse_iw_scan",
"parse_iwlist_scan",
"parse_airodump_csv",
]
+92 -90
View File
@@ -62,28 +62,28 @@ def parse_airodump_csv(filepath: str) -> tuple[list[WiFiObservation], list[dict]
clients = []
try:
with open(filepath, encoding='utf-8', errors='replace') as f:
with open(filepath, encoding="utf-8", errors="replace") as f:
content = f.read()
# airodump-ng separates sections with blank lines
# Split into AP section and Station section
sections = content.split('\n\n')
sections = content.split("\n\n")
for section in sections:
section = section.strip()
if not section:
continue
lines = section.split('\n')
lines = section.split("\n")
if not lines:
continue
header = lines[0].strip()
if header.startswith('BSSID'):
if header.startswith("BSSID"):
# Access Points section
networks = _parse_ap_section(lines)
elif header.startswith('Station MAC'):
elif header.startswith("Station MAC"):
# Clients/Stations section
clients = _parse_client_section(lines)
@@ -104,33 +104,33 @@ def _parse_ap_section(lines: list[str]) -> list[WiFiObservation]:
# Parse header to get column indices
header = lines[0]
header_parts = [h.strip().lower() for h in header.split(',')]
header_parts = [h.strip().lower() for h in header.split(",")]
# Find column indices
col_map = {}
for i, col in enumerate(header_parts):
if 'bssid' in col:
col_map['bssid'] = i
elif 'channel' in col and 'id-length' not in col:
col_map['channel'] = i
elif 'privacy' in col:
col_map['privacy'] = i
elif 'cipher' in col:
col_map['cipher'] = i
elif 'authentication' in col:
col_map['auth'] = i
elif 'power' in col:
col_map['power'] = i
elif 'beacons' in col or '# beacons' in col:
col_map['beacons'] = i
elif '# iv' in col or 'iv' in col:
col_map['data'] = i
elif 'essid' in col:
col_map['essid'] = i
elif 'first time seen' in col:
col_map['first_seen'] = i
elif 'last time seen' in col:
col_map['last_seen'] = i
if "bssid" in col:
col_map["bssid"] = i
elif "channel" in col and "id-length" not in col:
col_map["channel"] = i
elif "privacy" in col:
col_map["privacy"] = i
elif "cipher" in col:
col_map["cipher"] = i
elif "authentication" in col:
col_map["auth"] = i
elif "power" in col:
col_map["power"] = i
elif "beacons" in col or "# beacons" in col:
col_map["beacons"] = i
elif "# iv" in col or "iv" in col:
col_map["data"] = i
elif "essid" in col:
col_map["essid"] = i
elif "first time seen" in col:
col_map["first_seen"] = i
elif "last time seen" in col:
col_map["last_seen"] = i
# Parse data rows
for line in lines[1:]:
@@ -144,7 +144,7 @@ def _parse_ap_section(lines: list[str]) -> list[WiFiObservation]:
reader = csv.reader(io.StringIO(line))
parts = next(reader)
except Exception:
parts = line.split(',')
parts = line.split(",")
parts = [p.strip() for p in parts]
@@ -153,58 +153,58 @@ def _parse_ap_section(lines: list[str]) -> list[WiFiObservation]:
try:
# Get BSSID
bssid_idx = col_map.get('bssid', 0)
bssid_idx = col_map.get("bssid", 0)
bssid = parts[bssid_idx].upper() if bssid_idx < len(parts) else None
if not bssid or not re.match(r'^[0-9A-F:]{17}$', bssid):
if not bssid or not re.match(r"^[0-9A-F:]{17}$", bssid):
continue
# Get channel
channel = None
chan_idx = col_map.get('channel', 3)
chan_idx = col_map.get("channel", 3)
if chan_idx < len(parts):
chan_str = parts[chan_idx].strip()
if chan_str.lstrip('-').isdigit():
if chan_str.lstrip("-").isdigit():
channel = int(chan_str)
if channel < 0:
channel = abs(channel) # Negative indicates not currently on channel
# Get power/RSSI
rssi = None
power_idx = col_map.get('power', 8)
power_idx = col_map.get("power", 8)
if power_idx < len(parts):
power_str = parts[power_idx].strip()
if power_str.lstrip('-').isdigit():
if power_str.lstrip("-").isdigit():
rssi = int(power_str)
if rssi > 0:
rssi = -rssi # Should be negative
# Get security
privacy_idx = col_map.get('privacy', 5)
privacy = parts[privacy_idx].strip() if privacy_idx < len(parts) else ''
privacy_idx = col_map.get("privacy", 5)
privacy = parts[privacy_idx].strip() if privacy_idx < len(parts) else ""
security = _parse_airodump_security(privacy)
# Get cipher
cipher_idx = col_map.get('cipher', 6)
cipher_str = parts[cipher_idx].strip() if cipher_idx < len(parts) else ''
cipher_idx = col_map.get("cipher", 6)
cipher_str = parts[cipher_idx].strip() if cipher_idx < len(parts) else ""
cipher = _parse_airodump_cipher(cipher_str)
# Get auth
auth_idx = col_map.get('auth', 7)
auth_str = parts[auth_idx].strip() if auth_idx < len(parts) else ''
auth_idx = col_map.get("auth", 7)
auth_str = parts[auth_idx].strip() if auth_idx < len(parts) else ""
auth = _parse_airodump_auth(auth_str)
# Get ESSID (usually last column, might contain commas)
essid = None
essid_idx = col_map.get('essid', len(parts) - 1)
essid_idx = col_map.get("essid", len(parts) - 1)
if essid_idx < len(parts):
essid = parts[essid_idx].strip()
# Handle special markers
if essid in ('', '<length: 0>', '<length: 0>'):
if essid in ("", "<length: 0>", "<length: 0>"):
essid = None
# Get beacon count
beacon_count = 0
beacon_idx = col_map.get('beacons', 9)
beacon_idx = col_map.get("beacons", 9)
if beacon_idx < len(parts):
beacon_str = parts[beacon_idx].strip()
if beacon_str.isdigit():
@@ -212,7 +212,7 @@ def _parse_ap_section(lines: list[str]) -> list[WiFiObservation]:
# Get data count (IVs)
data_count = 0
data_idx = col_map.get('data', 10)
data_idx = col_map.get("data", 10)
if data_idx < len(parts):
data_str = parts[data_idx].strip()
if data_str.isdigit():
@@ -251,25 +251,25 @@ def _parse_client_section(lines: list[str]) -> list[dict]:
# Parse header
header = lines[0]
header_parts = [h.strip().lower() for h in header.split(',')]
header_parts = [h.strip().lower() for h in header.split(",")]
# Find column indices
col_map = {}
for i, col in enumerate(header_parts):
if 'station mac' in col:
col_map['mac'] = i
elif 'power' in col:
col_map['power'] = i
elif 'packets' in col or '# packets' in col:
col_map['packets'] = i
elif 'bssid' in col:
col_map['bssid'] = i
elif 'probed' in col:
col_map['probed'] = i
elif 'first time seen' in col:
col_map['first_seen'] = i
elif 'last time seen' in col:
col_map['last_seen'] = i
if "station mac" in col:
col_map["mac"] = i
elif "power" in col:
col_map["power"] = i
elif "packets" in col or "# packets" in col:
col_map["packets"] = i
elif "bssid" in col:
col_map["bssid"] = i
elif "probed" in col:
col_map["probed"] = i
elif "first time seen" in col:
col_map["first_seen"] = i
elif "last time seen" in col:
col_map["last_seen"] = i
# Parse data rows
for line in lines[1:]:
@@ -277,7 +277,7 @@ def _parse_client_section(lines: list[str]) -> list[dict]:
if not line:
continue
parts = line.split(',')
parts = line.split(",")
parts = [p.strip() for p in parts]
if len(parts) < 3:
@@ -285,24 +285,24 @@ def _parse_client_section(lines: list[str]) -> list[dict]:
try:
# Get MAC
mac_idx = col_map.get('mac', 0)
mac_idx = col_map.get("mac", 0)
mac = parts[mac_idx].upper() if mac_idx < len(parts) else None
if not mac or not re.match(r'^[0-9A-F:]{17}$', mac):
if not mac or not re.match(r"^[0-9A-F:]{17}$", mac):
continue
# Get power/RSSI
rssi = None
power_idx = col_map.get('power', 3)
power_idx = col_map.get("power", 3)
if power_idx < len(parts):
power_str = parts[power_idx].strip()
if power_str.lstrip('-').isdigit():
if power_str.lstrip("-").isdigit():
rssi = int(power_str)
if rssi > 0:
rssi = -rssi
# Get packets
packets = 0
packets_idx = col_map.get('packets', 4)
packets_idx = col_map.get("packets", 4)
if packets_idx < len(parts):
packets_str = parts[packets_idx].strip()
if packets_str.isdigit():
@@ -310,14 +310,14 @@ def _parse_client_section(lines: list[str]) -> list[dict]:
# Get associated BSSID
bssid = None
bssid_idx = col_map.get('bssid', 5)
bssid_idx = col_map.get("bssid", 5)
if bssid_idx < len(parts):
bssid = parts[bssid_idx].strip().upper()
if bssid == '(NOT ASSOCIATED)' or not re.match(r'^[0-9A-F:]{17}$', bssid):
if bssid == "(NOT ASSOCIATED)" or not re.match(r"^[0-9A-F:]{17}$", bssid):
bssid = None
# Get probed ESSIDs (remaining columns)
probed_idx = col_map.get('probed', 6)
probed_idx = col_map.get("probed", 6)
probed_essids = []
if probed_idx < len(parts):
for essid in parts[probed_idx:]:
@@ -325,13 +325,15 @@ def _parse_client_section(lines: list[str]) -> list[dict]:
if essid and essid not in probed_essids:
probed_essids.append(essid)
clients.append({
'mac': mac,
'rssi': rssi,
'packets': packets,
'bssid': bssid,
'probed_essids': probed_essids,
})
clients.append(
{
"mac": mac,
"rssi": rssi,
"packets": packets,
"bssid": bssid,
"probed_essids": probed_essids,
}
)
except Exception as e:
logger.debug(f"Error parsing client line: {line!r} - {e}")
@@ -343,17 +345,17 @@ def _parse_airodump_security(privacy: str) -> str:
"""Parse airodump privacy field to security type."""
privacy = privacy.upper()
if not privacy or privacy in ('', 'OPN', 'OPEN'):
if not privacy or privacy in ("", "OPN", "OPEN"):
return SECURITY_OPEN
elif 'WPA3' in privacy:
elif "WPA3" in privacy:
return SECURITY_WPA3
elif 'WPA2' in privacy and 'WPA' in privacy:
elif "WPA2" in privacy and "WPA" in privacy:
return SECURITY_WPA_WPA2
elif 'WPA2' in privacy:
elif "WPA2" in privacy:
return SECURITY_WPA2
elif 'WPA' in privacy:
elif "WPA" in privacy:
return SECURITY_WPA
elif 'WEP' in privacy:
elif "WEP" in privacy:
return SECURITY_WEP
return SECURITY_UNKNOWN
@@ -363,11 +365,11 @@ def _parse_airodump_cipher(cipher: str) -> str:
"""Parse airodump cipher field."""
cipher = cipher.upper()
if 'CCMP' in cipher:
if "CCMP" in cipher:
return CIPHER_CCMP
elif 'TKIP' in cipher:
elif "TKIP" in cipher:
return CIPHER_TKIP
elif 'WEP' in cipher:
elif "WEP" in cipher:
return CIPHER_WEP
return CIPHER_UNKNOWN
@@ -377,15 +379,15 @@ def _parse_airodump_auth(auth: str) -> str:
"""Parse airodump authentication field."""
auth = auth.upper()
if 'SAE' in auth:
if "SAE" in auth:
return AUTH_SAE
elif 'PSK' in auth:
elif "PSK" in auth:
return AUTH_PSK
elif 'MGT' in auth or 'EAP' in auth or '802.1X' in auth:
elif "MGT" in auth or "EAP" in auth or "802.1X" in auth:
return AUTH_EAP
elif 'OWE' in auth:
elif "OWE" in auth:
return AUTH_OWE
elif 'OPN' in auth or 'OPEN' in auth:
elif "OPN" in auth or "OPEN" in auth:
return AUTH_OPEN
return AUTH_UNKNOWN
+21 -21
View File
@@ -52,7 +52,7 @@ def parse_airport_scan(output: str) -> list[WiFiObservation]:
List of WiFiObservation objects.
"""
observations = []
lines = output.strip().split('\n')
lines = output.strip().split("\n")
if len(lines) < 2:
return observations
@@ -82,7 +82,7 @@ def _parse_airport_line(line: str) -> WiFiObservation | None:
# Split into parts, but we need to handle SSID which may have spaces
# BSSID is always 17 chars (xx:xx:xx:xx:xx:xx)
# Find BSSID using regex
bssid_match = re.search(r'([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}', line)
bssid_match = re.search(r"([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", line)
if not bssid_match:
return None
@@ -93,11 +93,11 @@ def _parse_airport_line(line: str) -> WiFiObservation | None:
ssid = line[:bssid_pos].strip()
# Handle hidden network indicator
if ssid == '--' or not ssid:
if ssid == "--" or not ssid:
ssid = None
# Parse remainder after BSSID
remainder = line[bssid_match.end():].strip()
remainder = line[bssid_match.end() :].strip()
parts = remainder.split()
if len(parts) < 4:
@@ -106,23 +106,23 @@ def _parse_airport_line(line: str) -> WiFiObservation | None:
# Parse RSSI (negative number)
rssi_str = parts[0]
rssi = int(rssi_str) if rssi_str.lstrip('-').isdigit() else None
rssi = int(rssi_str) if rssi_str.lstrip("-").isdigit() else None
# Parse channel - might include +1 or -1 for 40MHz
channel_str = parts[1]
channel_match = re.match(r'(\d+)', channel_str)
channel_match = re.match(r"(\d+)", channel_str)
channel = int(channel_match.group(1)) if channel_match else None
# Determine width from channel string
width = WIDTH_20_MHZ
if '+' in channel_str or '-' in channel_str:
if "+" in channel_str or "-" in channel_str:
width = WIDTH_40_MHZ
# HT flag (Y/N) at parts[2]
# CC (country code) at parts[3]
# Security is the rest (might have multiple parts like WPA2(PSK/AES/AES))
security_str = ' '.join(parts[4:]) if len(parts) > 4 else ''
security_str = " ".join(parts[4:]) if len(parts) > 4 else ""
security, cipher, auth = _parse_airport_security(security_str)
# Get frequency
@@ -158,44 +158,44 @@ def _parse_airport_security(security_str: str) -> tuple[str, str, str]:
'WEP' -> (WEP, WEP, OPEN)
'NONE' or '' -> (Open, None, Open)
"""
if not security_str or security_str.upper() == 'NONE':
if not security_str or security_str.upper() == "NONE":
return SECURITY_OPEN, CIPHER_NONE, AUTH_OPEN
security_upper = security_str.upper()
# Determine security type
security = SECURITY_UNKNOWN
if 'WPA3' in security_upper or 'SAE' in security_upper:
if "WPA3" in security_upper or "SAE" in security_upper:
security = SECURITY_WPA3
elif 'RSN' in security_upper or 'WPA2' in security_upper:
elif "RSN" in security_upper or "WPA2" in security_upper:
security = SECURITY_WPA2
elif 'WPA' in security_upper:
elif "WPA" in security_upper:
security = SECURITY_WPA
elif 'WEP' in security_upper:
elif "WEP" in security_upper:
security = SECURITY_WEP
# Handle mixed mode
if 'WPA2' in security_upper and 'WPA3' in security_upper:
if "WPA2" in security_upper and "WPA3" in security_upper:
security = SECURITY_WPA2_WPA3
elif 'WPA' in security_upper and 'WPA2' in security_upper:
elif "WPA" in security_upper and "WPA2" in security_upper:
security = SECURITY_WPA_WPA2
# Determine cipher
cipher = CIPHER_UNKNOWN
if 'AES' in security_upper or 'CCMP' in security_upper:
if "AES" in security_upper or "CCMP" in security_upper:
cipher = CIPHER_CCMP
elif 'TKIP' in security_upper:
elif "TKIP" in security_upper:
cipher = CIPHER_TKIP
elif 'WEP' in security_upper:
elif "WEP" in security_upper:
cipher = CIPHER_WEP
# Determine auth
auth = AUTH_UNKNOWN
if 'SAE' in security_upper:
if "SAE" in security_upper:
auth = AUTH_SAE
elif 'PSK' in security_upper:
elif "PSK" in security_upper:
auth = AUTH_PSK
elif 'EAP' in security_upper or '802.1X' in security_upper:
elif "EAP" in security_upper or "802.1X" in security_upper:
auth = AUTH_EAP
elif security == SECURITY_OPEN:
auth = AUTH_OPEN
+32 -32
View File
@@ -67,8 +67,8 @@ def parse_iw_scan(output: str) -> list[WiFiObservation]:
observations = []
current_block = []
for line in output.split('\n'):
if line.startswith('BSS '):
for line in output.split("\n"):
if line.startswith("BSS "):
# Start of new BSS entry
if current_block:
obs = _parse_iw_block(current_block)
@@ -92,7 +92,7 @@ def _parse_iw_block(lines: list[str]) -> WiFiObservation | None:
try:
# First line: BSS 00:11:22:33:44:55(on wlan0) -- associated
first_line = lines[0]
bssid_match = re.match(r'BSS ([0-9a-fA-F:]{17})', first_line)
bssid_match = re.match(r"BSS ([0-9a-fA-F:]{17})", first_line)
if not bssid_match:
return None
@@ -114,35 +114,35 @@ def _parse_iw_block(lines: list[str]) -> WiFiObservation | None:
while i < len(lines):
line = lines[i].strip()
if line.startswith('freq:'):
freq_match = re.search(r'freq:\s*(\d+)', line)
if line.startswith("freq:"):
freq_match = re.search(r"freq:\s*(\d+)", line)
if freq_match:
frequency_mhz = int(freq_match.group(1))
channel = get_channel_from_frequency(frequency_mhz)
elif line.startswith('signal:'):
signal_match = re.search(r'signal:\s*(-?\d+\.?\d*)', line)
elif line.startswith("signal:"):
signal_match = re.search(r"signal:\s*(-?\d+\.?\d*)", line)
if signal_match:
rssi = int(float(signal_match.group(1)))
elif line.startswith('SSID:'):
ssid_match = re.match(r'SSID:\s*(.*)', line)
elif line.startswith("SSID:"):
ssid_match = re.match(r"SSID:\s*(.*)", line)
if ssid_match:
ssid = ssid_match.group(1).strip()
if not ssid or ssid == '\\x00' * len(ssid):
if not ssid or ssid == "\\x00" * len(ssid):
ssid = None
elif line.startswith('DS Parameter set:'):
chan_match = re.search(r'channel\s*(\d+)', line)
elif line.startswith("DS Parameter set:"):
chan_match = re.search(r"channel\s*(\d+)", line)
if chan_match:
channel = int(chan_match.group(1))
elif line.startswith('capability:'):
if 'Privacy' in line:
elif line.startswith("capability:"):
if "Privacy" in line:
has_privacy = True
elif line.startswith('RSN:') or line.startswith('WPA:'):
is_rsn = line.startswith('RSN:')
elif line.startswith("RSN:") or line.startswith("WPA:"):
is_rsn = line.startswith("RSN:")
if is_rsn:
has_rsn = True
else:
@@ -150,41 +150,41 @@ def _parse_iw_block(lines: list[str]) -> WiFiObservation | None:
# Parse the RSN/WPA block
i += 1
while i < len(lines) and lines[i].startswith('\t\t'):
while i < len(lines) and lines[i].startswith("\t\t"):
subline = lines[i].strip()
if 'Group cipher:' in subline or 'Pairwise ciphers:' in subline:
if 'CCMP' in subline:
if "Group cipher:" in subline or "Pairwise ciphers:" in subline:
if "CCMP" in subline:
cipher = CIPHER_CCMP
elif 'TKIP' in subline:
elif "TKIP" in subline:
cipher = CIPHER_TKIP
elif 'GCMP' in subline:
elif "GCMP" in subline:
cipher = CIPHER_GCMP
elif 'Authentication suites:' in subline:
if 'SAE' in subline:
elif "Authentication suites:" in subline:
if "SAE" in subline:
auth = AUTH_SAE
elif 'PSK' in subline:
elif "PSK" in subline:
auth = AUTH_PSK
elif 'IEEE 802.1X' in subline or 'EAP' in subline:
elif "IEEE 802.1X" in subline or "EAP" in subline:
auth = AUTH_EAP
elif 'OWE' in subline:
elif "OWE" in subline:
auth = AUTH_OWE
i += 1
continue
elif 'HT operation:' in line or 'VHT operation:' in line or 'HE operation:' in line:
elif "HT operation:" in line or "VHT operation:" in line or "HE operation:" in line:
# Parse width from subsequent lines
i += 1
while i < len(lines) and lines[i].startswith('\t\t'):
while i < len(lines) and lines[i].startswith("\t\t"):
subline = lines[i].strip()
if 'channel width:' in subline.lower():
if '160' in subline:
if "channel width:" in subline.lower():
if "160" in subline:
width = WIDTH_160_MHZ
elif '80' in subline:
elif "80" in subline:
width = WIDTH_80_MHZ
elif '40' in subline:
elif "40" in subline:
width = WIDTH_40_MHZ
i += 1
continue
+21 -21
View File
@@ -61,9 +61,9 @@ def parse_iwlist_scan(output: str) -> list[WiFiObservation]:
observations = []
current_block = []
for line in output.split('\n'):
for line in output.split("\n"):
# New cell starts with "Cell XX - Address:"
if re.match(r'\s*Cell \d+ - Address:', line):
if re.match(r"\s*Cell \d+ - Address:", line):
if current_block:
obs = _parse_iwlist_block(current_block)
if obs:
@@ -86,7 +86,7 @@ def _parse_iwlist_block(lines: list[str]) -> WiFiObservation | None:
try:
# Extract BSSID from first line
first_line = lines[0]
bssid_match = re.search(r'Address:\s*([0-9A-Fa-f:]{17})', first_line)
bssid_match = re.search(r"Address:\s*([0-9A-Fa-f:]{17})", first_line)
if not bssid_match:
return None
@@ -107,33 +107,33 @@ def _parse_iwlist_block(lines: list[str]) -> WiFiObservation | None:
line = line.strip()
# Channel
if line.startswith('Channel:'):
chan_match = re.search(r'Channel:(\d+)', line)
if line.startswith("Channel:"):
chan_match = re.search(r"Channel:(\d+)", line)
if chan_match:
channel = int(chan_match.group(1))
# Frequency
elif line.startswith('Frequency:'):
elif line.startswith("Frequency:"):
# Format: "Frequency:2.437 GHz (Channel 6)"
freq_match = re.search(r'Frequency:(\d+\.?\d*)\s*GHz', line)
freq_match = re.search(r"Frequency:(\d+\.?\d*)\s*GHz", line)
if freq_match:
frequency_ghz = float(freq_match.group(1))
frequency_mhz = int(frequency_ghz * 1000)
# Also try to get channel from this line
chan_match = re.search(r'\(Channel (\d+)\)', line)
chan_match = re.search(r"\(Channel (\d+)\)", line)
if chan_match and not channel:
channel = int(chan_match.group(1))
# Signal level
elif 'Signal level' in line:
elif "Signal level" in line:
# Format: "Quality=70/70 Signal level=-40 dBm"
signal_match = re.search(r'Signal level[=:]?\s*(-?\d+)', line)
signal_match = re.search(r"Signal level[=:]?\s*(-?\d+)", line)
if signal_match:
rssi = int(signal_match.group(1))
# ESSID
elif line.startswith('ESSID:'):
elif line.startswith("ESSID:"):
ssid_match = re.search(r'ESSID:"([^"]*)"', line)
if ssid_match:
ssid = ssid_match.group(1)
@@ -141,27 +141,27 @@ def _parse_iwlist_block(lines: list[str]) -> WiFiObservation | None:
ssid = None
# Encryption
elif line.startswith('Encryption key:'):
has_encryption = 'on' in line.lower()
elif line.startswith("Encryption key:"):
has_encryption = "on" in line.lower()
# WPA/WPA2 IE
elif 'WPA2' in line or 'IEEE 802.11i' in line:
elif "WPA2" in line or "IEEE 802.11i" in line:
has_wpa2 = True
elif 'WPA Version' in line:
elif "WPA Version" in line:
has_wpa = True
# Cipher
elif 'Group Cipher' in line or 'Pairwise Ciphers' in line:
if 'CCMP' in line:
elif "Group Cipher" in line or "Pairwise Ciphers" in line:
if "CCMP" in line:
cipher = CIPHER_CCMP
elif 'TKIP' in line:
elif "TKIP" in line:
cipher = CIPHER_TKIP
# Auth
elif 'Authentication Suites' in line:
if 'PSK' in line:
elif "Authentication Suites" in line:
if "PSK" in line:
auth = AUTH_PSK
elif '802.1x' in line.lower() or 'EAP' in line:
elif "802.1x" in line.lower() or "EAP" in line:
auth = AUTH_EAP
# Derive channel from frequency if needed
+17 -17
View File
@@ -49,7 +49,7 @@ def parse_nmcli_scan(output: str) -> list[WiFiObservation]:
"""
observations = []
for line in output.strip().split('\n'):
for line in output.strip().split("\n"):
if not line:
continue
@@ -78,13 +78,13 @@ def _parse_nmcli_line(line: str) -> WiFiObservation | None:
freq_str = parts[4]
# rate_str = parts[5] # e.g., '130 Mbit/s'
signal_str = parts[6]
security_str = parts[7] if len(parts) > 7 else ''
security_str = parts[7] if len(parts) > 7 else ""
# Parse channel
channel = int(channel_str) if channel_str.isdigit() else None
# Parse frequency (e.g., "2437 MHz")
freq_match = re.match(r'(\d+)', freq_str)
freq_match = re.match(r"(\d+)", freq_str)
frequency_mhz = int(freq_match.group(1)) if freq_match else None
# If no channel, derive from frequency
@@ -126,13 +126,13 @@ def _split_nmcli_line(line: str) -> list[str]:
i = 0
while i < len(line):
if line[i] == '\\' and i + 1 < len(line) and line[i + 1] == ':':
if line[i] == "\\" and i + 1 < len(line) and line[i + 1] == ":":
# Escaped colon - add literal colon
current.append(':')
current.append(":")
i += 2
elif line[i] == ':':
elif line[i] == ":":
# Field delimiter
parts.append(''.join(current))
parts.append("".join(current))
current = []
i += 1
else:
@@ -140,7 +140,7 @@ def _split_nmcli_line(line: str) -> list[str]:
i += 1
# Add last field
parts.append(''.join(current))
parts.append("".join(current))
return parts
@@ -157,7 +157,7 @@ def _parse_nmcli_security(security_str: str) -> tuple[str, str, str]:
'WEP' -> (WEP, WEP, OPEN)
'' or '--' -> (Open, None, Open)
"""
if not security_str or security_str == '--':
if not security_str or security_str == "--":
return SECURITY_OPEN, CIPHER_UNKNOWN, AUTH_OPEN
security_upper = security_str.upper()
@@ -165,21 +165,21 @@ def _parse_nmcli_security(security_str: str) -> tuple[str, str, str]:
# Determine security type
security = SECURITY_UNKNOWN
if '802.1X' in security_upper:
if "802.1X" in security_upper:
security = SECURITY_ENTERPRISE
elif 'WPA3' in security_upper:
if 'WPA2' in security_upper:
elif "WPA3" in security_upper:
if "WPA2" in security_upper:
security = SECURITY_WPA2_WPA3
else:
security = SECURITY_WPA3
elif 'WPA2' in security_upper:
if 'WPA1' in security_upper or security_upper.count('WPA') > 1:
elif "WPA2" in security_upper:
if "WPA1" in security_upper or security_upper.count("WPA") > 1:
security = SECURITY_WPA_WPA2
else:
security = SECURITY_WPA2
elif 'WPA' in security_upper:
elif "WPA" in security_upper:
security = SECURITY_WPA
elif 'WEP' in security_upper:
elif "WEP" in security_upper:
security = SECURITY_WEP
# Determine cipher (assume CCMP for WPA2+)
@@ -191,7 +191,7 @@ def _parse_nmcli_security(security_str: str) -> tuple[str, str, str]:
# Determine auth
auth = AUTH_UNKNOWN
if security == SECURITY_ENTERPRISE or '802.1X' in security_upper:
if security == SECURITY_ENTERPRISE or "802.1X" in security_upper:
auth = AUTH_EAP
elif security == SECURITY_WPA3:
auth = AUTH_SAE
+195 -154
View File
@@ -145,55 +145,55 @@ class UnifiedWiFiScanner:
"""
caps = WiFiCapabilities()
caps.platform = platform.system().lower()
caps.is_root = os.geteuid() == 0 if hasattr(os, 'geteuid') else False
caps.is_root = os.geteuid() == 0 if hasattr(os, "geteuid") else False
# Detect tools
caps.has_nmcli = shutil.which('nmcli') is not None
caps.has_iw = shutil.which('iw') is not None
caps.has_iwlist = shutil.which('iwlist') is not None
caps.has_airmon_ng = shutil.which('airmon-ng') is not None
caps.has_airodump_ng = shutil.which('airodump-ng') is not None
caps.has_nmcli = shutil.which("nmcli") is not None
caps.has_iw = shutil.which("iw") is not None
caps.has_iwlist = shutil.which("iwlist") is not None
caps.has_airmon_ng = shutil.which("airmon-ng") is not None
caps.has_airodump_ng = shutil.which("airodump-ng") is not None
# macOS airport tool
airport_path = '/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport'
airport_path = "/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport"
caps.has_airport = os.path.exists(airport_path)
# Determine preferred quick scan tool
if caps.platform == 'darwin':
if caps.platform == "darwin":
if caps.has_airport:
caps.preferred_quick_tool = 'airport'
caps.preferred_quick_tool = "airport"
else: # Linux
if caps.has_nmcli:
caps.preferred_quick_tool = 'nmcli'
caps.preferred_quick_tool = "nmcli"
elif caps.has_iw:
caps.preferred_quick_tool = 'iw'
caps.preferred_quick_tool = "iw"
elif caps.has_iwlist:
caps.preferred_quick_tool = 'iwlist'
caps.preferred_quick_tool = "iwlist"
# Detect interfaces
caps.interfaces = self._detect_interfaces()
if caps.interfaces:
caps.default_interface = caps.interfaces[0].get('name')
caps.default_interface = caps.interfaces[0].get("name")
# Check for monitor-capable interface
for iface in caps.interfaces:
if iface.get('supports_monitor', False):
if iface.get("supports_monitor", False):
caps.has_monitor_capable_interface = True
caps.monitor_interface = iface.get('name')
caps.monitor_interface = iface.get("name")
break
# Build issues list
if not caps.interfaces:
caps.issues.append('No WiFi interfaces detected')
caps.issues.append("No WiFi interfaces detected")
if not caps.can_quick_scan:
caps.issues.append('No quick scan tools available')
caps.issues.append("No quick scan tools available")
if not caps.can_deep_scan:
if not caps.has_airodump_ng:
caps.issues.append('airodump-ng not installed (install aircrack-ng)')
caps.issues.append("airodump-ng not installed (install aircrack-ng)")
if not caps.is_root:
caps.issues.append('Root privileges required for deep scan')
caps.issues.append("Root privileges required for deep scan")
if not caps.has_monitor_capable_interface:
caps.issues.append('No monitor mode capable interface')
caps.issues.append("No monitor mode capable interface")
self._capabilities = caps
return caps
@@ -202,45 +202,49 @@ class UnifiedWiFiScanner:
"""Detect available WiFi interfaces."""
interfaces = []
if platform.system() == 'Darwin':
if platform.system() == "Darwin":
# macOS: Use networksetup
try:
result = subprocess.run(
['networksetup', '-listallhardwareports'],
["networksetup", "-listallhardwareports"],
capture_output=True,
text=True,
timeout=TOOL_TIMEOUT_DETECT,
)
current_port = None
for line in result.stdout.splitlines():
if line.startswith('Hardware Port:'):
current_port = line.split(':', 1)[1].strip()
elif line.startswith('Device:') and current_port:
device = line.split(':', 1)[1].strip()
if 'Wi-Fi' in current_port or 'wi-fi' in current_port.lower():
interfaces.append({
'name': device,
'description': current_port,
'supports_monitor': False, # macOS generally doesn't support monitor mode
})
if line.startswith("Hardware Port:"):
current_port = line.split(":", 1)[1].strip()
elif line.startswith("Device:") and current_port:
device = line.split(":", 1)[1].strip()
if "Wi-Fi" in current_port or "wi-fi" in current_port.lower():
interfaces.append(
{
"name": device,
"description": current_port,
"supports_monitor": False, # macOS generally doesn't support monitor mode
}
)
current_port = None
except Exception as e:
logger.debug(f"Error detecting macOS interfaces: {e}")
else:
# Linux: Use /sys/class/net or iw
try:
net_path = Path('/sys/class/net')
net_path = Path("/sys/class/net")
if net_path.exists():
for iface_path in net_path.iterdir():
wireless_path = iface_path / 'wireless'
wireless_path = iface_path / "wireless"
if wireless_path.exists():
iface_name = iface_path.name
supports_monitor = self._check_monitor_support(iface_name)
interfaces.append({
'name': iface_name,
'description': f'Wireless interface {iface_name}',
'supports_monitor': supports_monitor,
})
interfaces.append(
{
"name": iface_name,
"description": f"Wireless interface {iface_name}",
"supports_monitor": supports_monitor,
}
)
except Exception as e:
logger.debug(f"Error detecting Linux interfaces: {e}")
@@ -250,23 +254,23 @@ class UnifiedWiFiScanner:
"""Check if interface supports monitor mode."""
try:
result = subprocess.run(
['iw', interface, 'info'],
["iw", interface, "info"],
capture_output=True,
text=True,
timeout=TOOL_TIMEOUT_DETECT,
)
# Get phy name
phy_match = re.search(r'wiphy (\d+)', result.stdout)
phy_match = re.search(r"wiphy (\d+)", result.stdout)
if phy_match:
phy = f"phy{phy_match.group(1)}"
# Check supported modes
result = subprocess.run(
['iw', phy, 'info'],
["iw", phy, "info"],
capture_output=True,
text=True,
timeout=TOOL_TIMEOUT_DETECT,
)
return 'monitor' in result.stdout.lower()
return "monitor" in result.stdout.lower()
except Exception:
pass
return False
@@ -280,21 +284,21 @@ class UnifiedWiFiScanner:
- iw reports type as 'monitor'
"""
# Quick check by name convention
if interface.endswith('mon'):
if interface.endswith("mon"):
return True
# Check actual mode via iw
if shutil.which('iw'):
if shutil.which("iw"):
try:
result = subprocess.run(
['iw', interface, 'info'],
["iw", interface, "info"],
capture_output=True,
text=True,
timeout=TOOL_TIMEOUT_DETECT,
)
if result.returncode == 0:
# Look for "type monitor" in output
if re.search(r'type\s+monitor', result.stdout, re.IGNORECASE):
if re.search(r"type\s+monitor", result.stdout, re.IGNORECASE):
return True
except Exception:
pass
@@ -330,10 +334,10 @@ class UnifiedWiFiScanner:
pass
# Try ip link set up
if shutil.which('ip'):
if shutil.which("ip"):
try:
result = subprocess.run(
['ip', 'link', 'set', interface, 'up'],
["ip", "link", "set", interface, "up"],
capture_output=True,
text=True,
timeout=5,
@@ -348,10 +352,10 @@ class UnifiedWiFiScanner:
logger.warning(f"Failed to run ip link: {e}")
# Fallback to ifconfig
if shutil.which('ifconfig'):
if shutil.which("ifconfig"):
try:
result = subprocess.run(
['ifconfig', interface, 'up'],
["ifconfig", interface, "up"],
capture_output=True,
text=True,
timeout=5,
@@ -420,10 +424,10 @@ class UnifiedWiFiScanner:
errors_encountered = []
try:
if self._capabilities.platform == 'darwin':
if self._capabilities.platform == "darwin":
if self._capabilities.has_airport:
observations = self._scan_with_airport(iface, timeout)
tool_used = 'airport'
tool_used = "airport"
else:
result.error = "No WiFi scanning tool available on macOS (airport not found)"
result.is_complete = True
@@ -434,11 +438,11 @@ class UnifiedWiFiScanner:
tools_to_try = []
if self._capabilities.has_nmcli:
tools_to_try.append(('nmcli', self._scan_with_nmcli))
tools_to_try.append(("nmcli", self._scan_with_nmcli))
if self._capabilities.has_iw:
tools_to_try.append(('iw', self._scan_with_iw))
tools_to_try.append(("iw", self._scan_with_iw))
if self._capabilities.has_iwlist:
tools_to_try.append(('iwlist', self._scan_with_iwlist))
tools_to_try.append(("iwlist", self._scan_with_iwlist))
if not tools_to_try:
result.error = "No WiFi scanning tools available. Install NetworkManager (nmcli) or wireless-tools (iw/iwlist)."
@@ -457,7 +461,7 @@ class UnifiedWiFiScanner:
error_msg = f"{tool_name}: {str(e)}"
errors_encountered.append(error_msg)
logger.warning(f"Quick scan with {tool_name} failed: {e}")
if 'is down' in str(e):
if "is down" in str(e):
interface_was_down = True
continue # Try next tool
@@ -522,11 +526,11 @@ class UnifiedWiFiScanner:
"""Scan using macOS airport utility."""
from .parsers.airport import parse_airport_scan
airport_path = '/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport'
airport_path = "/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport"
try:
result = subprocess.run(
[airport_path, '-s'],
[airport_path, "-s"],
capture_output=True,
text=True,
timeout=timeout,
@@ -554,21 +558,31 @@ class UnifiedWiFiScanner:
try:
# Try to trigger a rescan first (might fail if interface not managed by NM)
rescan_result = subprocess.run(
['nmcli', 'device', 'wifi', 'rescan', 'ifname', interface],
["nmcli", "device", "wifi", "rescan", "ifname", interface],
capture_output=True,
timeout=timeout / 2,
)
if rescan_result.returncode != 0:
# Try without interface specification
subprocess.run(
['nmcli', 'device', 'wifi', 'rescan'],
["nmcli", "device", "wifi", "rescan"],
capture_output=True,
timeout=timeout / 2,
)
# Get results - try with interface first, then without
result = subprocess.run(
['nmcli', '-t', '-f', 'BSSID,SSID,MODE,CHAN,FREQ,RATE,SIGNAL,SECURITY', 'device', 'wifi', 'list', 'ifname', interface],
[
"nmcli",
"-t",
"-f",
"BSSID,SSID,MODE,CHAN,FREQ,RATE,SIGNAL,SECURITY",
"device",
"wifi",
"list",
"ifname",
interface,
],
capture_output=True,
text=True,
timeout=timeout,
@@ -578,7 +592,7 @@ class UnifiedWiFiScanner:
if result.returncode != 0 or not result.stdout.strip():
logger.debug(f"nmcli scan with interface {interface} failed, trying general scan")
result = subprocess.run(
['nmcli', '-t', '-f', 'BSSID,SSID,MODE,CHAN,FREQ,RATE,SIGNAL,SECURITY', 'device', 'wifi', 'list'],
["nmcli", "-t", "-f", "BSSID,SSID,MODE,CHAN,FREQ,RATE,SIGNAL,SECURITY", "device", "wifi", "list"],
capture_output=True,
text=True,
timeout=timeout,
@@ -587,9 +601,9 @@ class UnifiedWiFiScanner:
if result.returncode != 0:
error_msg = result.stderr.strip() or f"nmcli returned code {result.returncode}"
# Check for common issues
if 'not running' in error_msg.lower():
if "not running" in error_msg.lower():
raise RuntimeError("NetworkManager is not running")
elif 'not found' in error_msg.lower() or 'no such' in error_msg.lower():
elif "not found" in error_msg.lower() or "no such" in error_msg.lower():
raise RuntimeError(f"Interface {interface} not found or not managed by NetworkManager")
else:
raise RuntimeError(f"nmcli scan failed: {error_msg}")
@@ -609,7 +623,7 @@ class UnifiedWiFiScanner:
try:
result = subprocess.run(
['iw', interface, 'scan'],
["iw", interface, "scan"],
capture_output=True,
text=True,
timeout=timeout,
@@ -618,9 +632,9 @@ class UnifiedWiFiScanner:
if result.returncode != 0:
error_msg = result.stderr.strip() or f"iw returned code {result.returncode}"
# Check for common errors
if 'Operation not permitted' in error_msg or 'Permission denied' in error_msg:
if "Operation not permitted" in error_msg or "Permission denied" in error_msg:
raise RuntimeError(f"iw scan requires root privileges: {error_msg}")
elif 'Network is down' in error_msg:
elif "Network is down" in error_msg:
raise RuntimeError(f"Interface {interface} is down: {error_msg}")
else:
raise RuntimeError(f"iw scan failed: {error_msg}")
@@ -637,7 +651,7 @@ class UnifiedWiFiScanner:
try:
result = subprocess.run(
['iwlist', interface, 'scan'],
["iwlist", interface, "scan"],
capture_output=True,
text=True,
timeout=timeout,
@@ -645,9 +659,9 @@ class UnifiedWiFiScanner:
if result.returncode != 0:
error_msg = result.stderr.strip() or f"iwlist returned code {result.returncode}"
if 'Operation not permitted' in error_msg or 'Permission denied' in error_msg:
if "Operation not permitted" in error_msg or "Permission denied" in error_msg:
raise RuntimeError(f"iwlist scan requires root privileges: {error_msg}")
elif 'Network is down' in error_msg:
elif "Network is down" in error_msg:
raise RuntimeError(f"Interface {interface} is down: {error_msg}")
else:
raise RuntimeError(f"iwlist scan failed: {error_msg}")
@@ -665,7 +679,7 @@ class UnifiedWiFiScanner:
def start_deep_scan(
self,
interface: str | None = None,
band: str = 'all',
band: str = "all",
channel: int | None = None,
channels: list[int] | None = None,
) -> bool:
@@ -715,11 +729,13 @@ class UnifiedWiFiScanner:
started_at=datetime.now(),
)
self._queue_event({
'type': 'scan_started',
'mode': SCAN_MODE_DEEP,
'interface': iface,
})
self._queue_event(
{
"type": "scan_started",
"mode": SCAN_MODE_DEEP,
"interface": iface,
}
)
# Auto-start deauth detector
self._start_deauth_detector(iface)
@@ -752,10 +768,12 @@ class UnifiedWiFiScanner:
self._status.is_scanning = False
self._status.error = None
self._queue_event({
'type': 'scan_stopped',
'mode': SCAN_MODE_DEEP,
})
self._queue_event(
{
"type": "scan_stopped",
"mode": SCAN_MODE_DEEP,
}
)
cleanup_start = time.perf_counter()
@@ -768,7 +786,7 @@ class UnifiedWiFiScanner:
try:
detector.stop()
logger.info("Deauth detector stopped")
self._queue_event({'type': 'deauth_detector_stopped'})
self._queue_event({"type": "deauth_detector_stopped"})
except Exception as exc:
logger.error(f"Error stopping deauth detector: {exc}")
@@ -790,7 +808,7 @@ class UnifiedWiFiScanner:
target=_finalize_stop,
args=(cleanup_process, cleanup_thread, cleanup_detector),
daemon=True,
name='wifi-deep-stop',
name="wifi-deep-stop",
).start()
return True
@@ -808,22 +826,22 @@ class UnifiedWiFiScanner:
from .parsers.airodump import parse_airodump_csv
# Create temp directory for output files
with tempfile.TemporaryDirectory(prefix='wifi_scan_') as tmpdir:
output_prefix = os.path.join(tmpdir, 'scan')
with tempfile.TemporaryDirectory(prefix="wifi_scan_") as tmpdir:
output_prefix = os.path.join(tmpdir, "scan")
# Build command
cmd = ['airodump-ng', '-w', output_prefix, '--output-format', 'csv']
cmd = ["airodump-ng", "-w", output_prefix, "--output-format", "csv"]
if channels:
cmd.extend(['-c', ','.join(str(c) for c in channels)])
cmd.extend(["-c", ",".join(str(c) for c in channels)])
elif channel:
cmd.extend(['-c', str(channel)])
elif band == '2.4':
cmd.extend(['--band', 'bg'])
elif band == '5':
cmd.extend(['--band', 'a'])
cmd.extend(["-c", str(channel)])
elif band == "2.4":
cmd.extend(["--band", "bg"])
elif band == "5":
cmd.extend(["--band", "a"])
else:
cmd.extend(['--band', 'abg'])
cmd.extend(["--band", "abg"])
cmd.append(interface)
@@ -878,10 +896,12 @@ class UnifiedWiFiScanner:
except Exception as e:
logger.exception(f"Deep scan error: {e}")
self._queue_event({
'type': 'scan_error',
'error': str(e),
})
self._queue_event(
{
"type": "scan_error",
"error": str(e),
}
)
finally:
with self._lock:
if process is not None and self._deep_scan_process is process:
@@ -908,10 +928,12 @@ class UnifiedWiFiScanner:
ap.is_new = True
# Queue update event
self._queue_event({
'type': 'network_update',
'network': ap.to_summary_dict(),
})
self._queue_event(
{
"type": "network_update",
"network": ap.to_summary_dict(),
}
)
# Callback
if self._on_network_updated:
@@ -970,11 +992,13 @@ class UnifiedWiFiScanner:
# Update ESSID if revealed
if obs.essid and ap.is_hidden:
ap.revealed_essid = obs.essid
self._queue_event({
'type': 'hidden_revealed',
'bssid': ap.bssid,
'revealed_essid': obs.essid,
})
self._queue_event(
{
"type": "hidden_revealed",
"bssid": ap.bssid,
"revealed_essid": obs.essid,
}
)
# Update RSSI stats
if obs.rssi is not None:
@@ -1018,8 +1042,8 @@ class UnifiedWiFiScanner:
def _process_client(self, client_data: dict):
"""Process client data from airodump-ng."""
mac = client_data.get('mac', '').upper()
if not mac or mac == '(not associated)':
mac = client_data.get("mac", "").upper()
if not mac or mac == "(not associated)":
return
with self._lock:
@@ -1031,13 +1055,15 @@ class UnifiedWiFiScanner:
self._clients[mac] = client
# Queue update event
self._queue_event({
'type': 'client_update',
'client': client.to_dict(),
})
self._queue_event(
{
"type": "client_update",
"client": client.to_dict(),
}
)
# Process probe requests
probed = client_data.get('probed_essids', [])
probed = client_data.get("probed_essids", [])
for ssid in probed:
if ssid and ssid not in client.probed_ssids:
client.probed_ssids.append(ssid)
@@ -1052,10 +1078,12 @@ class UnifiedWiFiScanner:
)
self._probe_requests.append(probe)
self._queue_event({
'type': 'probe_request',
'probe': probe.to_dict(),
})
self._queue_event(
{
"type": "probe_request",
"probe": probe.to_dict(),
}
)
# Callback
if self._on_client_updated:
@@ -1067,7 +1095,7 @@ class UnifiedWiFiScanner:
def _create_client(self, data: dict) -> WiFiClient:
"""Create new client from data."""
now = datetime.now()
mac = data.get('mac', '').upper()
mac = data.get("mac", "").upper()
client = WiFiClient(
mac=mac,
@@ -1077,7 +1105,7 @@ class UnifiedWiFiScanner:
seen_count=1,
)
rssi = data.get('rssi')
rssi = data.get("rssi")
if rssi is not None:
client.rssi_current = rssi
client.rssi_samples = [(now, rssi)]
@@ -1088,8 +1116,8 @@ class UnifiedWiFiScanner:
client.signal_band = get_signal_band(rssi)
client.proximity_band = get_proximity_band(rssi)
bssid = data.get('bssid')
if bssid and bssid != '(not associated)':
bssid = data.get("bssid")
if bssid and bssid != "(not associated)":
client.associated_bssid = bssid.upper()
client.is_associated = True
@@ -1105,7 +1133,7 @@ class UnifiedWiFiScanner:
client.last_seen = now
client.seen_count += 1
rssi = data.get('rssi')
rssi = data.get("rssi")
if rssi is not None:
client.rssi_current = rssi
client.rssi_samples.append((now, rssi))
@@ -1166,9 +1194,9 @@ class UnifiedWiFiScanner:
if stats.ap_count > 0:
# Simple utilization score based on AP and client density
from .constants import CHANNEL_WEIGHT_AP_COUNT, CHANNEL_WEIGHT_CLIENT_COUNT
stats.utilization_score = (
(stats.ap_count * CHANNEL_WEIGHT_AP_COUNT) +
(stats.client_count * CHANNEL_WEIGHT_CLIENT_COUNT)
(stats.ap_count * CHANNEL_WEIGHT_AP_COUNT) + (stats.client_count * CHANNEL_WEIGHT_CLIENT_COUNT)
) / 10.0 # Normalize
stats.utilization_score = min(1.0, stats.utilization_score)
@@ -1192,25 +1220,29 @@ class UnifiedWiFiScanner:
for channel in NON_OVERLAPPING_2_4_GHZ:
s = stats_map.get(channel)
score = s.utilization_score if s else 0.0
recommendations.append(ChannelRecommendation(
channel=channel,
band=BAND_2_4_GHZ,
score=score,
reason=f"{s.ap_count if s else 0} APs on channel" if s else "No APs detected",
is_dfs=False,
))
recommendations.append(
ChannelRecommendation(
channel=channel,
band=BAND_2_4_GHZ,
score=score,
reason=f"{s.ap_count if s else 0} APs on channel" if s else "No APs detected",
is_dfs=False,
)
)
for channel in NON_OVERLAPPING_5_GHZ:
s = stats_map.get(channel)
score = s.utilization_score if s else 0.0
is_dfs = 52 <= channel <= 144
recommendations.append(ChannelRecommendation(
channel=channel,
band=BAND_5_GHZ,
score=score,
reason=f"{s.ap_count if s else 0} APs on channel" + (" (DFS)" if is_dfs else ""),
is_dfs=is_dfs,
))
recommendations.append(
ChannelRecommendation(
channel=channel,
band=BAND_5_GHZ,
score=score,
reason=f"{s.ap_count if s else 0} APs on channel" + (" (DFS)" if is_dfs else ""),
is_dfs=is_dfs,
)
)
# Sort by score (lower is better)
recommendations.sort(key=lambda r: (r.score, r.is_dfs))
@@ -1244,7 +1276,7 @@ class UnifiedWiFiScanner:
event = self._event_queue.get(timeout=1.0)
yield event
except queue.Empty:
yield {'type': 'keepalive'}
yield {"type": "keepalive"}
except Exception:
break
@@ -1334,10 +1366,11 @@ class UnifiedWiFiScanner:
# Also store in app-level DataStore if available
try:
import app as app_module
if hasattr(app_module, 'deauth_alerts') and event.get('type') == 'deauth_alert':
alert_id = event.get('id', str(time.time()))
if hasattr(app_module, "deauth_alerts") and event.get("type") == "deauth_alert":
alert_id = event.get("id", str(time.time()))
app_module.deauth_alerts[alert_id] = event
if hasattr(app_module, 'deauth_detector_queue'):
if hasattr(app_module, "deauth_detector_queue"):
with contextlib.suppress(queue.Full):
app_module.deauth_detector_queue.put_nowait(event)
except Exception as e:
@@ -1363,16 +1396,20 @@ class UnifiedWiFiScanner:
self._deauth_detector.start()
logger.info(f"Deauth detector started on {interface}")
self._queue_event({
'type': 'deauth_detector_started',
'interface': interface,
})
self._queue_event(
{
"type": "deauth_detector_started",
"interface": interface,
}
)
except Exception as e:
logger.error(f"Failed to start deauth detector: {e}")
self._queue_event({
'type': 'deauth_error',
'error': f"Failed to start deauth detector: {e}",
})
self._queue_event(
{
"type": "deauth_error",
"error": f"Failed to start deauth detector: {e}",
}
)
def _stop_deauth_detector(self):
"""Stop the deauth detector."""
@@ -1380,9 +1417,11 @@ class UnifiedWiFiScanner:
try:
self._deauth_detector.stop()
logger.info("Deauth detector stopped")
self._queue_event({
'type': 'deauth_detector_stopped',
})
self._queue_event(
{
"type": "deauth_detector_stopped",
}
)
except Exception as e:
logger.error(f"Error stopping deauth detector: {e}")
finally:
@@ -1406,7 +1445,8 @@ class UnifiedWiFiScanner:
# Also clear from app-level store
try:
import app as app_module
if hasattr(app_module, 'deauth_alerts'):
if hasattr(app_module, "deauth_alerts"):
app_module.deauth_alerts.clear()
except Exception:
pass
@@ -1416,6 +1456,7 @@ class UnifiedWiFiScanner:
# Module-level functions
# =============================================================================
def get_wifi_scanner(interface: str | None = None) -> UnifiedWiFiScanner:
"""
Get or create the global WiFi scanner instance.