mirror of
https://github.com/smittix/intercept.git
synced 2026-07-06 08:38:14 -07:00
96172ca593
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>
613 lines
20 KiB
Python
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,
|
|
}
|