Files
intercept/utils/wifi/models.py
T
James Smith 96172ca593 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>
2026-07-05 14:48:11 +01:00

613 lines
20 KiB
Python

"""
WiFi data models for the unified scanner.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from .constants import (
AUTH_UNKNOWN,
BAND_UNKNOWN,
CIPHER_UNKNOWN,
PROXIMITY_UNKNOWN,
SCAN_MODE_QUICK,
SECURITY_UNKNOWN,
SIGNAL_UNKNOWN,
WIDTH_UNKNOWN,
get_band_from_channel,
get_vendor_from_mac,
)
@dataclass
class WiFiObservation:
"""Represents a single WiFi access point scan result."""
timestamp: datetime
bssid: str
essid: str | None = None
channel: int | None = None
frequency_mhz: int | None = None
rssi: int | None = None
# Security
security: str = SECURITY_UNKNOWN
cipher: str = CIPHER_UNKNOWN
auth: str = AUTH_UNKNOWN
# Additional info
width: str = WIDTH_UNKNOWN
beacon_count: int = 0
data_count: int = 0
@property
def is_hidden(self) -> bool:
"""Check if this is a hidden network."""
return not self.essid or self.essid.strip() == ""
@property
def band(self) -> str:
"""Get WiFi band from channel."""
if self.channel:
return get_band_from_channel(self.channel)
return BAND_UNKNOWN
@property
def vendor(self) -> str | None:
"""Get vendor name from BSSID."""
return get_vendor_from_mac(self.bssid)
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,
}
@dataclass
class WiFiAccessPoint:
"""Aggregated WiFi access point data over time."""
# Identity
bssid: str
essid: str | None = None
is_hidden: bool = False
revealed_essid: str | None = None # Revealed through correlation
# Radio info
channel: int | None = None
frequency_mhz: int | None = None
band: str = BAND_UNKNOWN
width: str = WIDTH_UNKNOWN
# Signal aggregation
rssi_samples: list[tuple[datetime, int]] = field(default_factory=list)
rssi_current: int | None = None
rssi_median: float | None = None
rssi_min: int | None = None
rssi_max: int | None = None
rssi_variance: float | None = None
rssi_ema: float | None = None
# Proximity/signal bands
signal_band: str = SIGNAL_UNKNOWN
proximity_band: str = PROXIMITY_UNKNOWN
estimated_distance_m: float | None = None
distance_confidence: float = 0.0
# Security
security: str = SECURITY_UNKNOWN
cipher: str = CIPHER_UNKNOWN
auth: str = AUTH_UNKNOWN
# Timestamps
first_seen: datetime = field(default_factory=datetime.now)
last_seen: datetime = field(default_factory=datetime.now)
seen_count: int = 0
seen_rate: float = 0.0 # Observations per minute
# Traffic stats
beacon_count: int = 0
data_count: int = 0
client_count: int = 0
# Metadata
vendor: str | None = None
# Heuristic flags
heuristic_flags: list[str] = field(default_factory=list)
is_new: bool = False
is_persistent: bool = False
is_strong_stable: bool = False
# Baseline tracking
in_baseline: bool = False
baseline_id: int | None = None
@property
def display_name(self) -> str:
"""Get display name (revealed SSID, ESSID, or BSSID)."""
if self.revealed_essid:
return f"{self.revealed_essid} (revealed)"
if self.essid and not self.is_hidden:
return self.essid
return f"[Hidden] {self.bssid}"
@property
def age_seconds(self) -> float:
"""Seconds since last seen."""
return (datetime.now() - self.last_seen).total_seconds()
@property
def duration_seconds(self) -> float:
"""Total duration from first to last seen."""
return (self.last_seen - self.first_seen).total_seconds()
def get_rssi_history(self, max_points: int = 50) -> list[dict]:
"""Get RSSI history for visualization."""
if not self.rssi_samples:
return []
samples = self.rssi_samples[-max_points:]
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,
# Radio
"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(),
# 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),
# Security
"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),
# Traffic
"beacon_count": self.beacon_count,
"data_count": self.data_count,
"client_count": self.client_count,
# Metadata
"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,
},
# Baseline
"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,
}
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
}
@dataclass
class WiFiClient:
"""WiFi client (station) observed during scanning."""
# Identity
mac: str
vendor: str | None = None
# Signal
rssi_samples: list[tuple[datetime, int]] = field(default_factory=list)
rssi_current: int | None = None
rssi_median: float | None = None
rssi_min: int | None = None
rssi_max: int | None = None
rssi_ema: float | None = None
# Proximity
signal_band: str = SIGNAL_UNKNOWN
proximity_band: str = PROXIMITY_UNKNOWN
estimated_distance_m: float | None = None
# Association
associated_bssid: str | None = None
is_associated: bool = False
# Probes
probed_ssids: list[str] = field(default_factory=list)
probe_timestamps: dict[str, datetime] = field(default_factory=dict)
# Timestamps
first_seen: datetime = field(default_factory=datetime.now)
last_seen: datetime = field(default_factory=datetime.now)
seen_count: int = 0
# Traffic stats
packets_sent: int = 0
packets_received: int = 0
# Heuristics
heuristic_flags: list[str] = field(default_factory=list)
@property
def age_seconds(self) -> float:
"""Seconds since last seen."""
return (datetime.now() - self.last_seen).total_seconds()
def get_rssi_history(self, max_points: int = 50) -> list[dict]:
"""Get RSSI history for visualization."""
if not self.rssi_samples:
return []
samples = self.rssi_samples[-max_points:]
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,
# 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(),
# 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,
# Association
"associated_bssid": self.associated_bssid,
"is_associated": self.is_associated,
# Probes
"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,
# Traffic
"packets_sent": self.packets_sent,
"packets_received": self.packets_received,
# Heuristics
"heuristic_flags": self.heuristic_flags,
}
@dataclass
class WiFiProbeRequest:
"""A single probe request captured during scanning."""
timestamp: datetime
client_mac: str
probed_ssid: str
rssi: int | None = None
client_vendor: str | None = None
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,
}
@dataclass
class ChannelStats:
"""Statistics for a single WiFi channel."""
channel: int
band: str = BAND_UNKNOWN
frequency_mhz: int | None = None
# Counts
ap_count: int = 0
client_count: int = 0
# Signal stats
rssi_avg: float | None = None
rssi_min: int | None = None
rssi_max: int | None = None
# Utilization score (0.0-1.0, lower is better)
utilization_score: float = 0.0
# Recommendation rank (1 = best)
recommendation_rank: int | None = None
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,
}
@dataclass
class ChannelRecommendation:
"""Channel recommendation with reasoning."""
channel: int
band: str
score: float # Lower is better
reason: str
is_dfs: bool = False
recommendation_rank: int | None = None
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,
}
@dataclass
class WiFiScanResult:
"""Complete result from a WiFi scan operation."""
# Discovered entities
access_points: list[WiFiAccessPoint] = field(default_factory=list)
clients: list[WiFiClient] = field(default_factory=list)
probe_requests: list[WiFiProbeRequest] = field(default_factory=list)
# Channel analysis
channel_stats: list[ChannelStats] = field(default_factory=list)
recommendations: list[ChannelRecommendation] = field(default_factory=list)
# Scan metadata
scan_mode: str = SCAN_MODE_QUICK
interface: str | None = None
started_at: datetime | None = None
completed_at: datetime | None = None
duration_seconds: float | None = None
# Status
is_complete: bool = False
error: str | None = None
warnings: list[str] = field(default_factory=list)
@property
def network_count(self) -> int:
"""Total number of access points found."""
return len(self.access_points)
@property
def client_count(self) -> int:
"""Total number of clients found."""
return len(self.clients)
@property
def hidden_count(self) -> int:
"""Number of hidden networks."""
return sum(1 for ap in self.access_points if ap.is_hidden)
def to_dict(self) -> dict:
"""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],
# Channel analysis
"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,
# Stats
"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,
}
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,
}
@dataclass
class WiFiScanStatus:
"""Current WiFi scanning status."""
is_scanning: bool = False
scan_mode: str = SCAN_MODE_QUICK
interface: str | None = None
started_at: datetime | None = None
networks_found: int = 0
clients_found: int = 0
error: str | None = None
@property
def elapsed_seconds(self) -> float | None:
"""Seconds since scan started."""
if self.started_at:
return (datetime.now() - self.started_at).total_seconds()
return None
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,
}
@dataclass
class WiFiCapabilities:
"""WiFi system capabilities check result."""
# Platform
platform: str = "unknown" # 'linux', 'darwin', 'windows'
is_root: bool = False
# Interfaces
interfaces: list[dict] = field(default_factory=list)
default_interface: str | None = None
# Quick scan tools
has_nmcli: bool = False
has_iw: bool = False
has_iwlist: bool = False
has_airport: bool = False
preferred_quick_tool: str | None = None
# Deep scan tools
has_airmon_ng: bool = False
has_airodump_ng: bool = False
has_monitor_capable_interface: bool = False
monitor_interface: str | None = None
# Issues
issues: list[str] = field(default_factory=list)
@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
@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
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,
# Platform
"platform": self.platform,
"is_root": self.is_root,
# Interfaces
"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,
},
"preferred_quick_tool": self.preferred_quick_tool,
# Deep scan
"has_monitor_capable_interface": self.has_monitor_capable_interface,
"monitor_interface": self.monitor_interface,
# Issues
"issues": self.issues,
}