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>
2220 lines
85 KiB
Python
2220 lines
85 KiB
Python
"""
|
|
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)
|