mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 07:10:00 -07:00
- Fix SSE fanout thread AttributeError when source queue is None during interpreter shutdown by snapshotting to local variable with null guard - Fix branded "i" logo rendering oversized on first page load (FOUC) by adding inline width/height to SVG elements across 10 templates - Bump version to 2.26.0 in config.py, pyproject.toml, and CHANGELOG.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2239 lines
84 KiB
Python
2239 lines
84 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)
|