""" TSCM Advanced Features Module Implements: 1. Capability & Coverage Reality Panel 2. Baseline Diff & Baseline Health 3. Per-Device Timelines 4. Meeting-Window Summary Enhancements 5. WiFi Advanced Indicators (Evil Twin, Probes, Deauth) 6. Bluetooth Risk Explainability & Proximity Heuristics 7. Operator Playbooks DISCLAIMER: This system performs wireless and RF surveillance screening. Findings indicate anomalies and indicators, not confirmed surveillance devices. All claims are probabilistic pattern matches requiring professional verification. """ from __future__ import annotations import logging import os import platform import subprocess from dataclasses import dataclass, field from datetime import datetime, timedelta from enum import Enum from typing import Any logger = logging.getLogger("intercept.tscm.advanced") # ============================================================================= # 1. Capability & Coverage Reality Panel # ============================================================================= class WifiMode(Enum): """WiFi adapter operating modes.""" MONITOR = "monitor" MANAGED = "managed" UNAVAILABLE = "unavailable" class BluetoothMode(Enum): """Bluetooth adapter capabilities.""" BLE_CLASSIC = "ble_classic" BLE_ONLY = "ble_only" LIMITED = "limited" UNAVAILABLE = "unavailable" @dataclass class RFCapability: """RF/SDR device capabilities.""" device_type: str = "none" driver: str = "" min_frequency_mhz: float = 0.0 max_frequency_mhz: float = 0.0 sample_rate_max: int = 0 available: bool = False limitations: list[str] = field(default_factory=list) @dataclass class SweepCapabilities: """ Complete capabilities snapshot for a TSCM sweep. Exposes what the current sweep CAN and CANNOT detect based on OS, privileges, adapters, and SDR hardware limits. """ # System info os_name: str = "" os_version: str = "" is_root: bool = False # WiFi capabilities wifi_mode: WifiMode = WifiMode.UNAVAILABLE wifi_interface: str = "" wifi_driver: str = "" wifi_monitor_capable: bool = False wifi_limitations: list[str] = field(default_factory=list) # Bluetooth capabilities bt_mode: BluetoothMode = BluetoothMode.UNAVAILABLE bt_adapter: str = "" bt_version: str = "" bt_limitations: list[str] = field(default_factory=list) # RF/SDR capabilities rf_capability: RFCapability = field(default_factory=RFCapability) # Overall limitations all_limitations: list[str] = field(default_factory=list) # Timestamp captured_at: datetime = field(default_factory=datetime.now) def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { "system": { "os": self.os_name, "os_version": self.os_version, "is_root": self.is_root, }, "wifi": { "mode": self.wifi_mode.value, "interface": self.wifi_interface, "driver": self.wifi_driver, "monitor_capable": self.wifi_monitor_capable, "limitations": self.wifi_limitations, }, "bluetooth": { "mode": self.bt_mode.value, "adapter": self.bt_adapter, "version": self.bt_version, "limitations": self.bt_limitations, }, "rf": { "device_type": self.rf_capability.device_type, "driver": self.rf_capability.driver, "frequency_range_mhz": { "min": self.rf_capability.min_frequency_mhz, "max": self.rf_capability.max_frequency_mhz, }, "sample_rate_max": self.rf_capability.sample_rate_max, "available": self.rf_capability.available, "limitations": self.rf_capability.limitations, }, "all_limitations": self.all_limitations, "captured_at": self.captured_at.isoformat(), "disclaimer": ( "Capabilities are detected at sweep start time and may change. " "Limitations listed affect what this sweep can reliably detect." ), } def detect_sweep_capabilities( wifi_interface: str = "", bt_adapter: str = "", sdr_device: Any = None ) -> SweepCapabilities: """ Detect current system capabilities for TSCM sweeping. Args: wifi_interface: Specific WiFi interface to check bt_adapter: Specific BT adapter to check sdr_device: SDR device object if available Returns: SweepCapabilities object with complete capability assessment """ caps = SweepCapabilities() # System info caps.os_name = platform.system() caps.os_version = platform.release() caps.is_root = os.geteuid() == 0 if hasattr(os, "geteuid") else False # Detect WiFi capabilities _detect_wifi_capabilities(caps, wifi_interface) # Detect Bluetooth capabilities _detect_bluetooth_capabilities(caps, bt_adapter) # Detect RF/SDR capabilities _detect_rf_capabilities(caps, sdr_device) # Compile all limitations caps.all_limitations = caps.wifi_limitations + caps.bt_limitations + caps.rf_capability.limitations # Add privilege-based limitations if not caps.is_root: caps.all_limitations.append("Running without root privileges - some features may be limited") return caps def _detect_wifi_capabilities(caps: SweepCapabilities, interface: str) -> None: """Detect WiFi adapter capabilities.""" caps.wifi_interface = interface if platform.system() == "Darwin": # macOS: Check for WiFi capability using multiple methods wifi_available = False # Method 1: Check airport utility (older macOS) airport_path = "/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport" if os.path.exists(airport_path): wifi_available = True # Method 2: Check for WiFi interface using networksetup (works on all macOS) if not wifi_available: try: result = subprocess.run( ["networksetup", "-listallhardwareports"], capture_output=True, text=True, timeout=5 ) if "Wi-Fi" in result.stdout or "AirPort" in result.stdout: wifi_available = True except Exception: pass # Method 3: Check if en0 exists (common WiFi interface on macOS) if not wifi_available: try: result = subprocess.run(["ifconfig", "en0"], capture_output=True, text=True, timeout=5) if result.returncode == 0: wifi_available = True except Exception: pass if wifi_available: caps.wifi_mode = WifiMode.MANAGED caps.wifi_driver = "apple80211" caps.wifi_monitor_capable = False caps.wifi_limitations = [ "macOS WiFi operates in managed mode only.", "Cannot capture probe requests or deauthentication frames.", "Evil twin detection limited to SSID/BSSID comparison only.", ] else: caps.wifi_mode = WifiMode.UNAVAILABLE caps.wifi_limitations = ["WiFi scanning unavailable - no interface found"] else: # Linux: Check for monitor mode capability try: # Check if interface supports monitor mode result = subprocess.run(["iw", "list"], capture_output=True, text=True, timeout=5) if "monitor" in result.stdout.lower(): # Check current mode if interface: mode_result = subprocess.run( ["iw", "dev", interface, "info"], capture_output=True, text=True, timeout=5 ) if "type monitor" in mode_result.stdout.lower(): caps.wifi_mode = WifiMode.MONITOR caps.wifi_monitor_capable = True else: caps.wifi_mode = WifiMode.MANAGED caps.wifi_monitor_capable = True caps.wifi_limitations.append( "WiFi interface in managed mode. Probe requests and deauth detection require monitor mode." ) else: caps.wifi_mode = WifiMode.MANAGED caps.wifi_monitor_capable = True else: caps.wifi_mode = WifiMode.MANAGED caps.wifi_monitor_capable = False caps.wifi_limitations = [ "Passive WiFi frame analysis is not available in this sweep.", "WiFi adapter does not support monitor mode.", "Probe request and deauthentication detection unavailable.", ] # Get driver info if interface: try: driver_path = f"/sys/class/net/{interface}/device/driver" if os.path.exists(driver_path): caps.wifi_driver = os.path.basename(os.readlink(driver_path)) except Exception: pass except (subprocess.TimeoutExpired, FileNotFoundError): caps.wifi_mode = WifiMode.UNAVAILABLE caps.wifi_limitations = ["WiFi scanning tools not available"] def _detect_bluetooth_capabilities(caps: SweepCapabilities, adapter: str) -> None: """Detect Bluetooth adapter capabilities.""" caps.bt_adapter = adapter if platform.system() == "Darwin": # macOS: Use system_profiler try: result = subprocess.run( ["system_profiler", "SPBluetoothDataType", "-json"], capture_output=True, text=True, timeout=10 ) if "Bluetooth" in result.stdout: caps.bt_mode = BluetoothMode.BLE_CLASSIC caps.bt_version = "macOS CoreBluetooth" caps.bt_limitations = [ "BLE scanning limited to advertising devices only.", "Classic Bluetooth discovery may be incomplete.", "Manufacturer data parsing depends on device advertising.", ] else: caps.bt_mode = BluetoothMode.UNAVAILABLE caps.bt_limitations = ["Bluetooth not available"] except (subprocess.TimeoutExpired, FileNotFoundError): caps.bt_mode = BluetoothMode.UNAVAILABLE caps.bt_limitations = ["Bluetooth detection failed"] else: # Linux: Check bluetoothctl/hciconfig try: result = subprocess.run(["hciconfig", "-a"], capture_output=True, text=True, timeout=5) if "hci" in result.stdout.lower(): # Check for BLE support if "le" in result.stdout.lower(): caps.bt_mode = BluetoothMode.BLE_CLASSIC caps.bt_limitations = [ "BLE scanning range depends on adapter sensitivity.", "Some devices may not be detected if not advertising.", ] else: caps.bt_mode = BluetoothMode.LIMITED caps.bt_limitations = [ "Adapter may not support BLE scanning.", "Limited to classic Bluetooth discovery.", ] # Extract version for line in result.stdout.split("\n"): if "hci version" in line.lower(): caps.bt_version = line.strip() break else: caps.bt_mode = BluetoothMode.UNAVAILABLE caps.bt_limitations = ["No Bluetooth adapter found"] except (subprocess.TimeoutExpired, FileNotFoundError): caps.bt_mode = BluetoothMode.UNAVAILABLE caps.bt_limitations = ["Bluetooth tools not available"] def _detect_rf_capabilities(caps: SweepCapabilities, sdr_device: Any) -> None: """Detect RF/SDR device capabilities.""" rf_cap = RFCapability() try: from utils.sdr import SDRFactory devices = SDRFactory.detect_devices() if devices: device = devices[0] # Use first device rf_cap.available = True rf_cap.device_type = getattr(device, "sdr_type", "unknown") if hasattr(rf_cap.device_type, "value"): rf_cap.device_type = rf_cap.device_type.value rf_cap.driver = getattr(device, "driver", "") # Set frequency ranges based on device type if "rtl" in rf_cap.device_type.lower(): rf_cap.min_frequency_mhz = 24.0 rf_cap.max_frequency_mhz = 1766.0 rf_cap.sample_rate_max = 3200000 rf_cap.limitations = [ "RTL-SDR frequency range: 24-1766 MHz typical.", "Cannot reliably cover frequencies below 24 MHz.", "Cannot cover microwave bands (>1.8 GHz) without upconverter.", "Signal detection limited by SDR noise floor and dynamic range.", ] elif "hackrf" in rf_cap.device_type.lower(): rf_cap.min_frequency_mhz = 1.0 rf_cap.max_frequency_mhz = 6000.0 rf_cap.sample_rate_max = 20000000 rf_cap.limitations = [ "HackRF frequency range: 1 MHz - 6 GHz.", "8-bit ADC limits dynamic range for weak signal detection.", ] else: rf_cap.limitations = [ f"Unknown SDR type: {rf_cap.device_type}", "Frequency coverage and capabilities uncertain.", ] else: rf_cap.available = False rf_cap.device_type = "none" rf_cap.limitations = [ "No SDR device detected.", "RF spectrum analysis is not available in this sweep.", "Cannot scan for wireless microphones, bugs, or RF transmitters.", ] except ImportError: rf_cap.available = False rf_cap.limitations = [ "SDR support not installed.", "RF spectrum analysis unavailable.", ] except Exception as e: rf_cap.available = False rf_cap.limitations = [f"SDR detection failed: {str(e)}"] caps.rf_capability = rf_cap # ============================================================================= # 2. Baseline Diff & Baseline Health # ============================================================================= class BaselineHealth(Enum): """Baseline health status.""" HEALTHY = "healthy" NOISY = "noisy" STALE = "stale" @dataclass class DeviceChange: """Represents a change detected compared to baseline.""" identifier: str protocol: str change_type: str # 'new', 'missing', 'rssi_drift', 'channel_change', 'security_change' description: str expected: bool = False # True if this is an expected/normal change details: dict = field(default_factory=dict) @dataclass class BaselineDiff: """ Complete diff between a baseline and a sweep. Shows what changed, whether baseline is reliable, and separates expected vs unexpected changes. """ baseline_id: int sweep_id: int # Health assessment health: BaselineHealth = BaselineHealth.HEALTHY health_score: float = 1.0 # 0-1, higher is healthier health_reasons: list[str] = field(default_factory=list) # Age metrics baseline_age_hours: float = 0.0 is_stale: bool = False # Device changes new_devices: list[DeviceChange] = field(default_factory=list) missing_devices: list[DeviceChange] = field(default_factory=list) changed_devices: list[DeviceChange] = field(default_factory=list) # Summary counts total_new: int = 0 total_missing: int = 0 total_changed: int = 0 # Expected vs unexpected expected_changes: list[DeviceChange] = field(default_factory=list) unexpected_changes: list[DeviceChange] = field(default_factory=list) def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { "baseline_id": self.baseline_id, "sweep_id": self.sweep_id, "health": { "status": self.health.value, "score": round(self.health_score, 2), "reasons": self.health_reasons, }, "age": { "hours": round(self.baseline_age_hours, 1), "is_stale": self.is_stale, }, "summary": { "new_devices": self.total_new, "missing_devices": self.total_missing, "changed_devices": self.total_changed, "expected_changes": len(self.expected_changes), "unexpected_changes": len(self.unexpected_changes), }, "new_devices": [ {"identifier": d.identifier, "protocol": d.protocol, "description": d.description, "details": d.details} for d in self.new_devices ], "missing_devices": [ {"identifier": d.identifier, "protocol": d.protocol, "description": d.description, "details": d.details} for d in self.missing_devices ], "changed_devices": [ { "identifier": d.identifier, "protocol": d.protocol, "change_type": d.change_type, "description": d.description, "expected": d.expected, "details": d.details, } for d in self.changed_devices ], "disclaimer": ( "Baseline comparison shows differences, not confirmed threats. " "New devices may be legitimate. Missing devices may have been powered off." ), } def calculate_baseline_diff( baseline: dict, current_wifi: list[dict], current_wifi_clients: list[dict], current_bt: list[dict], current_rf: list[dict], sweep_id: int, ) -> BaselineDiff: """ Calculate comprehensive diff between baseline and current scan. Args: baseline: Baseline dict from database current_wifi: Current WiFi devices current_wifi_clients: Current WiFi clients current_bt: Current Bluetooth devices current_rf: Current RF signals sweep_id: Current sweep ID Returns: BaselineDiff with complete comparison results """ diff = BaselineDiff(baseline_id=baseline.get("id", 0), sweep_id=sweep_id) # Calculate baseline age created_at = baseline.get("created_at") if created_at: if isinstance(created_at, str): try: created = datetime.fromisoformat(created_at.replace("Z", "+00:00")) diff.baseline_age_hours = (datetime.now() - created.replace(tzinfo=None)).total_seconds() / 3600 except ValueError: diff.baseline_age_hours = 0 elif isinstance(created_at, datetime): diff.baseline_age_hours = (datetime.now() - created_at).total_seconds() / 3600 # Check if baseline is stale (>72 hours old) diff.is_stale = diff.baseline_age_hours > 72 # Build baseline lookup dicts baseline_wifi = { d.get("bssid", d.get("mac", "")).upper(): d for d in baseline.get("wifi_networks", []) if d.get("bssid") or d.get("mac") } baseline_wifi_clients = { d.get("mac", d.get("address", "")).upper(): d for d in baseline.get("wifi_clients", []) if d.get("mac") or d.get("address") } baseline_bt = { d.get("mac", d.get("address", "")).upper(): d for d in baseline.get("bt_devices", []) if d.get("mac") or d.get("address") } baseline_rf = {round(d.get("frequency", 0), 1): d for d in baseline.get("rf_frequencies", []) if d.get("frequency")} # Compare WiFi _compare_wifi(diff, baseline_wifi, current_wifi) # Compare WiFi clients _compare_wifi_clients(diff, baseline_wifi_clients, current_wifi_clients) # Compare Bluetooth _compare_bluetooth(diff, baseline_bt, current_bt) # Compare RF _compare_rf(diff, baseline_rf, current_rf) # Calculate totals diff.total_new = len(diff.new_devices) diff.total_missing = len(diff.missing_devices) diff.total_changed = len(diff.changed_devices) # Separate expected vs unexpected changes for change in diff.new_devices + diff.missing_devices + diff.changed_devices: if change.expected: diff.expected_changes.append(change) else: diff.unexpected_changes.append(change) # Calculate health _calculate_baseline_health(diff, baseline) return diff def _compare_wifi(diff: BaselineDiff, baseline: dict, current: list[dict]) -> None: """Compare WiFi devices between baseline and current.""" current_macs = {d.get("bssid", d.get("mac", "")).upper(): d for d in current if d.get("bssid") or d.get("mac")} # Find new devices for mac, device in current_macs.items(): if mac not in baseline: ssid = device.get("essid", device.get("ssid", "Hidden")) diff.new_devices.append( DeviceChange( identifier=mac, protocol="wifi", change_type="new", description=f"New WiFi AP: {ssid}", expected=False, details={ "ssid": ssid, "channel": device.get("channel"), "rssi": device.get("power", device.get("signal")), }, ) ) def _compare_wifi_clients(diff: BaselineDiff, baseline: dict, current: list[dict]) -> None: """Compare WiFi clients between baseline and current.""" current_macs = {d.get("mac", d.get("address", "")).upper(): d for d in current if d.get("mac") or d.get("address")} # Find new clients for mac, device in current_macs.items(): if mac not in baseline: name = device.get("vendor", "WiFi Client") diff.new_devices.append( DeviceChange( identifier=mac, protocol="wifi_client", change_type="new", description=f"New WiFi client: {name}", expected=False, details={ "vendor": name, "rssi": device.get("rssi"), "associated_bssid": device.get("associated_bssid"), }, ) ) # Find missing clients for mac, device in baseline.items(): if mac not in current_macs: name = device.get("vendor", "WiFi Client") diff.missing_devices.append( DeviceChange( identifier=mac, protocol="wifi_client", change_type="missing", description=f"Missing WiFi client: {name}", expected=True, details={ "vendor": name, }, ) ) else: # Check for changes baseline_dev = baseline[mac] changes = [] # RSSI drift curr_rssi = device.get("power", device.get("signal")) base_rssi = baseline_dev.get("power", baseline_dev.get("signal")) if curr_rssi and base_rssi: rssi_diff = abs(int(curr_rssi) - int(base_rssi)) if rssi_diff > 15: changes.append(("rssi_drift", f"RSSI changed by {rssi_diff} dBm")) # Channel change curr_chan = device.get("channel") base_chan = baseline_dev.get("channel") if curr_chan and base_chan and curr_chan != base_chan: changes.append(("channel_change", f"Channel changed from {base_chan} to {curr_chan}")) # Security change curr_sec = device.get("encryption", device.get("privacy", "")) base_sec = baseline_dev.get("encryption", baseline_dev.get("privacy", "")) if curr_sec and base_sec and curr_sec != base_sec: changes.append(("security_change", f"Security changed from {base_sec} to {curr_sec}")) for change_type, desc in changes: diff.changed_devices.append( DeviceChange( identifier=mac, protocol="wifi", change_type=change_type, description=desc, expected=change_type == "rssi_drift", # RSSI drift is often expected details={ "ssid": device.get("essid", device.get("ssid")), "baseline": baseline_dev, "current": device, }, ) ) # Find missing devices for mac, device in baseline.items(): if mac not in current_macs: ssid = device.get("essid", device.get("ssid", "Hidden")) diff.missing_devices.append( DeviceChange( identifier=mac, protocol="wifi", change_type="missing", description=f"Missing WiFi AP: {ssid}", expected=False, # Could be powered off details={ "ssid": ssid, "last_channel": device.get("channel"), }, ) ) def _compare_bluetooth(diff: BaselineDiff, baseline: dict, current: list[dict]) -> None: """Compare Bluetooth devices between baseline and current.""" current_macs = {d.get("mac", d.get("address", "")).upper(): d for d in current if d.get("mac") or d.get("address")} # Find new devices for mac, device in current_macs.items(): if mac not in baseline: name = device.get("name", "Unknown") diff.new_devices.append( DeviceChange( identifier=mac, protocol="bluetooth", change_type="new", description=f"New BLE device: {name}", expected=False, details={ "name": name, "rssi": device.get("rssi"), "manufacturer": device.get("manufacturer"), }, ) ) else: # Check for changes baseline_dev = baseline[mac] # Name change (device renamed) curr_name = device.get("name", "") base_name = baseline_dev.get("name", "") if curr_name and base_name and curr_name != base_name: diff.changed_devices.append( DeviceChange( identifier=mac, protocol="bluetooth", change_type="name_change", description=f"Device renamed: {base_name} -> {curr_name}", expected=True, details={"old_name": base_name, "new_name": curr_name}, ) ) # Find missing devices for mac, device in baseline.items(): if mac not in current_macs: name = device.get("name", "Unknown") diff.missing_devices.append( DeviceChange( identifier=mac, protocol="bluetooth", change_type="missing", description=f"Missing BLE device: {name}", expected=True, # BLE devices often go to sleep details={"name": name}, ) ) def _compare_rf(diff: BaselineDiff, baseline: dict, current: list[dict]) -> None: """Compare RF signals between baseline and current.""" current_freqs = {round(s.get("frequency", 0), 1): s for s in current if s.get("frequency")} # Find new signals for freq, signal in current_freqs.items(): if freq not in baseline: diff.new_devices.append( DeviceChange( identifier=f"{freq:.1f} MHz", protocol="rf", change_type="new", description=f"New RF signal at {freq:.3f} MHz", expected=False, details={ "frequency": freq, "power": signal.get("power", signal.get("level")), "modulation": signal.get("modulation"), }, ) ) # Find missing signals for freq, signal in baseline.items(): if freq not in current_freqs: diff.missing_devices.append( DeviceChange( identifier=f"{freq:.1f} MHz", protocol="rf", change_type="missing", description=f"Missing RF signal at {freq:.1f} MHz", expected=True, # RF signals can be intermittent details={"frequency": freq}, ) ) def _calculate_baseline_health(diff: BaselineDiff, baseline: dict) -> None: """Calculate baseline health score and status.""" score = 1.0 reasons = [] # Age penalty if diff.baseline_age_hours > 168: # > 1 week score -= 0.4 reasons.append(f"Baseline is {diff.baseline_age_hours:.0f} hours old (>1 week)") elif diff.baseline_age_hours > 72: # > 3 days score -= 0.2 reasons.append(f"Baseline is {diff.baseline_age_hours:.0f} hours old (>3 days)") elif diff.baseline_age_hours > 24: score -= 0.1 reasons.append(f"Baseline is {diff.baseline_age_hours:.0f} hours old") # Device churn penalty total_baseline = ( len(baseline.get("wifi_networks", [])) + len(baseline.get("wifi_clients", [])) + len(baseline.get("bt_devices", [])) + len(baseline.get("rf_frequencies", [])) ) if total_baseline > 0: churn_rate = (diff.total_new + diff.total_missing) / total_baseline if churn_rate > 0.5: score -= 0.3 reasons.append(f"High device churn rate: {churn_rate:.0%}") elif churn_rate > 0.25: score -= 0.15 reasons.append(f"Moderate device churn rate: {churn_rate:.0%}") # Small baseline penalty if total_baseline < 3: score -= 0.2 reasons.append(f"Baseline has few devices ({total_baseline}) - may be incomplete") # Set health status diff.health_score = max(0, min(1, score)) if diff.health_score >= 0.7: diff.health = BaselineHealth.HEALTHY elif diff.health_score >= 0.4: diff.health = BaselineHealth.NOISY if not reasons: reasons.append("Baseline showing moderate variability") else: diff.health = BaselineHealth.STALE if not reasons: reasons.append("Baseline requires refresh") diff.health_reasons = reasons # ============================================================================= # 3. Per-Device Timelines # ============================================================================= @dataclass class DeviceObservation: """A single observation of a device.""" timestamp: datetime rssi: int | None = None present: bool = True channel: int | None = None frequency: float | None = None attributes: dict = field(default_factory=dict) @dataclass class DeviceTimeline: """ Complete timeline for a device showing behavior over time. Used to assess signal stability, movement patterns, and meeting window correlation. """ identifier: str protocol: str name: str | None = None # Observation history (time-bucketed) observations: list[DeviceObservation] = field(default_factory=list) # Computed metrics first_seen: datetime | None = None last_seen: datetime | None = None total_observations: int = 0 presence_ratio: float = 0.0 # % of time device was present # Signal metrics rssi_min: int | None = None rssi_max: int | None = None rssi_mean: float | None = None rssi_stability: float = 0.0 # 0-1, higher = more stable # Movement assessment appears_stationary: bool = True movement_pattern: str = "unknown" # 'stationary', 'mobile', 'intermittent' # Meeting correlation meeting_correlated: bool = False meeting_observations: int = 0 def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { "identifier": self.identifier, "protocol": self.protocol, "name": self.name, "observations": [ { "timestamp": obs.timestamp.isoformat(), "rssi": obs.rssi, "present": obs.present, "channel": obs.channel, "frequency": obs.frequency, } for obs in self.observations[-50:] # Limit to last 50 ], "metrics": { "first_seen": self.first_seen.isoformat() if self.first_seen else None, "last_seen": self.last_seen.isoformat() if self.last_seen else None, "total_observations": self.total_observations, "presence_ratio": round(self.presence_ratio, 2), }, "signal": { "rssi_min": self.rssi_min, "rssi_max": self.rssi_max, "rssi_mean": round(self.rssi_mean, 1) if self.rssi_mean else None, "stability": round(self.rssi_stability, 2), }, "movement": { "appears_stationary": self.appears_stationary, "pattern": self.movement_pattern, }, "meeting_correlation": { "correlated": self.meeting_correlated, "observations_during_meeting": self.meeting_observations, }, } class TimelineManager: """ Manages per-device timelines with time-bucketing. Buckets observations to keep memory bounded while preserving useful behavioral patterns. """ def __init__(self, bucket_seconds: int = 30, max_observations: int = 200): """ Args: bucket_seconds: Time bucket size in seconds max_observations: Maximum observations to keep per device """ self.bucket_seconds = bucket_seconds self.max_observations = max_observations self.timelines: dict[str, DeviceTimeline] = {} self._meeting_windows: list[tuple[datetime, datetime | None]] = [] def add_observation( self, identifier: str, protocol: str, rssi: int | None = None, channel: int | None = None, frequency: float | None = None, name: str | None = None, attributes: dict | None = None, ) -> None: """Add an observation for a device.""" key = f"{protocol}:{identifier.upper()}" now = datetime.now() if key not in self.timelines: self.timelines[key] = DeviceTimeline( identifier=identifier.upper(), protocol=protocol, name=name, first_seen=now, ) timeline = self.timelines[key] # Update name if provided if name: timeline.name = name # Check if we should bucket with previous observation if timeline.observations: last_obs = timeline.observations[-1] time_diff = (now - last_obs.timestamp).total_seconds() if time_diff < self.bucket_seconds: # Update existing bucket if rssi is not None: # Average RSSI if last_obs.rssi is not None: last_obs.rssi = (last_obs.rssi + rssi) // 2 else: last_obs.rssi = rssi return # Add new observation obs = DeviceObservation( timestamp=now, rssi=rssi, present=True, channel=channel, frequency=frequency, attributes=attributes or {}, ) timeline.observations.append(obs) # Enforce max observations if len(timeline.observations) > self.max_observations: timeline.observations = timeline.observations[-self.max_observations :] # Update metrics timeline.last_seen = now timeline.total_observations = len(timeline.observations) # Check meeting correlation if self._is_during_meeting(now): timeline.meeting_observations += 1 timeline.meeting_correlated = True def start_meeting_window(self) -> None: """Mark the start of a meeting window.""" self._meeting_windows.append((datetime.now(), None)) def end_meeting_window(self) -> None: """Mark the end of a meeting window.""" if self._meeting_windows and self._meeting_windows[-1][1] is None: start = self._meeting_windows[-1][0] self._meeting_windows[-1] = (start, datetime.now()) def _is_during_meeting(self, timestamp: datetime) -> bool: """Check if timestamp falls within a meeting window.""" for start, end in self._meeting_windows: if end is None: if timestamp >= start: return True elif start <= timestamp <= end: return True return False def compute_metrics(self, identifier: str, protocol: str) -> DeviceTimeline | None: """Compute all metrics for a device timeline.""" key = f"{protocol}:{identifier.upper()}" if key not in self.timelines: return None timeline = self.timelines[key] if not timeline.observations: return timeline # RSSI metrics rssi_values = [obs.rssi for obs in timeline.observations if obs.rssi is not None] if rssi_values: timeline.rssi_min = min(rssi_values) timeline.rssi_max = max(rssi_values) timeline.rssi_mean = sum(rssi_values) / len(rssi_values) # Calculate stability (0-1) if len(rssi_values) >= 3: variance = sum((r - timeline.rssi_mean) ** 2 for r in rssi_values) / len(rssi_values) timeline.rssi_stability = max(0, 1 - (variance / 100)) # Movement assessment based on RSSI variance rssi_range = timeline.rssi_max - timeline.rssi_min if rssi_range < 10: timeline.appears_stationary = True timeline.movement_pattern = "stationary" elif rssi_range < 25: timeline.appears_stationary = False timeline.movement_pattern = "mobile" else: timeline.appears_stationary = False timeline.movement_pattern = "intermittent" # Presence ratio if timeline.first_seen and timeline.last_seen: total_duration = (timeline.last_seen - timeline.first_seen).total_seconds() if total_duration > 0: # Estimate presence based on observation count and bucket size estimated_present_time = timeline.total_observations * self.bucket_seconds timeline.presence_ratio = min(1.0, estimated_present_time / total_duration) return timeline def get_timeline(self, identifier: str, protocol: str) -> DeviceTimeline | None: """Get computed timeline for a device.""" return self.compute_metrics(identifier, protocol) def get_all_timelines(self) -> list[DeviceTimeline]: """Get all device timelines with computed metrics.""" for key in self.timelines: protocol, identifier = key.split(":", 1) self.compute_metrics(identifier, protocol) return list(self.timelines.values()) # ============================================================================= # 5. Meeting-Window Summary Enhancements # ============================================================================= @dataclass class MeetingWindowSummary: """ Summary of device activity during a meeting window. Tracks devices first seen during meeting, behavior changes, and applies meeting-window scoring modifiers. """ meeting_id: int name: str | None = None start_time: datetime | None = None end_time: datetime | None = None duration_minutes: float = 0.0 # Devices first seen during meeting (high interest) devices_first_seen: list[dict] = field(default_factory=list) # Devices with behavior change during meeting devices_behavior_change: list[dict] = field(default_factory=list) # All active devices during meeting active_devices: list[dict] = field(default_factory=list) # Summary metrics total_devices_active: int = 0 new_devices_count: int = 0 behavior_changes_count: int = 0 high_interest_count: int = 0 def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { "meeting_id": self.meeting_id, "name": self.name, "start_time": self.start_time.isoformat() if self.start_time else None, "end_time": self.end_time.isoformat() if self.end_time else None, "duration_minutes": round(self.duration_minutes, 1), "summary": { "total_devices_active": self.total_devices_active, "new_devices": self.new_devices_count, "behavior_changes": self.behavior_changes_count, "high_interest": self.high_interest_count, }, "devices_first_seen": self.devices_first_seen, "devices_behavior_change": self.devices_behavior_change, "disclaimer": ( "Meeting-correlated activity indicates temporal correlation only, " "not confirmed surveillance. Devices may have legitimate reasons " "for appearing during meetings." ), } def generate_meeting_summary( meeting_window: dict, device_timelines: list[DeviceTimeline], device_profiles: list[dict] ) -> MeetingWindowSummary: """ Generate summary of device activity during a meeting window. Args: meeting_window: Meeting window dict from database device_timelines: List of device timelines device_profiles: List of device profiles from correlation engine Returns: MeetingWindowSummary with analysis """ summary = MeetingWindowSummary( meeting_id=meeting_window.get("id", 0), name=meeting_window.get("name"), ) # Parse times start_str = meeting_window.get("start_time") end_str = meeting_window.get("end_time") if start_str: if isinstance(start_str, str): summary.start_time = datetime.fromisoformat(start_str.replace("Z", "+00:00")).replace(tzinfo=None) else: summary.start_time = start_str if end_str: if isinstance(end_str, str): summary.end_time = datetime.fromisoformat(end_str.replace("Z", "+00:00")).replace(tzinfo=None) else: summary.end_time = end_str if summary.start_time and summary.end_time: summary.duration_minutes = (summary.end_time - summary.start_time).total_seconds() / 60 if not summary.start_time: return summary # Analyze device timelines for timeline in device_timelines: if not timeline.first_seen: continue # Check if device was active during meeting was_active = False first_seen_during = False for obs in timeline.observations: if summary.end_time: if summary.start_time <= obs.timestamp <= summary.end_time: was_active = True if timeline.first_seen and abs((obs.timestamp - timeline.first_seen).total_seconds()) < 60: first_seen_during = True break else: # Meeting still ongoing if obs.timestamp >= summary.start_time: was_active = True if timeline.first_seen and abs((obs.timestamp - timeline.first_seen).total_seconds()) < 60: first_seen_during = True break if was_active: device_info = { "identifier": timeline.identifier, "protocol": timeline.protocol, "name": timeline.name, "meeting_correlated": True, } summary.active_devices.append(device_info) if first_seen_during: device_info["first_seen_during_meeting"] = True summary.devices_first_seen.append( { **device_info, "description": "Device first seen during meeting window", "risk_modifier": "+2 (meeting-correlated activity)", } ) # Update counts summary.total_devices_active = len(summary.active_devices) summary.new_devices_count = len(summary.devices_first_seen) summary.behavior_changes_count = len(summary.devices_behavior_change) # Count high interest from profiles for profile in device_profiles: if profile.get("risk_level") == "high_interest": indicators = profile.get("indicators", []) if any(i.get("type") == "meeting_correlated" for i in indicators): summary.high_interest_count += 1 return summary # ============================================================================= # 7. WiFi Advanced Indicators (LIMITED SCOPE) # ============================================================================= @dataclass class WiFiAdvancedIndicator: """An advanced WiFi indicator detection.""" indicator_type: str # 'evil_twin', 'probe_request', 'deauth_burst' severity: str # 'high', 'medium', 'low' description: str details: dict = field(default_factory=dict) timestamp: datetime = field(default_factory=datetime.now) requires_monitor_mode: bool = False def to_dict(self) -> dict: return { "type": self.indicator_type, "severity": self.severity, "description": self.description, "details": self.details, "timestamp": self.timestamp.isoformat(), "requires_monitor_mode": self.requires_monitor_mode, "disclaimer": ( "Pattern detected - this is an indicator, not confirmation of an attack. " "Further investigation required." ), } class WiFiAdvancedDetector: """ Detects advanced WiFi indicators. LIMITED SCOPE - Only implements: 1. Evil Twin patterns (same SSID, different BSSID/security/abnormal signal) 2. Probe requests for sensitive SSIDs (requires monitor mode) 3. Deauthentication bursts (requires monitor mode) All findings labeled as "pattern detected", never called attacks. """ def __init__(self, monitor_mode_available: bool = False): self.monitor_mode = monitor_mode_available self.known_networks: dict[str, dict] = {} # SSID -> expected BSSID/security self.probe_requests: list[dict] = [] self.deauth_frames: list[dict] = [] self.indicators: list[WiFiAdvancedIndicator] = [] def set_known_networks(self, networks: list[dict]) -> None: """Set known/expected networks from baseline.""" for net in networks: ssid = net.get("essid", net.get("ssid", "")) if ssid: self.known_networks[ssid] = { "bssid": net.get("bssid", net.get("mac", "")).upper(), "security": net.get("encryption", net.get("privacy", "")), "channel": net.get("channel"), "rssi": net.get("power", net.get("signal")), } def analyze_network(self, network: dict) -> list[WiFiAdvancedIndicator]: """ Analyze a network for evil twin patterns. Detects: Same SSID with different BSSID, security, or abnormal signal. """ indicators = [] ssid = network.get("essid", network.get("ssid", "")) bssid = network.get("bssid", network.get("mac", "")).upper() security = network.get("encryption", network.get("privacy", "")) rssi = network.get("power", network.get("signal")) if not ssid or ssid in ["", "Hidden", "[Hidden]"]: return indicators if ssid in self.known_networks: known = self.known_networks[ssid] # Different BSSID for same SSID if known["bssid"] and known["bssid"] != bssid: # Check security mismatch security_mismatch = known["security"] and security and known["security"] != security # Check signal anomaly (significantly stronger than expected) signal_anomaly = False if rssi and known.get("rssi"): try: rssi_diff = int(rssi) - int(known["rssi"]) signal_anomaly = rssi_diff > 20 # Much stronger than expected except (ValueError, TypeError): pass if security_mismatch: indicators.append( WiFiAdvancedIndicator( indicator_type="evil_twin", severity="high", description=f'Evil twin pattern detected for SSID "{ssid}"', details={ "ssid": ssid, "detected_bssid": bssid, "expected_bssid": known["bssid"], "detected_security": security, "expected_security": known["security"], "pattern": "Different BSSID with security downgrade", }, requires_monitor_mode=False, ) ) elif signal_anomaly: indicators.append( WiFiAdvancedIndicator( indicator_type="evil_twin", severity="medium", description=f'Possible evil twin pattern for SSID "{ssid}"', details={ "ssid": ssid, "detected_bssid": bssid, "expected_bssid": known["bssid"], "signal_difference": f"+{rssi_diff} dBm stronger than expected", "pattern": "Different BSSID with abnormally strong signal", }, requires_monitor_mode=False, ) ) else: indicators.append( WiFiAdvancedIndicator( indicator_type="evil_twin", severity="low", description=f'Duplicate SSID detected: "{ssid}"', details={ "ssid": ssid, "detected_bssid": bssid, "expected_bssid": known["bssid"], "pattern": "Multiple APs with same SSID (may be legitimate)", }, requires_monitor_mode=False, ) ) self.indicators.extend(indicators) return indicators def add_probe_request(self, frame: dict) -> WiFiAdvancedIndicator | None: """ Record a probe request frame (requires monitor mode). Detects repeated probing for sensitive SSIDs. """ if not self.monitor_mode: return None self.probe_requests.append( { "timestamp": datetime.now(), "src_mac": frame.get("src_mac", "").upper(), "probed_ssid": frame.get("ssid", ""), } ) # Keep last 1000 probe requests if len(self.probe_requests) > 1000: self.probe_requests = self.probe_requests[-1000:] # Check for sensitive SSID probing ssid = frame.get("ssid", "") sensitive_patterns = [ "corp", "internal", "private", "secure", "vpn", "admin", "management", "executive", "board", ] is_sensitive = any(p in ssid.lower() for p in sensitive_patterns) if ssid else False if is_sensitive: # Count recent probes for this SSID recent_cutoff = datetime.now() - timedelta(minutes=5) recent_probes = [ p for p in self.probe_requests if p["probed_ssid"] == ssid and p["timestamp"] > recent_cutoff ] if len(recent_probes) >= 3: indicator = WiFiAdvancedIndicator( indicator_type="probe_request", severity="medium", description=f'Repeated probing for sensitive SSID "{ssid}"', details={ "ssid": ssid, "probe_count": len(recent_probes), "source_macs": list({p["src_mac"] for p in recent_probes}), "pattern": "Multiple probe requests for potentially sensitive network", }, requires_monitor_mode=True, ) self.indicators.append(indicator) return indicator return None def add_deauth_frame(self, frame: dict) -> WiFiAdvancedIndicator | None: """ Record a deauthentication frame (requires monitor mode). Detects abnormal deauth volume potentially indicating attack. """ if not self.monitor_mode: return None self.deauth_frames.append( { "timestamp": datetime.now(), "src_mac": frame.get("src_mac", "").upper(), "dst_mac": frame.get("dst_mac", "").upper(), "bssid": frame.get("bssid", "").upper(), "reason": frame.get("reason_code"), } ) # Keep last 500 deauth frames if len(self.deauth_frames) > 500: self.deauth_frames = self.deauth_frames[-500:] # Check for deauth burst (>10 deauths in 10 seconds) recent_cutoff = datetime.now() - timedelta(seconds=10) recent_deauths = [d for d in self.deauth_frames if d["timestamp"] > recent_cutoff] if len(recent_deauths) >= 10: # Check if targeting specific BSSID bssid = frame.get("bssid", "").upper() targeting_bssid = len([d for d in recent_deauths if d["bssid"] == bssid]) >= 5 indicator = WiFiAdvancedIndicator( indicator_type="deauth_burst", severity="high" if targeting_bssid else "medium", description="Deauthentication burst pattern detected", details={ "deauth_count": len(recent_deauths), "time_window_seconds": 10, "targeted_bssid": bssid if targeting_bssid else None, "unique_sources": len({d["src_mac"] for d in recent_deauths}), "pattern": "Abnormal deauthentication frame volume", }, requires_monitor_mode=True, ) self.indicators.append(indicator) # Clear recent to avoid repeated alerts self.deauth_frames = [d for d in self.deauth_frames if d["timestamp"] <= recent_cutoff] return indicator return None def get_all_indicators(self) -> list[dict]: """Get all detected indicators.""" return [i.to_dict() for i in self.indicators] def get_unavailable_features(self) -> list[str]: """Get list of features unavailable without monitor mode.""" if self.monitor_mode: return [] return [ "Probe request analysis: Requires monitor mode to capture probe frames.", "Deauthentication detection: Requires monitor mode to capture management frames.", "Raw 802.11 frame analysis: Not available in managed mode.", ] # ============================================================================= # 8. Bluetooth Risk Explainability & Proximity Heuristics # ============================================================================= class BLEProximity(Enum): """RSSI-based proximity estimation.""" VERY_CLOSE = "very_close" # Within ~1m CLOSE = "close" # Within ~3m MODERATE = "moderate" # Within ~10m FAR = "far" # Beyond ~10m UNKNOWN = "unknown" @dataclass class BLERiskExplanation: """ Explainable risk assessment for a BLE device. Provides human-readable explanations, proximity estimates, and recommended actions. """ identifier: str name: str | None = None # Risk assessment risk_level: str = "informational" risk_score: int = 0 risk_explanation: str = "" # Proximity proximity: BLEProximity = BLEProximity.UNKNOWN proximity_explanation: str = "" estimated_distance: str = "" # Tracker detection is_tracker: bool = False tracker_type: str | None = None tracker_explanation: str = "" # Meeting correlation meeting_correlated: bool = False meeting_explanation: str = "" # Recommended action recommended_action: str = "" action_rationale: str = "" # All indicators with explanations indicators: list[dict] = field(default_factory=list) def to_dict(self) -> dict: return { "identifier": self.identifier, "name": self.name, "risk": { "level": self.risk_level, "score": self.risk_score, "explanation": self.risk_explanation, }, "proximity": { "estimate": self.proximity.value, "explanation": self.proximity_explanation, "estimated_distance": self.estimated_distance, }, "tracker": { "is_tracker": self.is_tracker, "type": self.tracker_type, "explanation": self.tracker_explanation, }, "meeting_correlation": { "correlated": self.meeting_correlated, "explanation": self.meeting_explanation, }, "recommended_action": { "action": self.recommended_action, "rationale": self.action_rationale, }, "indicators": self.indicators, "disclaimer": ( "Risk assessment is based on observable indicators and heuristics. " "Proximity estimates are approximate based on RSSI and may vary with environment. " "Tracker detection indicates brand presence, not confirmed threat." ), } def estimate_ble_proximity(rssi: int) -> tuple[BLEProximity, str, str]: """ Estimate BLE device proximity from RSSI. Note: RSSI-based distance is highly variable due to: - TX power differences between devices - Environmental factors (walls, interference) - Antenna characteristics Returns: Tuple of (proximity enum, explanation, estimated distance string) """ if rssi is None: return (BLEProximity.UNKNOWN, "RSSI not available - cannot estimate proximity", "Unknown") # These thresholds are heuristic approximations if rssi >= -50: return ( BLEProximity.VERY_CLOSE, f"Very strong signal ({rssi} dBm) suggests device is very close", "< 1 meter (approximate)", ) elif rssi >= -65: return (BLEProximity.CLOSE, f"Strong signal ({rssi} dBm) suggests device is nearby", "1-3 meters (approximate)") elif rssi >= -80: return ( BLEProximity.MODERATE, f"Moderate signal ({rssi} dBm) suggests device is in the area", "3-10 meters (approximate)", ) else: return (BLEProximity.FAR, f"Weak signal ({rssi} dBm) suggests device is distant", "> 10 meters (approximate)") def generate_ble_risk_explanation( device: dict, profile: dict | None = None, is_during_meeting: bool = False ) -> BLERiskExplanation: """ Generate human-readable risk explanation for a BLE device. Args: device: BLE device dict with mac, name, rssi, etc. profile: DeviceProfile dict from correlation engine is_during_meeting: Whether device was detected during meeting Returns: BLERiskExplanation with complete assessment """ mac = device.get("mac", device.get("address", "")).upper() name = device.get("name", "") rssi = device.get("rssi", device.get("signal")) explanation = BLERiskExplanation( identifier=mac, name=name if name else None, ) # Proximity estimation if rssi: try: rssi_int = int(rssi) prox, prox_exp, dist = estimate_ble_proximity(rssi_int) explanation.proximity = prox explanation.proximity_explanation = prox_exp explanation.estimated_distance = dist except (ValueError, TypeError): explanation.proximity = BLEProximity.UNKNOWN explanation.proximity_explanation = "Could not parse RSSI value" # Tracker detection with explanation device.get("tracker_type") or device.get("is_tracker") if device.get("is_airtag"): explanation.is_tracker = True explanation.tracker_type = "Apple AirTag" explanation.tracker_explanation = ( "Apple AirTag detected via manufacturer data. AirTags are legitimate " "tracking devices but may indicate unwanted tracking if not recognized. " "Apple's Find My network will alert iPhone users to unknown AirTags." ) elif device.get("is_tile"): explanation.is_tracker = True explanation.tracker_type = "Tile" explanation.tracker_explanation = ( "Tile tracker detected. Tile trackers are common consumer devices " "for finding lost items. Presence does not indicate surveillance." ) elif device.get("is_smarttag"): explanation.is_tracker = True explanation.tracker_type = "Samsung SmartTag" explanation.tracker_explanation = ( "Samsung SmartTag detected. SmartTags are consumer tracking devices " "similar to AirTags. Samsung phones can detect unknown SmartTags." ) elif device.get("is_espressif"): explanation.tracker_type = "ESP32/ESP8266" explanation.tracker_explanation = ( "Espressif chipset (ESP32/ESP8266) detected. These are programmable " "development boards commonly used in IoT projects. They can be configured " "for various purposes including custom tracking devices." ) # Meeting correlation explanation if is_during_meeting or device.get("meeting_correlated"): explanation.meeting_correlated = True explanation.meeting_explanation = ( "Device detected during a marked meeting window. This temporal correlation " "is noted but does not confirm malicious intent - many legitimate devices " "are active during meetings (phones, laptops, wearables)." ) # Build risk explanation from profile if profile: explanation.risk_level = profile.get("risk_level", "informational") explanation.risk_score = profile.get("total_score", 0) # Convert indicators to explanations for ind in profile.get("indicators", []): ind_type = ind.get("type", "") ind_desc = ind.get("description", "") explanation.indicators.append( { "type": ind_type, "description": ind_desc, "explanation": _get_indicator_explanation(ind_type), } ) # Build overall risk explanation if explanation.risk_level == "high_interest": explanation.risk_explanation = ( f"This device has accumulated {explanation.risk_score} risk points " "across multiple indicators, warranting closer investigation. " "High interest does not confirm surveillance - manual verification required." ) elif explanation.risk_level == "review": explanation.risk_explanation = ( f"This device shows {explanation.risk_score} risk points indicating " "it should be reviewed but is not immediately concerning." ) else: explanation.risk_explanation = ( "This device shows typical characteristics and does not raise " "significant concerns based on observable indicators." ) else: explanation.risk_explanation = "No detailed profile available for risk assessment." # Recommended action _set_recommended_action(explanation) return explanation def _get_indicator_explanation(indicator_type: str) -> str: """Get human-readable explanation for an indicator type.""" explanations = { "unknown_device": ( "Device manufacturer is unknown or uses a generic chipset. " "This is common in DIY/hobbyist devices and some surveillance equipment." ), "audio_capable": ( "Device advertises audio services (headphones, speakers, etc.). " "Audio-capable devices could theoretically transmit captured audio." ), "persistent": ( "Device has been detected repeatedly across multiple scans. " "Persistence suggests a fixed or regularly present device." ), "meeting_correlated": ( "Device activity correlates with marked meeting windows. " "This is a temporal pattern that warrants attention." ), "hidden_identity": ( "Device does not broadcast a name or uses minimal advertising. " "Some legitimate devices minimize advertising for battery life." ), "stable_rssi": ( "Signal strength is very stable, suggesting a stationary device. " "Fixed placement could indicate a planted device." ), "mac_rotation": ( "Device appears to use MAC address randomization. " "This is a privacy feature in modern devices, also used to evade detection." ), "known_tracker": ( "Device matches known tracking device signatures. " "May be a legitimate item tracker or unwanted surveillance." ), "airtag_detected": ("Apple AirTag identified. Check if this belongs to someone present."), "tile_detected": ("Tile tracker identified. Common consumer tracking device."), "smarttag_detected": ("Samsung SmartTag identified. Consumer tracking device."), "esp32_device": ( "Espressif development board detected. Highly programmable, " "could be configured for custom surveillance applications." ), } return explanations.get(indicator_type, "Indicator detected requiring review.") def _set_recommended_action(explanation: BLERiskExplanation) -> None: """Set recommended action based on risk assessment.""" if explanation.risk_level == "high_interest": if explanation.is_tracker and explanation.proximity == BLEProximity.VERY_CLOSE: explanation.recommended_action = "Investigate immediately" explanation.action_rationale = ( "Unknown tracker in very close proximity warrants immediate " "physical search of the area and personal belongings." ) elif explanation.is_tracker: explanation.recommended_action = "Investigate location" explanation.action_rationale = ( "Tracker detected - recommend searching the area to locate " "the physical device and determine if it belongs to someone present." ) else: explanation.recommended_action = "Review and document" explanation.action_rationale = ( "Multiple risk indicators present. Document the finding, " "attempt to identify the device, and consider physical search " "if other indicators suggest surveillance." ) elif explanation.risk_level == "review": explanation.recommended_action = "Monitor and document" explanation.action_rationale = ( "Device shows some indicators worth noting. Add to monitoring list " "and compare against future sweeps to identify patterns." ) else: explanation.recommended_action = "Continue monitoring" explanation.action_rationale = ( "No immediate action required. Device will be tracked in subsequent sweeps for pattern analysis." ) # ============================================================================= # 9. Operator Playbooks ("What To Do Next") # ============================================================================= @dataclass class PlaybookStep: """A single step in an operator playbook.""" step_number: int action: str details: str safety_note: str | None = None @dataclass class OperatorPlaybook: """ Procedural guidance for TSCM operators based on findings. Playbooks are procedural (what to do), not prescriptive (how to decide). All guidance is legally safe and professional. """ playbook_id: str title: str risk_level: str description: str steps: list[PlaybookStep] = field(default_factory=list) when_to_escalate: str = "" documentation_required: list[str] = field(default_factory=list) def to_dict(self) -> dict: return { "playbook_id": self.playbook_id, "title": self.title, "risk_level": self.risk_level, "description": self.description, "steps": [ { "step": s.step_number, "action": s.action, "details": s.details, "safety_note": s.safety_note, } for s in self.steps ], "when_to_escalate": self.when_to_escalate, "documentation_required": self.documentation_required, "disclaimer": ( "This playbook provides procedural guidance only. Actions should be " "adapted to local laws, organizational policies, and professional judgment. " "Do not disassemble, interfere with, or remove suspected devices without " "proper authorization and legal guidance." ), } # Predefined playbooks by risk level PLAYBOOKS = { "high_interest_tracker": OperatorPlaybook( playbook_id="PB-001", title="High Interest: Unknown Tracker Detection", risk_level="high_interest", description="Guidance for responding to unknown tracking device detection", steps=[ PlaybookStep( step_number=1, action="Document the finding", details="Record device identifier, signal strength, location, and timestamp. Take screenshots of the detection.", ), PlaybookStep( step_number=2, action="Estimate device location", details="Use signal strength variations while moving to triangulate approximate device position. Note areas of strongest signal.", safety_note="Do not touch or disturb any physical device found.", ), PlaybookStep( step_number=3, action="Physical search (if authorized)", details="Systematically search the high-signal area. Check common hiding spots: under furniture, in plants, behind fixtures, in bags/belongings.", safety_note="Only conduct physical searches with proper authorization.", ), PlaybookStep( step_number=4, action="Identify device owner", details="If device is located, determine if it belongs to someone legitimately present. Apple/Samsung/Tile devices can be scanned by their respective apps.", ), PlaybookStep( step_number=5, action="Escalate if unidentified", details="If device owner cannot be determined and device is in sensitive location, escalate to security management.", ), ], when_to_escalate="Escalate immediately if: device is concealed in sensitive area, owner cannot be identified, or multiple unknown trackers are found.", documentation_required=[ "Device identifier (MAC address)", "Signal strength readings at multiple locations", "Physical location description", "Photos of any located devices", "Names of individuals present during search", ], ), "high_interest_generic": OperatorPlaybook( playbook_id="PB-002", title="High Interest: Suspicious Device Pattern", risk_level="high_interest", description="Guidance for devices with multiple high-risk indicators", steps=[ PlaybookStep( step_number=1, action="Review all indicators", details="Examine each risk indicator in the device profile. Understand why the device scored high interest.", ), PlaybookStep( step_number=2, action="Cross-reference with baseline", details="Check if device appears in baseline. New devices warrant more scrutiny than known devices.", ), PlaybookStep( step_number=3, action="Monitor for pattern", details="Continue sweep and note if device persists, moves, or correlates with sensitive activities.", ), PlaybookStep( step_number=4, action="Attempt identification", details="Research manufacturer OUI, check for matching devices in the environment, ask occupants about devices.", ), PlaybookStep( step_number=5, action="Document and report", details="Add finding to sweep report with full details. Include in meeting/client debrief.", ), ], when_to_escalate="Escalate if: device cannot be identified, shows surveillance-consistent behavior, or correlates strongly with sensitive activities.", documentation_required=[ "Complete device profile", "All risk indicators with scores", "Timeline of observations", "Correlation with meeting windows", "Any identification attempts and results", ], ), "needs_review": OperatorPlaybook( playbook_id="PB-003", title="Needs Review: Unknown Device", risk_level="needs_review", description="Guidance for devices requiring investigation but not immediately concerning", steps=[ PlaybookStep( step_number=1, action="Note the device", details="Add device to monitoring list. Record basic details: identifier, type, signal strength.", ), PlaybookStep( step_number=2, action="Check against known devices", details="Verify device is not a known infrastructure device or personal device of authorized personnel.", ), PlaybookStep( step_number=3, action="Continue sweep", details="Complete the sweep. Review device in context of all findings.", ), PlaybookStep( step_number=4, action="Assess in final review", details="During sweep wrap-up, decide if device warrants further investigation or can be added to baseline.", ), ], when_to_escalate='Escalate if: multiple "needs review" devices appear together, or device shows high-interest indicators in subsequent sweeps.', documentation_required=[ "Device identifier and type", "Brief description of why flagged", "Decision made (investigate further / add to baseline / monitor)", ], ), "informational": OperatorPlaybook( playbook_id="PB-004", title="Informational: Known/Expected Device", risk_level="informational", description="Guidance for devices that appear normal and expected", steps=[ PlaybookStep( step_number=1, action="Verify against baseline", details="Confirm device matches baseline entry. Note any changes (signal strength, channel, etc.).", ), PlaybookStep( step_number=2, action="Log observation", details="Record observation for timeline tracking. Even known devices should be logged.", ), PlaybookStep( step_number=3, action="Continue sweep", details="No further action required. Proceed with sweep.", ), ], when_to_escalate="Only escalate if device shows unexpected behavior changes or additional risk indicators.", documentation_required=[ "Device identifier (for timeline)", "Observation timestamp", ], ), "wifi_evil_twin": OperatorPlaybook( playbook_id="PB-005", title="High Interest: Evil Twin Pattern Detected", risk_level="high_interest", description="Guidance when duplicate SSID with security mismatch is detected", steps=[ PlaybookStep( step_number=1, action="Document both access points", details="Record details of legitimate AP and suspected rogue: BSSID, security, signal strength, channel.", ), PlaybookStep( step_number=2, action="Verify legitimate AP", details="Confirm which AP is the authorized infrastructure. Check with IT/facilities if needed.", ), PlaybookStep( step_number=3, action="Locate rogue AP", details="Use signal strength to estimate rogue AP location. Walk the area noting signal variations.", safety_note="Do not connect to or interact with the suspected rogue AP.", ), PlaybookStep( step_number=4, action="Physical search", details="Search suspected area for unauthorized access point. Check for hidden devices, suspicious equipment.", ), PlaybookStep( step_number=5, action="Report to IT Security", details="Even if device not found, report the finding to IT Security for network monitoring.", ), ], when_to_escalate="Escalate immediately. Evil twin attacks can capture credentials and traffic.", documentation_required=[ "Both AP details (BSSID, SSID, security, channel, signal)", "Location where detected", "Signal strength map if created", "Physical search results", ], ), } def get_playbook_for_finding( risk_level: str, finding_type: str | None = None, indicators: list[dict] | None = None ) -> OperatorPlaybook: """ Get appropriate playbook for a finding. Args: risk_level: Risk level string finding_type: Optional specific finding type indicators: Optional list of indicators Returns: Appropriate OperatorPlaybook """ # Check for specific finding types if finding_type == "evil_twin": return PLAYBOOKS["wifi_evil_twin"] # Check indicators for tracker if indicators: tracker_types = ["airtag_detected", "tile_detected", "smarttag_detected", "known_tracker"] if any(i.get("type") in tracker_types for i in indicators) and risk_level == "high_interest": return PLAYBOOKS["high_interest_tracker"] # Return based on risk level if risk_level == "high_interest": return PLAYBOOKS["high_interest_generic"] elif risk_level in ["review", "needs_review"]: return PLAYBOOKS["needs_review"] else: return PLAYBOOKS["informational"] def attach_playbook_to_finding(finding: dict) -> dict: """ Attach appropriate playbook to a finding dict. Args: finding: Finding dict with risk_level, indicators, etc. Returns: Finding dict with playbook attached """ risk_level = finding.get("risk_level", "informational") finding_type = finding.get("finding_type") indicators = finding.get("indicators", []) playbook = get_playbook_for_finding(risk_level, finding_type, indicators) finding["suggested_playbook"] = playbook.to_dict() finding["suggested_next_steps"] = [ f"Step {s.step_number}: {s.action}" for s in playbook.steps[:3] # First 3 steps as quick reference ] return finding # ============================================================================= # Global Instance Management # ============================================================================= _timeline_manager: TimelineManager | None = None _wifi_detector: WiFiAdvancedDetector | None = None def get_timeline_manager() -> TimelineManager: """Get or create global timeline manager.""" global _timeline_manager if _timeline_manager is None: _timeline_manager = TimelineManager() return _timeline_manager def reset_timeline_manager() -> None: """Reset global timeline manager.""" global _timeline_manager _timeline_manager = TimelineManager() def get_wifi_detector(monitor_mode: bool = False) -> WiFiAdvancedDetector: """Get or create global WiFi detector.""" global _wifi_detector if _wifi_detector is None: _wifi_detector = WiFiAdvancedDetector(monitor_mode) return _wifi_detector def reset_wifi_detector(monitor_mode: bool = False) -> None: """Reset global WiFi detector.""" global _wifi_detector _wifi_detector = WiFiAdvancedDetector(monitor_mode)