mirror of
https://github.com/smittix/intercept.git
synced 2026-07-07 09:08:12 -07:00
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:
+63
-75
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user