Files
intercept/utils/wifi/parsers/airport.py
Smittix e00fbfddc1 v2.26.0: fix SSE fanout crash and branded logo FOUC
- Fix SSE fanout thread AttributeError when source queue is None during
  interpreter shutdown by snapshotting to local variable with null guard
- Fix branded "i" logo rendering oversized on first page load (FOUC) by
  adding inline width/height to SVG elements across 10 templates
- Bump version to 2.26.0 in config.py, pyproject.toml, and CHANGELOG.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:51:27 +00:00

204 lines
5.9 KiB
Python

"""
Parser for macOS airport utility output.
Example output from 'airport -s':
SSID BSSID RSSI CHANNEL HT CC SECURITY
MyWiFi 00:11:22:33:44:55 -65 6 Y US WPA2(PSK/AES/AES)
Hidden -- 00:11:22:33:44:66 -70 11 Y US WPA2(PSK/AES/AES)
"""
from __future__ import annotations
import logging
import re
from datetime import datetime
from ..constants import (
AUTH_EAP,
AUTH_OPEN,
AUTH_PSK,
AUTH_SAE,
AUTH_UNKNOWN,
CHANNEL_FREQUENCIES,
CIPHER_CCMP,
CIPHER_NONE,
CIPHER_TKIP,
CIPHER_UNKNOWN,
CIPHER_WEP,
SECURITY_OPEN,
SECURITY_UNKNOWN,
SECURITY_WEP,
SECURITY_WPA,
SECURITY_WPA2,
SECURITY_WPA2_WPA3,
SECURITY_WPA3,
SECURITY_WPA_WPA2,
WIDTH_20_MHZ,
WIDTH_40_MHZ,
)
from ..models import WiFiObservation
logger = logging.getLogger(__name__)
def parse_airport_scan(output: str) -> list[WiFiObservation]:
"""
Parse macOS airport scan output.
Args:
output: Raw output from 'airport -s' command.
Returns:
List of WiFiObservation objects.
"""
observations = []
lines = output.strip().split('\n')
if len(lines) < 2:
return observations
# Skip header line
for line in lines[1:]:
obs = _parse_airport_line(line)
if obs:
observations.append(obs)
return observations
def _parse_airport_line(line: str) -> WiFiObservation | None:
"""Parse a single line of airport output."""
# airport output is space-aligned, need careful parsing
# Format: SSID (variable width) BSSID RSSI CHANNEL HT CC SECURITY
#
# The tricky part is SSID can contain spaces and the columns are
# aligned by whitespace. We parse from the right side.
line = line.rstrip()
if not line:
return None
try:
# 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)
if not bssid_match:
return None
bssid = bssid_match.group(0).upper()
bssid_pos = bssid_match.start()
# SSID is everything before BSSID (stripped)
ssid = line[:bssid_pos].strip()
# Handle hidden network indicator
if ssid == '--' or not ssid:
ssid = None
# Parse remainder after BSSID
remainder = line[bssid_match.end():].strip()
parts = remainder.split()
if len(parts) < 4:
# Minimal: RSSI CHANNEL HT SECURITY
return None
# Parse RSSI (negative number)
rssi_str = parts[0]
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 = 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:
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, cipher, auth = _parse_airport_security(security_str)
# Get frequency
frequency_mhz = CHANNEL_FREQUENCIES.get(channel) if channel else None
return WiFiObservation(
timestamp=datetime.now(),
bssid=bssid,
essid=ssid,
channel=channel,
frequency_mhz=frequency_mhz,
rssi=rssi,
security=security,
cipher=cipher,
auth=auth,
width=width,
)
except Exception as e:
logger.debug(f"Failed to parse airport line: {line!r} - {e}")
return None
def _parse_airport_security(security_str: str) -> tuple[str, str, str]:
"""
Parse airport security string.
Examples:
'WPA2(PSK/AES/AES)' -> (WPA2, CCMP, PSK)
'WPA(PSK/TKIP/TKIP)' -> (WPA, TKIP, PSK)
'WPA2(PSK,FT-PSK/AES/AES)' -> (WPA2, CCMP, PSK)
'RSN(PSK/AES,TKIP/TKIP)' -> (WPA2, CCMP, PSK)
'WEP' -> (WEP, WEP, OPEN)
'NONE' or '' -> (Open, None, Open)
"""
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:
security = SECURITY_WPA3
elif 'RSN' in security_upper or 'WPA2' in security_upper:
security = SECURITY_WPA2
elif 'WPA' in security_upper:
security = SECURITY_WPA
elif 'WEP' in security_upper:
security = SECURITY_WEP
# Handle mixed mode
if 'WPA2' in security_upper and 'WPA3' in security_upper:
security = SECURITY_WPA2_WPA3
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:
cipher = CIPHER_CCMP
elif 'TKIP' in security_upper:
cipher = CIPHER_TKIP
elif 'WEP' in security_upper:
cipher = CIPHER_WEP
# Determine auth
auth = AUTH_UNKNOWN
if 'SAE' in security_upper:
auth = AUTH_SAE
elif 'PSK' in security_upper:
auth = AUTH_PSK
elif 'EAP' in security_upper or '802.1X' in security_upper:
auth = AUTH_EAP
elif security == SECURITY_OPEN:
auth = AUTH_OPEN
return security, cipher, auth