From 5d4b19aef2bb8edf92d40bec24e0c9dc1c9b06ab Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 5 Feb 2026 16:07:34 +0000 Subject: [PATCH] Fix TSCM sweep scan resilience and add per-device error isolation The sweep loop's WiFi/BT/RF scan processing had unprotected timeline_manager.add_observation() calls that could crash an entire scan iteration, silently preventing all device events from reaching the frontend. Additionally, scan interval timestamps were only updated at the end of processing, causing tight retry loops on persistent errors. - Wrap timeline observation calls in try/except for all three protocols - Move last_*_scan timestamp updates immediately after scan completes - Add per-device try/except so one bad device doesn't block others - Emit sweep_progress after WiFi scan for real-time status visibility - Log warning when WiFi scan returns 0 networks for easier diagnosis - Add known_device and score_modifier fields to correlation engine - Add TSCM scheduling, cases, known devices, and advanced WiFi indicators Co-Authored-By: Claude Opus 4.5 --- intercept_agent.py | 111 +- routes/controller.py | 95 +- routes/tscm.py | 7177 +++++++++++++++------------- static/css/modes/tscm.css | 213 + static/js/core/agents.js | 25 +- static/js/modes/wifi.js | 9 +- templates/index.html | 1678 ++++++- templates/partials/modes/tscm.html | 9 + utils/database.py | 136 +- utils/sstv.py | 55 +- utils/tscm/correlation.py | 249 +- 11 files changed, 6202 insertions(+), 3555 deletions(-) diff --git a/intercept_agent.py b/intercept_agent.py index 92dd68e..1cc91b1 100644 --- a/intercept_agent.py +++ b/intercept_agent.py @@ -3118,10 +3118,11 @@ class ModeManager: # Get params for what to scan scan_wifi = params.get('wifi', True) scan_bt = params.get('bluetooth', True) - scan_rf = params.get('rf', True) - wifi_interface = params.get('wifi_interface') or params.get('interface') - bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0') - sdr_device = params.get('sdr_device', params.get('device', 0)) + scan_rf = params.get('rf', True) + wifi_interface = params.get('wifi_interface') or params.get('interface') + bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0') + sdr_device = params.get('sdr_device', params.get('device', 0)) + sweep_type = params.get('sweep_type') # Get baseline_id for comparison (same as local mode) baseline_id = params.get('baseline_id') @@ -3129,11 +3130,11 @@ class ModeManager: started_scans = [] # Start the combined TSCM scanner thread using existing Intercept functions - thread = threading.Thread( - target=self._tscm_scanner_thread, - args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id), - daemon=True - ) + thread = threading.Thread( + target=self._tscm_scanner_thread, + args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id, sweep_type), + daemon=True + ) thread.start() self.output_threads['tscm'] = thread @@ -3152,9 +3153,9 @@ class ModeManager: 'scanning': started_scans } - def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool, - wifi_interface: str | None, bt_adapter: str, sdr_device: int, - baseline_id: int | None = None): + def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool, + wifi_interface: str | None, bt_adapter: str, sdr_device: int, + baseline_id: int | None = None, sweep_type: str | None = None): """Combined TSCM scanner using existing Intercept functions. NOTE: This matches local mode behavior exactly: @@ -3167,11 +3168,20 @@ class ModeManager: stop_event = self.stop_events.get(mode) # Import existing Intercept TSCM functions - from routes.tscm import _scan_wifi_networks, _scan_bluetooth_devices, _scan_rf_signals - logger.info("TSCM imports successful") - - # Load baseline if specified (same as local mode) - baseline = None + from routes.tscm import _scan_wifi_networks, _scan_bluetooth_devices, _scan_rf_signals + logger.info("TSCM imports successful") + + sweep_ranges = None + if sweep_type: + try: + from data.tscm_frequencies import get_sweep_preset, SWEEP_PRESETS + preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard') + sweep_ranges = preset.get('ranges') if preset else None + except Exception: + sweep_ranges = None + + # Load baseline if specified (same as local mode) + baseline = None if baseline_id and HAS_BASELINE_DB and get_tscm_baseline: baseline = get_tscm_baseline(baseline_id) if baseline: @@ -3239,15 +3249,18 @@ class ModeManager: enriched['is_new'] = not classification.get('in_baseline', False) enriched['reasons'] = classification.get('reasons', []) - if self._tscm_correlation: - profile = self._tscm_correlation.analyze_wifi_device(enriched) - enriched['classification'] = profile.risk_level.value - enriched['score'] = profile.total_score - enriched['indicators'] = [ - {'type': i.type.value, 'desc': i.description} - for i in profile.indicators - ] - enriched['recommended_action'] = profile.recommended_action + if self._tscm_correlation: + profile = self._tscm_correlation.analyze_wifi_device(enriched) + enriched['classification'] = profile.risk_level.value + enriched['score'] = profile.total_score + enriched['score_modifier'] = profile.score_modifier + enriched['known_device'] = profile.known_device + enriched['known_device_name'] = profile.known_device_name + enriched['indicators'] = [ + {'type': i.type.value, 'desc': i.description} + for i in profile.indicators + ] + enriched['recommended_action'] = profile.recommended_action self.wifi_networks[bssid] = enriched except Exception as e: @@ -3285,15 +3298,18 @@ class ModeManager: enriched['is_new'] = not classification.get('in_baseline', False) enriched['reasons'] = classification.get('reasons', []) - if self._tscm_correlation: - profile = self._tscm_correlation.analyze_bluetooth_device(enriched) - enriched['classification'] = profile.risk_level.value - enriched['score'] = profile.total_score - enriched['indicators'] = [ - {'type': i.type.value, 'desc': i.description} - for i in profile.indicators - ] - enriched['recommended_action'] = profile.recommended_action + if self._tscm_correlation: + profile = self._tscm_correlation.analyze_bluetooth_device(enriched) + enriched['classification'] = profile.risk_level.value + enriched['score'] = profile.total_score + enriched['score_modifier'] = profile.score_modifier + enriched['known_device'] = profile.known_device + enriched['known_device_name'] = profile.known_device_name + enriched['indicators'] = [ + {'type': i.type.value, 'desc': i.description} + for i in profile.indicators + ] + enriched['recommended_action'] = profile.recommended_action self.bluetooth_devices[mac] = enriched except Exception as e: @@ -3304,7 +3320,11 @@ class ModeManager: try: # Pass a stop check that uses our stop_event (not the module's _sweep_running) agent_stop_check = lambda: stop_event and stop_event.is_set() - rf_signals = _scan_rf_signals(sdr_device, stop_check=agent_stop_check) + rf_signals = _scan_rf_signals( + sdr_device, + stop_check=agent_stop_check, + sweep_ranges=sweep_ranges + ) # Analyze each RF signal like local mode does analyzed_signals = [] @@ -3324,14 +3344,17 @@ class ModeManager: analyzed['reasons'] = classification.get('reasons', []) # Use correlation engine for scoring (same as local mode) - if hasattr(self, '_tscm_correlation') and self._tscm_correlation: - profile = self._tscm_correlation.analyze_rf_signal(signal) - analyzed['classification'] = profile.risk_level.value - analyzed['score'] = profile.total_score - analyzed['indicators'] = [ - {'type': i.type.value, 'desc': i.description} - for i in profile.indicators - ] + if hasattr(self, '_tscm_correlation') and self._tscm_correlation: + profile = self._tscm_correlation.analyze_rf_signal(signal) + analyzed['classification'] = profile.risk_level.value + analyzed['score'] = profile.total_score + analyzed['score_modifier'] = profile.score_modifier + analyzed['known_device'] = profile.known_device + analyzed['known_device_name'] = profile.known_device_name + analyzed['indicators'] = [ + {'type': i.type.value, 'desc': i.description} + for i in profile.indicators + ] analyzed['is_threat'] = is_threat analyzed_signals.append(analyzed) diff --git a/routes/controller.py b/routes/controller.py index 3d29ef4..920724c 100644 --- a/routes/controller.py +++ b/routes/controller.py @@ -10,14 +10,16 @@ This blueprint provides: from __future__ import annotations -import json -import logging -import queue -import time -from datetime import datetime, timezone -from typing import Generator - -from flask import Blueprint, jsonify, request, Response +import json +import logging +import queue +import time +from datetime import datetime, timezone +from typing import Generator + +import requests + +from flask import Blueprint, jsonify, request, Response from utils.database import ( create_agent, get_agent, get_agent_by_name, list_agents, @@ -450,12 +452,12 @@ def proxy_mode_status(agent_id: int, mode: str): }), 502 -@controller_bp.route('/agents///data', methods=['GET']) -def proxy_mode_data(agent_id: int, mode: str): - """Get current data from a remote agent.""" - agent = get_agent(agent_id) - if not agent: - return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 +@controller_bp.route('/agents///data', methods=['GET']) +def proxy_mode_data(agent_id: int, mode: str): + """Get current data from a remote agent.""" + agent = get_agent(agent_id) + if not agent: + return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 try: client = create_client_from_agent(agent) @@ -473,18 +475,59 @@ def proxy_mode_data(agent_id: int, mode: str): 'data': result }) - except (AgentHTTPError, AgentConnectionError) as e: - return jsonify({ - 'status': 'error', - 'message': f'Agent error: {e}' - }), 502 - - -@controller_bp.route('/agents//wifi/monitor', methods=['POST']) -def proxy_wifi_monitor(agent_id: int): - """Toggle monitor mode on a remote agent's WiFi interface.""" - agent = get_agent(agent_id) - if not agent: + except (AgentHTTPError, AgentConnectionError) as e: + return jsonify({ + 'status': 'error', + 'message': f'Agent error: {e}' + }), 502 + + +@controller_bp.route('/agents///stream') +def proxy_mode_stream(agent_id: int, mode: str): + """Proxy SSE stream from a remote agent.""" + agent = get_agent(agent_id) + if not agent: + return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 + + client = create_client_from_agent(agent) + query = request.query_string.decode('utf-8') + url = f"{client.base_url}/{mode}/stream" + if query: + url = f"{url}?{query}" + + headers = {'Accept': 'text/event-stream'} + if agent.get('api_key'): + headers['X-API-Key'] = agent['api_key'] + + def generate() -> Generator[str, None, None]: + try: + with requests.get(url, headers=headers, stream=True, timeout=(5, 3600)) as resp: + resp.raise_for_status() + for chunk in resp.iter_content(chunk_size=1024): + if not chunk: + continue + yield chunk.decode('utf-8', errors='ignore') + except Exception as e: + logger.error(f"SSE proxy error for agent {agent_id}/{mode}: {e}") + yield format_sse({ + 'type': 'error', + 'message': str(e), + 'agent_id': agent_id, + 'mode': mode, + }) + + response = Response(generate(), mimetype='text/event-stream') + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + response.headers['Connection'] = 'keep-alive' + return response + + +@controller_bp.route('/agents//wifi/monitor', methods=['POST']) +def proxy_wifi_monitor(agent_id: int): + """Toggle monitor mode on a remote agent's WiFi interface.""" + agent = get_agent(agent_id) + if not agent: return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 data = request.json or {} diff --git a/routes/tscm.py b/routes/tscm.py index 190fbc2..518af75 100644 --- a/routes/tscm.py +++ b/routes/tscm.py @@ -1,3290 +1,3887 @@ -""" -TSCM (Technical Surveillance Countermeasures) Routes - -Provides endpoints for counter-surveillance sweeps, baseline management, -threat detection, and reporting. -""" - -from __future__ import annotations - -import json -import logging -import queue -import threading -import time -from datetime import datetime -from typing import Any - -from flask import Blueprint, Response, jsonify, request - -from data.tscm_frequencies import ( - SWEEP_PRESETS, - get_all_sweep_presets, - get_sweep_preset, -) -from utils.database import ( - add_tscm_threat, - acknowledge_tscm_threat, - create_tscm_sweep, - delete_tscm_baseline, - get_active_tscm_baseline, - get_all_tscm_baselines, - get_tscm_baseline, - get_tscm_sweep, - get_tscm_threat_summary, - get_tscm_threats, - set_active_tscm_baseline, - update_tscm_sweep, -) -from utils.tscm.baseline import ( - BaselineComparator, - BaselineRecorder, - get_comparison_for_active_baseline, -) -from utils.tscm.correlation import ( - CorrelationEngine, - get_correlation_engine, - reset_correlation_engine, -) -from utils.tscm.detector import ThreatDetector -from utils.tscm.device_identity import ( - get_identity_engine, - reset_identity_engine, - ingest_ble_dict, - ingest_wifi_dict, -) - -# Import unified Bluetooth scanner helper for TSCM integration -try: - from routes.bluetooth_v2 import get_tscm_bluetooth_snapshot - _USE_UNIFIED_BT_SCANNER = True -except ImportError: - _USE_UNIFIED_BT_SCANNER = False - -logger = logging.getLogger('intercept.tscm') - -tscm_bp = Blueprint('tscm', __name__, url_prefix='/tscm') - -# ============================================================================= -# Global State (will be initialized from app.py) -# ============================================================================= - -# These will be set by app.py -tscm_queue: queue.Queue | None = None -tscm_lock: threading.Lock | None = None - -# Local state -_sweep_thread: threading.Thread | None = None -_sweep_running = False -_current_sweep_id: int | None = None -_baseline_recorder = BaselineRecorder() - - -def init_tscm_state(tscm_q: queue.Queue, lock: threading.Lock) -> None: - """Initialize TSCM state from app.py.""" - global tscm_queue, tscm_lock - tscm_queue = tscm_q - tscm_lock = lock - - -def _emit_event(event_type: str, data: dict) -> None: - """Emit an event to the SSE queue.""" - if tscm_queue: - try: - tscm_queue.put_nowait({ - 'type': event_type, - 'timestamp': datetime.now().isoformat(), - **data - }) - except queue.Full: - logger.warning("TSCM queue full, dropping event") - - -# ============================================================================= -# Sweep Endpoints -# ============================================================================= - -def _check_available_devices(wifi: bool, bt: bool, rf: bool) -> dict: - """Check which scanning devices are available.""" - import os - import platform - import shutil - import subprocess - - available = { - 'wifi': False, - 'bluetooth': False, - 'rf': False, - 'wifi_reason': 'Not checked', - 'bt_reason': 'Not checked', - 'rf_reason': 'Not checked', - } - - # Check WiFi - if wifi: - if platform.system() == 'Darwin': - # macOS: Check for airport utility - airport_path = '/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport' - if os.path.exists(airport_path): - try: - result = subprocess.run( - [airport_path, '-I'], - capture_output=True, - text=True, - timeout=5 - ) - if result.returncode == 0: - available['wifi'] = True - available['wifi_reason'] = 'macOS WiFi available' - else: - available['wifi_reason'] = 'WiFi interface not active' - except (subprocess.TimeoutExpired, subprocess.SubprocessError): - available['wifi_reason'] = 'Cannot access WiFi interface' - else: - available['wifi_reason'] = 'macOS airport utility not found' - else: - # Linux: Check for wireless tools - if shutil.which('airodump-ng') or shutil.which('iwlist') or shutil.which('iw'): - try: - result = subprocess.run( - ['iwconfig'], - capture_output=True, - text=True, - timeout=5 - ) - if 'no wireless extensions' not in result.stderr.lower() and result.stdout.strip(): - available['wifi'] = True - available['wifi_reason'] = 'Wireless interface detected' - else: - available['wifi_reason'] = 'No wireless interfaces found' - except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError): - # Try iw as fallback - try: - result = subprocess.run( - ['iw', 'dev'], - capture_output=True, - text=True, - timeout=5 - ) - if 'Interface' in result.stdout: - available['wifi'] = True - available['wifi_reason'] = 'Wireless interface detected' - else: - # Check /sys/class/net for wireless interfaces - try: - import glob - wireless_devs = glob.glob('/sys/class/net/*/wireless') - if wireless_devs: - available['wifi'] = True - available['wifi_reason'] = 'Wireless interface detected' - else: - available['wifi_reason'] = 'No wireless interfaces found' - except Exception: - available['wifi_reason'] = 'No wireless interfaces found' - except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError): - # Last resort: check /sys/class/net - try: - import glob - wireless_devs = glob.glob('/sys/class/net/*/wireless') - if wireless_devs: - available['wifi'] = True - available['wifi_reason'] = 'Wireless interface detected' - else: - available['wifi_reason'] = 'Cannot detect wireless interfaces' - except Exception: - available['wifi_reason'] = 'Cannot detect wireless interfaces' - else: - # Fallback: check /sys/class/net even without tools - try: - import glob - wireless_devs = glob.glob('/sys/class/net/*/wireless') - if wireless_devs: - available['wifi'] = True - available['wifi_reason'] = 'Wireless interface detected (no scan tools)' - else: - available['wifi_reason'] = 'WiFi tools not installed (wireless-tools)' - except Exception: - available['wifi_reason'] = 'WiFi tools not installed (wireless-tools)' - - # Check Bluetooth - if bt: - if platform.system() == 'Darwin': - # macOS: Check for Bluetooth via system_profiler - try: - result = subprocess.run( - ['system_profiler', 'SPBluetoothDataType'], - capture_output=True, - text=True, - timeout=10 - ) - if 'Bluetooth' in result.stdout and result.returncode == 0: - available['bluetooth'] = True - available['bt_reason'] = 'macOS Bluetooth available' - else: - available['bt_reason'] = 'Bluetooth not available' - except (subprocess.TimeoutExpired, FileNotFoundError): - available['bt_reason'] = 'Cannot detect Bluetooth' - else: - # Linux: Check for Bluetooth tools - if shutil.which('bluetoothctl') or shutil.which('hcitool') or shutil.which('hciconfig'): - try: - result = subprocess.run( - ['hciconfig'], - capture_output=True, - text=True, - timeout=5 - ) - if 'hci' in result.stdout.lower(): - available['bluetooth'] = True - available['bt_reason'] = 'Bluetooth adapter detected' - else: - available['bt_reason'] = 'No Bluetooth adapters found' - except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError): - # Try bluetoothctl as fallback - try: - result = subprocess.run( - ['bluetoothctl', 'list'], - capture_output=True, - text=True, - timeout=5 - ) - if result.stdout.strip(): - available['bluetooth'] = True - available['bt_reason'] = 'Bluetooth adapter detected' - else: - # Check /sys for Bluetooth - try: - import glob - bt_devs = glob.glob('/sys/class/bluetooth/hci*') - if bt_devs: - available['bluetooth'] = True - available['bt_reason'] = 'Bluetooth adapter detected' - else: - available['bt_reason'] = 'No Bluetooth adapters found' - except Exception: - available['bt_reason'] = 'No Bluetooth adapters found' - except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError): - # Check /sys for Bluetooth - try: - import glob - bt_devs = glob.glob('/sys/class/bluetooth/hci*') - if bt_devs: - available['bluetooth'] = True - available['bt_reason'] = 'Bluetooth adapter detected' - else: - available['bt_reason'] = 'Cannot detect Bluetooth adapters' - except Exception: - available['bt_reason'] = 'Cannot detect Bluetooth adapters' - else: - # Fallback: check /sys even without tools - try: - import glob - bt_devs = glob.glob('/sys/class/bluetooth/hci*') - if bt_devs: - available['bluetooth'] = True - available['bt_reason'] = 'Bluetooth adapter detected (no scan tools)' - else: - available['bt_reason'] = 'Bluetooth tools not installed (bluez)' - except Exception: - available['bt_reason'] = 'Bluetooth tools not installed (bluez)' - - # Check RF/SDR - if rf: - try: - from utils.sdr import SDRFactory - devices = SDRFactory.detect_devices() - if devices: - available['rf'] = True - available['rf_reason'] = f'{len(devices)} SDR device(s) detected' - else: - available['rf_reason'] = 'No SDR devices found' - except ImportError: - available['rf_reason'] = 'SDR detection unavailable' - - return available - - -@tscm_bp.route('/sweep/start', methods=['POST']) -def start_sweep(): - """Start a TSCM sweep.""" - global _sweep_running, _sweep_thread, _current_sweep_id - - if _sweep_running: - return jsonify({'status': 'error', 'message': 'Sweep already running'}) - - data = request.get_json() or {} - sweep_type = data.get('sweep_type', 'standard') - baseline_id = data.get('baseline_id') - wifi_enabled = data.get('wifi', True) - bt_enabled = data.get('bluetooth', True) - rf_enabled = data.get('rf', True) - verbose_results = bool(data.get('verbose_results', False)) - - # Get interface selections - wifi_interface = data.get('wifi_interface', '') - bt_interface = data.get('bt_interface', '') - sdr_device = data.get('sdr_device') - - # Check for available devices - devices = _check_available_devices(wifi_enabled, bt_enabled, rf_enabled) - - warnings = [] - if wifi_enabled and not devices['wifi']: - warnings.append(f"WiFi: {devices['wifi_reason']}") - if bt_enabled and not devices['bluetooth']: - warnings.append(f"Bluetooth: {devices['bt_reason']}") - if rf_enabled and not devices['rf']: - warnings.append(f"RF: {devices['rf_reason']}") - - # If no devices available at all, return error - if not any([devices['wifi'], devices['bluetooth'], devices['rf']]): - return jsonify({ - 'status': 'error', - 'message': 'No scanning devices available', - 'details': warnings - }), 400 - - # Create sweep record - _current_sweep_id = create_tscm_sweep( - sweep_type=sweep_type, - baseline_id=baseline_id, - wifi_enabled=wifi_enabled, - bt_enabled=bt_enabled, - rf_enabled=rf_enabled - ) - - _sweep_running = True - - # Start sweep thread - _sweep_thread = threading.Thread( - target=_run_sweep, - args=(sweep_type, baseline_id, wifi_enabled, bt_enabled, rf_enabled, - wifi_interface, bt_interface, sdr_device, verbose_results), - daemon=True - ) - _sweep_thread.start() - - logger.info(f"Started TSCM sweep: type={sweep_type}, id={_current_sweep_id}") - - return jsonify({ - 'status': 'success', - 'message': 'Sweep started', - 'sweep_id': _current_sweep_id, - 'sweep_type': sweep_type, - 'warnings': warnings if warnings else None, - 'devices': { - 'wifi': devices['wifi'], - 'bluetooth': devices['bluetooth'], - 'rf': devices['rf'] - } - }) - - -@tscm_bp.route('/sweep/stop', methods=['POST']) -def stop_sweep(): - """Stop the current TSCM sweep.""" - global _sweep_running - - if not _sweep_running: - return jsonify({'status': 'error', 'message': 'No sweep running'}) - - _sweep_running = False - - if _current_sweep_id: - update_tscm_sweep(_current_sweep_id, status='aborted', completed=True) - - _emit_event('sweep_stopped', {'reason': 'user_requested'}) - - logger.info("TSCM sweep stopped by user") - - return jsonify({'status': 'success', 'message': 'Sweep stopped'}) - - -@tscm_bp.route('/sweep/status') -def sweep_status(): - """Get current sweep status.""" - status = { - 'running': _sweep_running, - 'sweep_id': _current_sweep_id, - } - - if _current_sweep_id: - sweep = get_tscm_sweep(_current_sweep_id) - if sweep: - status['sweep'] = sweep - - return jsonify(status) - - -@tscm_bp.route('/sweep/stream') -def sweep_stream(): - """SSE stream for real-time sweep updates.""" - def generate(): - while True: - try: - if tscm_queue: - msg = tscm_queue.get(timeout=1) - yield f"data: {json.dumps(msg)}\n\n" - else: - time.sleep(1) - yield f"data: {json.dumps({'type': 'keepalive'})}\n\n" - except queue.Empty: - yield f"data: {json.dumps({'type': 'keepalive'})}\n\n" - - return Response( - generate(), - mimetype='text/event-stream', - headers={ - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'X-Accel-Buffering': 'no' - } - ) - - -@tscm_bp.route('/devices') -def get_tscm_devices(): - """Get available scanning devices for TSCM sweeps.""" - import platform - import shutil - import subprocess - - devices = { - 'wifi_interfaces': [], - 'bt_adapters': [], - 'sdr_devices': [] - } - - # Detect WiFi interfaces - if platform.system() == 'Darwin': # macOS - try: - result = subprocess.run( - ['networksetup', '-listallhardwareports'], - capture_output=True, text=True, timeout=5 - ) - lines = result.stdout.split('\n') - for i, line in enumerate(lines): - if 'Wi-Fi' in line or 'AirPort' in line: - # Get the hardware port name (e.g., "Wi-Fi") - port_name = line.replace('Hardware Port:', '').strip() - for j in range(i + 1, min(i + 3, len(lines))): - if 'Device:' in lines[j]: - device = lines[j].split('Device:')[1].strip() - devices['wifi_interfaces'].append({ - 'name': device, - 'display_name': f'{port_name} ({device})', - 'type': 'internal', - 'monitor_capable': False - }) - break - except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): - pass - else: # Linux - try: - result = subprocess.run( - ['iw', 'dev'], - capture_output=True, text=True, timeout=5 - ) - current_iface = None - for line in result.stdout.split('\n'): - line = line.strip() - if line.startswith('Interface'): - current_iface = line.split()[1] - elif current_iface and 'type' in line: - iface_type = line.split()[-1] - devices['wifi_interfaces'].append({ - 'name': current_iface, - 'display_name': f'Wireless ({current_iface}) - {iface_type}', - 'type': iface_type, - 'monitor_capable': True - }) - current_iface = None - except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): - # Fall back to iwconfig - try: - result = subprocess.run( - ['iwconfig'], - capture_output=True, text=True, timeout=5 - ) - for line in result.stdout.split('\n'): - if 'IEEE 802.11' in line: - iface = line.split()[0] - devices['wifi_interfaces'].append({ - 'name': iface, - 'display_name': f'Wireless ({iface})', - 'type': 'managed', - 'monitor_capable': True - }) - except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): - pass - - # Detect Bluetooth adapters - if platform.system() == 'Linux': - try: - result = subprocess.run( - ['hciconfig'], - capture_output=True, text=True, timeout=5 - ) - import re - blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE) - for idx, block in enumerate(blocks): - if block.strip(): - first_line = block.split('\n')[0] - match = re.match(r'(hci\d+):', first_line) - if match: - iface_name = match.group(1) - is_up = 'UP RUNNING' in block or '\tUP ' in block - devices['bt_adapters'].append({ - 'name': iface_name, - 'display_name': f'Bluetooth Adapter ({iface_name})', - 'type': 'hci', - 'status': 'up' if is_up else 'down' - }) - except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): - # Try bluetoothctl as fallback - try: - result = subprocess.run( - ['bluetoothctl', 'list'], - capture_output=True, text=True, timeout=5 - ) - for line in result.stdout.split('\n'): - if 'Controller' in line: - # Format: Controller XX:XX:XX:XX:XX:XX Name - parts = line.split() - if len(parts) >= 3: - addr = parts[1] - name = ' '.join(parts[2:]) if len(parts) > 2 else 'Bluetooth' - devices['bt_adapters'].append({ - 'name': addr, - 'display_name': f'{name} ({addr[-8:]})', - 'type': 'controller', - 'status': 'available' - }) - except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): - pass - elif platform.system() == 'Darwin': - # macOS has built-in Bluetooth - get more info via system_profiler - try: - result = subprocess.run( - ['system_profiler', 'SPBluetoothDataType'], - capture_output=True, text=True, timeout=10 - ) - # Extract controller info - bt_name = 'Built-in Bluetooth' - bt_addr = '' - for line in result.stdout.split('\n'): - if 'Address:' in line: - bt_addr = line.split('Address:')[1].strip() - break - devices['bt_adapters'].append({ - 'name': 'default', - 'display_name': f'{bt_name}' + (f' ({bt_addr[-8:]})' if bt_addr else ''), - 'type': 'macos', - 'status': 'available' - }) - except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): - devices['bt_adapters'].append({ - 'name': 'default', - 'display_name': 'Built-in Bluetooth', - 'type': 'macos', - 'status': 'available' - }) - - # Detect SDR devices - try: - from utils.sdr import SDRFactory - sdr_list = SDRFactory.detect_devices() - for sdr in sdr_list: - # SDRDevice is a dataclass with attributes, not a dict - sdr_type_name = sdr.sdr_type.value if hasattr(sdr.sdr_type, 'value') else str(sdr.sdr_type) - # Create a friendly display name - display_name = sdr.name - if sdr.serial and sdr.serial not in ('N/A', 'Unknown'): - display_name = f'{sdr.name} (SN: {sdr.serial[-8:]})' - devices['sdr_devices'].append({ - 'index': sdr.index, - 'name': sdr.name, - 'display_name': display_name, - 'type': sdr_type_name, - 'serial': sdr.serial, - 'driver': sdr.driver - }) - except ImportError: - logger.debug("SDR module not available") - except Exception as e: - logger.warning(f"Error detecting SDR devices: {e}") - - # Check if running as root - import os - from flask import current_app - running_as_root = current_app.config.get('RUNNING_AS_ROOT', os.geteuid() == 0) - - warnings = [] - if not running_as_root: - warnings.append({ - 'type': 'privileges', - 'message': 'Not running as root. WiFi monitor mode and some Bluetooth features require sudo.', - 'action': 'Run with: sudo -E venv/bin/python intercept.py' - }) - - return jsonify({ - 'status': 'success', - 'devices': devices, - 'running_as_root': running_as_root, - 'warnings': warnings - }) - - -def _scan_wifi_networks(interface: str) -> list[dict]: - """ - Scan for WiFi networks using the unified WiFi scanner. - - This is a facade that maintains backwards compatibility with TSCM - while using the new unified scanner module. - - Automatically detects monitor mode interfaces and uses deep scan - (airodump-ng) when appropriate. - - Args: - interface: WiFi interface name (optional). - - Returns: - List of network dicts with: bssid, essid, power, channel, privacy - """ - try: - from utils.wifi import get_wifi_scanner - - scanner = get_wifi_scanner() - - # Check if interface is in monitor mode - is_monitor = False - if interface: - is_monitor = scanner._is_monitor_mode_interface(interface) - - if is_monitor: - # Use deep scan for monitor mode interfaces - logger.info(f"Interface {interface} is in monitor mode, using deep scan") - - # Check if airodump-ng is available - caps = scanner.check_capabilities() - if not caps.has_airodump_ng: - logger.warning("airodump-ng not available for monitor mode scanning") - return [] - - # Start a short deep scan - if not scanner.is_scanning: - scanner.start_deep_scan(interface=interface, band='all') - - # Wait briefly for some results - import time - time.sleep(5) - - # Get current access points - networks = [] - for ap in scanner.access_points: - networks.append(ap.to_legacy_dict()) - - logger.info(f"WiFi deep scan found {len(networks)} networks") - return networks - else: - # Use quick scan for managed mode interfaces - result = scanner.quick_scan(interface=interface, timeout=15) - - if result.error: - logger.warning(f"WiFi scan error: {result.error}") - - # Convert to legacy format for TSCM - networks = [] - for ap in result.access_points: - networks.append(ap.to_legacy_dict()) - - logger.info(f"WiFi scan found {len(networks)} networks") - return networks - - except ImportError as e: - logger.error(f"Failed to import wifi scanner: {e}") - return [] - except Exception as e: - logger.exception(f"WiFi scan failed: {e}") - return [] - - -def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]: - """ - Scan for Bluetooth devices with manufacturer data detection. - - Uses the BLE scanner module (bleak library) for proper manufacturer ID - detection, with fallback to system tools if bleak is unavailable. - """ - import platform - import os - import re - import shutil - import subprocess - - devices = [] - seen_macs = set() - - logger.info(f"Starting Bluetooth scan (duration={duration}s, interface={interface})") - - # Try the BLE scanner module first (uses bleak for proper manufacturer detection) - try: - from utils.tscm.ble_scanner import get_ble_scanner, scan_ble_devices - - logger.info("Using BLE scanner module with manufacturer detection") - ble_devices = scan_ble_devices(duration) - - for ble_dev in ble_devices: - mac = ble_dev.get('mac', '').upper() - if mac and mac not in seen_macs: - seen_macs.add(mac) - - device = { - 'mac': mac, - 'name': ble_dev.get('name', 'Unknown'), - 'rssi': ble_dev.get('rssi'), - 'type': 'ble', - 'manufacturer': ble_dev.get('manufacturer_name'), - 'manufacturer_id': ble_dev.get('manufacturer_id'), - 'is_tracker': ble_dev.get('is_tracker', False), - 'tracker_type': ble_dev.get('tracker_type'), - 'is_airtag': ble_dev.get('is_airtag', False), - 'is_tile': ble_dev.get('is_tile', False), - 'is_smarttag': ble_dev.get('is_smarttag', False), - 'is_espressif': ble_dev.get('is_espressif', False), - 'service_uuids': ble_dev.get('service_uuids', []), - } - devices.append(device) - - if devices: - logger.info(f"BLE scanner found {len(devices)} devices") - trackers = [d for d in devices if d.get('is_tracker')] - if trackers: - logger.info(f"Trackers detected: {[d.get('tracker_type') for d in trackers]}") - return devices - - except ImportError: - logger.warning("BLE scanner module not available, using fallback") - except Exception as e: - logger.warning(f"BLE scanner failed: {e}, using fallback") - - if platform.system() == 'Darwin': - # macOS: Use system_profiler for basic Bluetooth info - try: - result = subprocess.run( - ['system_profiler', 'SPBluetoothDataType', '-json'], - capture_output=True, text=True, timeout=15 - ) - import json - data = json.loads(result.stdout) - bt_data = data.get('SPBluetoothDataType', [{}])[0] - - # Get connected/paired devices - for section in ['device_connected', 'device_title']: - section_data = bt_data.get(section, {}) - if isinstance(section_data, dict): - for name, info in section_data.items(): - if isinstance(info, dict): - mac = info.get('device_address', '') - if mac and mac not in seen_macs: - seen_macs.add(mac) - devices.append({ - 'mac': mac.upper(), - 'name': name, - 'type': info.get('device_minorType', 'unknown'), - 'connected': section == 'device_connected' - }) - logger.info(f"macOS Bluetooth scan found {len(devices)} devices") - except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError, json.JSONDecodeError) as e: - logger.warning(f"macOS Bluetooth scan failed: {e}") - - else: - # Linux: Try multiple methods - iface = interface or 'hci0' - - # Method 1: Try hcitool scan (simpler, more reliable) - if shutil.which('hcitool'): - try: - logger.info("Trying hcitool scan...") - result = subprocess.run( - ['hcitool', '-i', iface, 'scan', '--flush'], - capture_output=True, text=True, timeout=duration + 5 - ) - for line in result.stdout.split('\n'): - line = line.strip() - if line and '\t' in line: - parts = line.split('\t') - if len(parts) >= 1 and ':' in parts[0]: - mac = parts[0].strip().upper() - name = parts[1].strip() if len(parts) > 1 else 'Unknown' - if mac not in seen_macs: - seen_macs.add(mac) - devices.append({'mac': mac, 'name': name}) - logger.info(f"hcitool scan found {len(devices)} classic BT devices") - except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e: - logger.warning(f"hcitool scan failed: {e}") - - # Method 2: Try btmgmt for BLE devices - if shutil.which('btmgmt'): - try: - logger.info("Trying btmgmt find...") - result = subprocess.run( - ['btmgmt', 'find'], - capture_output=True, text=True, timeout=duration + 5 - ) - for line in result.stdout.split('\n'): - # Parse btmgmt output: "dev_found: XX:XX:XX:XX:XX:XX type LE..." - if 'dev_found' in line.lower() or ('type' in line.lower() and ':' in line): - mac_match = re.search( - r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:' - r'[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})', - line - ) - if mac_match: - mac = mac_match.group(1).upper() - if mac not in seen_macs: - seen_macs.add(mac) - # Try to extract name - name_match = re.search(r'name\s+(.+?)(?:\s|$)', line, re.I) - name = name_match.group(1) if name_match else 'Unknown BLE' - devices.append({ - 'mac': mac, - 'name': name, - 'type': 'ble' if 'le' in line.lower() else 'classic' - }) - logger.info(f"btmgmt found {len(devices)} total devices") - except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e: - logger.warning(f"btmgmt find failed: {e}") - - # Method 3: Try bluetoothctl as last resort - if not devices and shutil.which('bluetoothctl'): - try: - import pty - import select - - logger.info("Trying bluetoothctl scan...") - master_fd, slave_fd = pty.openpty() - process = subprocess.Popen( - ['bluetoothctl'], - stdin=slave_fd, - stdout=slave_fd, - stderr=slave_fd, - close_fds=True - ) - os.close(slave_fd) - - # Start scanning - time.sleep(0.3) - os.write(master_fd, b'power on\n') - time.sleep(0.3) - os.write(master_fd, b'scan on\n') - - # Collect devices for specified duration - scan_end = time.time() + min(duration, 10) # Cap at 10 seconds - buffer = '' - - while time.time() < scan_end: - readable, _, _ = select.select([master_fd], [], [], 1.0) - if readable: - try: - data = os.read(master_fd, 4096) - if not data: - break - buffer += data.decode('utf-8', errors='replace') - - while '\n' in buffer: - line, buffer = buffer.split('\n', 1) - line = re.sub(r'\x1b\[[0-9;]*m', '', line).strip() - - if 'Device' in line: - match = re.search( - r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:' - r'[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})\s*(.*)', - line - ) - if match: - mac = match.group(1).upper() - name = match.group(2).strip() - # Remove RSSI from name if present - name = re.sub(r'\s*RSSI:\s*-?\d+\s*', '', name).strip() - - if mac not in seen_macs: - seen_macs.add(mac) - devices.append({ - 'mac': mac, - 'name': name or '[Unknown]' - }) - except OSError: - break - - # Stop scanning and cleanup - try: - os.write(master_fd, b'scan off\n') - time.sleep(0.2) - os.write(master_fd, b'quit\n') - except OSError: - pass - - process.terminate() - try: - process.wait(timeout=2) - except subprocess.TimeoutExpired: - process.kill() - - try: - os.close(master_fd) - except OSError: - pass - - logger.info(f"bluetoothctl scan found {len(devices)} devices") - - except (FileNotFoundError, subprocess.SubprocessError) as e: - logger.warning(f"bluetoothctl scan failed: {e}") - - return devices - - -def _scan_rf_signals(sdr_device: int | None, duration: int = 30, stop_check: callable | None = None) -> list[dict]: - """ - Scan for RF signals using SDR (rtl_power). - - Scans common surveillance frequency bands: - - 88-108 MHz: FM broadcast (potential FM bugs) - - 315 MHz: Common ISM band (wireless devices) - - 433 MHz: ISM band (European wireless devices, car keys) - - 868 MHz: European ISM band - - 915 MHz: US ISM band - - 1.2 GHz: Video transmitters - - 2.4 GHz: WiFi, Bluetooth, video transmitters - - Args: - sdr_device: SDR device index - duration: Scan duration per band - stop_check: Optional callable that returns True if scan should stop. - Defaults to checking module-level _sweep_running. - """ - # Default stop check uses module-level _sweep_running - if stop_check is None: - stop_check = lambda: not _sweep_running - import os - import shutil - import subprocess - import tempfile - - signals = [] - - logger.info(f"Starting RF scan (device={sdr_device})") - - rtl_power_path = shutil.which('rtl_power') - if not rtl_power_path: - logger.warning("rtl_power not found in PATH, RF scanning unavailable") - _emit_event('rf_status', { - 'status': 'error', - 'message': 'rtl_power not installed. Install rtl-sdr package for RF scanning.', - }) - return signals - - logger.info(f"Found rtl_power at: {rtl_power_path}") - - # Test if RTL-SDR device is accessible - rtl_test_path = shutil.which('rtl_test') - if rtl_test_path: - try: - test_result = subprocess.run( - [rtl_test_path, '-t'], - capture_output=True, - text=True, - timeout=5 - ) - if 'No supported devices found' in test_result.stderr or test_result.returncode != 0: - logger.warning("No RTL-SDR device found") - _emit_event('rf_status', { - 'status': 'error', - 'message': 'No RTL-SDR device connected. Connect an RTL-SDR dongle for RF scanning.', - }) - return signals - except subprocess.TimeoutExpired: - pass # Device might be busy, continue anyway - except Exception as e: - logger.debug(f"rtl_test check failed: {e}") - - # Define frequency bands to scan (in Hz) - focus on common bug frequencies - # Format: (start_freq, end_freq, bin_size, description) - scan_bands = [ - (88000000, 108000000, 100000, 'FM Broadcast'), # FM bugs - (315000000, 316000000, 10000, '315 MHz ISM'), # US ISM - (433000000, 434000000, 10000, '433 MHz ISM'), # EU ISM - (868000000, 869000000, 10000, '868 MHz ISM'), # EU ISM - (902000000, 928000000, 100000, '915 MHz ISM'), # US ISM - (1200000000, 1300000000, 100000, '1.2 GHz Video'), # Video TX - (2400000000, 2500000000, 500000, '2.4 GHz ISM'), # WiFi/BT/Video - ] - - # Create temp file for output - with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as tmp: - tmp_path = tmp.name - - try: - # Build device argument - device_arg = ['-d', str(sdr_device if sdr_device is not None else 0)] - - # Scan each band and look for strong signals - for start_freq, end_freq, bin_size, band_name in scan_bands: - if stop_check(): - break - - logger.info(f"Scanning {band_name} ({start_freq/1e6:.1f}-{end_freq/1e6:.1f} MHz)") - - try: - # Run rtl_power for a quick sweep of this band - cmd = [ - rtl_power_path, - '-f', f'{start_freq}:{end_freq}:{bin_size}', - '-g', '40', # Gain - '-i', '1', # Integration interval (1 second) - '-1', # Single shot mode - '-c', '20%', # Crop 20% of edges - ] + device_arg + [tmp_path] - - logger.debug(f"Running: {' '.join(cmd)}") - - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=30 - ) - - if result.returncode != 0: - logger.warning(f"rtl_power returned {result.returncode}: {result.stderr}") - - # Parse the CSV output - if os.path.exists(tmp_path) and os.path.getsize(tmp_path) > 0: - with open(tmp_path, 'r') as f: - for line in f: - parts = line.strip().split(',') - if len(parts) >= 7: - try: - # CSV format: date, time, hz_low, hz_high, hz_step, samples, db_values... - hz_low = int(parts[2]) - hz_high = int(parts[3]) - hz_step = float(parts[4]) - db_values = [float(x) for x in parts[6:] if x.strip()] - - # Find peaks above noise floor - # RTL-SDR dongles have higher noise figures, so use permissive thresholds - noise_floor = sum(db_values) / len(db_values) if db_values else -100 - threshold = noise_floor + 6 # Signal must be 6dB above noise - - for idx, db in enumerate(db_values): - if db > threshold and db > -90: # Detect signals above -90dBm - freq_hz = hz_low + (idx * hz_step) - freq_mhz = freq_hz / 1000000 - - signals.append({ - 'frequency': freq_mhz, - 'frequency_hz': freq_hz, - 'power': db, - 'band': band_name, - 'noise_floor': noise_floor, - 'signal_strength': db - noise_floor - }) - except (ValueError, IndexError): - continue - - # Clear file for next band - open(tmp_path, 'w').close() - - except subprocess.TimeoutExpired: - logger.warning(f"RF scan timeout for band {band_name}") - except Exception as e: - logger.warning(f"RF scan error for band {band_name}: {e}") - - finally: - # Cleanup temp file - try: - os.unlink(tmp_path) - except OSError: - pass - - # Deduplicate nearby frequencies (within 100kHz) - if signals: - signals.sort(key=lambda x: x['frequency']) - deduped = [signals[0]] - for sig in signals[1:]: - if sig['frequency'] - deduped[-1]['frequency'] > 0.1: # 100 kHz - deduped.append(sig) - elif sig['power'] > deduped[-1]['power']: - deduped[-1] = sig # Keep stronger signal - signals = deduped - - logger.info(f"RF scan found {len(signals)} signals") - return signals - - -def _run_sweep( - sweep_type: str, - baseline_id: int | None, - wifi_enabled: bool, - bt_enabled: bool, - rf_enabled: bool, - wifi_interface: str = '', - bt_interface: str = '', - sdr_device: int | None = None, - verbose_results: bool = False -) -> None: - """ - Run the TSCM sweep in a background thread. - - This orchestrates data collection from WiFi, BT, and RF sources, - then analyzes results for threats using the correlation engine. - """ - global _sweep_running, _current_sweep_id - - try: - # Get baseline for comparison if specified - baseline = None - if baseline_id: - baseline = get_tscm_baseline(baseline_id) - - # Get sweep preset - preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard') - duration = preset.get('duration_seconds', 300) - - _emit_event('sweep_started', { - 'sweep_id': _current_sweep_id, - 'sweep_type': sweep_type, - 'duration': duration, - 'wifi': wifi_enabled, - 'bluetooth': bt_enabled, - 'rf': rf_enabled, - }) - - # Initialize detector and correlation engine - detector = ThreatDetector(baseline) - correlation = get_correlation_engine() - # Clear old profiles from previous sweeps (keep 24h history) - correlation.clear_old_profiles(24) - - # Initialize device identity engine for MAC-randomization resistant detection - identity_engine = get_identity_engine() - identity_engine.clear() # Start fresh for this sweep - - # Collect and analyze data - threats_found = 0 - severity_counts = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0} - all_wifi = {} # Use dict for deduplication by BSSID - all_bt = {} # Use dict for deduplication by MAC - all_rf = [] - - start_time = time.time() - last_wifi_scan = 0 - last_bt_scan = 0 - last_rf_scan = 0 - wifi_scan_interval = 15 # Scan WiFi every 15 seconds - bt_scan_interval = 20 # Scan Bluetooth every 20 seconds - rf_scan_interval = 30 # Scan RF every 30 seconds - - while _sweep_running and (time.time() - start_time) < duration: - current_time = time.time() - - # Perform WiFi scan - if wifi_enabled and (current_time - last_wifi_scan) >= wifi_scan_interval: - try: - wifi_networks = _scan_wifi_networks(wifi_interface) - for network in wifi_networks: - bssid = network.get('bssid', '') - if bssid and bssid not in all_wifi: - all_wifi[bssid] = network - # Emit device event for frontend - is_threat = False - # Analyze for threats - threat = detector.analyze_wifi_device(network) - if threat: - _handle_threat(threat) - threats_found += 1 - is_threat = True - sev = threat.get('severity', 'low').lower() - if sev in severity_counts: - severity_counts[sev] += 1 - # Classify device and get correlation profile - classification = detector.classify_wifi_device(network) - profile = correlation.analyze_wifi_device(network) - - # Feed to identity engine for MAC-randomization resistant clustering - # Note: WiFi APs don't typically use randomized MACs, but clients do - try: - wifi_obs = { - 'timestamp': datetime.now().isoformat(), - 'src_mac': bssid, - 'bssid': bssid, - 'ssid': network.get('essid'), - 'rssi': network.get('power'), - 'channel': network.get('channel'), - 'encryption': network.get('privacy'), - 'frame_type': 'beacon', - } - ingest_wifi_dict(wifi_obs) - except Exception as e: - logger.debug(f"Identity engine WiFi ingest error: {e}") - - # Send device to frontend - _emit_event('wifi_device', { - 'bssid': bssid, - 'ssid': network.get('essid', 'Hidden'), - 'channel': network.get('channel', ''), - 'signal': network.get('power', ''), - 'security': network.get('privacy', ''), - 'is_threat': is_threat, - 'is_new': not classification.get('in_baseline', False), - 'classification': profile.risk_level.value, - 'reasons': classification.get('reasons', []), - 'score': profile.total_score, - 'indicators': [{'type': i.type.value, 'desc': i.description} for i in profile.indicators], - 'recommended_action': profile.recommended_action, - }) - last_wifi_scan = current_time - except Exception as e: - logger.error(f"WiFi scan error: {e}") - - # Perform Bluetooth scan - if bt_enabled and (current_time - last_bt_scan) >= bt_scan_interval: - try: - # Use unified Bluetooth scanner if available - if _USE_UNIFIED_BT_SCANNER: - logger.info("TSCM: Using unified BT scanner for snapshot") - bt_devices = get_tscm_bluetooth_snapshot(duration=8) - logger.info(f"TSCM: Unified scanner returned {len(bt_devices)} devices") - else: - logger.info(f"TSCM: Using legacy BT scanner on {bt_interface}") - bt_devices = _scan_bluetooth_devices(bt_interface, duration=8) - logger.info(f"TSCM: Legacy scanner returned {len(bt_devices)} devices") - for device in bt_devices: - mac = device.get('mac', '') - if mac and mac not in all_bt: - all_bt[mac] = device - is_threat = False - # Analyze for threats - threat = detector.analyze_bt_device(device) - if threat: - _handle_threat(threat) - threats_found += 1 - is_threat = True - sev = threat.get('severity', 'low').lower() - if sev in severity_counts: - severity_counts[sev] += 1 - # Classify device and get correlation profile - classification = detector.classify_bt_device(device) - profile = correlation.analyze_bluetooth_device(device) - - # Feed to identity engine for MAC-randomization resistant clustering - try: - ble_obs = { - 'timestamp': datetime.now().isoformat(), - 'addr': mac, - 'rssi': device.get('rssi'), - 'manufacturer_id': device.get('manufacturer_id') or device.get('company_id'), - 'manufacturer_data': device.get('manufacturer_data'), - 'service_uuids': device.get('services', []), - 'local_name': device.get('name'), - } - ingest_ble_dict(ble_obs) - except Exception as e: - logger.debug(f"Identity engine BLE ingest error: {e}") - - # Send device to frontend - _emit_event('bt_device', { - 'mac': mac, - 'name': device.get('name', 'Unknown'), - 'device_type': device.get('type', ''), - 'rssi': device.get('rssi', ''), - 'is_threat': is_threat, - 'is_new': not classification.get('in_baseline', False), - 'classification': profile.risk_level.value, - 'reasons': classification.get('reasons', []), - 'is_audio_capable': classification.get('is_audio_capable', False), - 'score': profile.total_score, - 'indicators': [{'type': i.type.value, 'desc': i.description} for i in profile.indicators], - 'recommended_action': profile.recommended_action, - }) - last_bt_scan = current_time - except Exception as e: - import traceback - logger.error(f"Bluetooth scan error: {e}\n{traceback.format_exc()}") - - # Perform RF scan using SDR - if rf_enabled and (current_time - last_rf_scan) >= rf_scan_interval: - try: - _emit_event('sweep_progress', { - 'progress': min(100, int(((current_time - start_time) / duration) * 100)), - 'status': 'Scanning RF spectrum...', - 'wifi_count': len(all_wifi), - 'bt_count': len(all_bt), - 'rf_count': len(all_rf), - }) - # Try RF scan even if sdr_device is None (will use device 0) - rf_signals = _scan_rf_signals(sdr_device) - - # If no signals and this is first RF scan, send info event - if not rf_signals and last_rf_scan == 0: - _emit_event('rf_status', { - 'status': 'no_signals', - 'message': 'RF scan completed - no signals above threshold. This may be normal in a quiet RF environment.', - }) - - for signal in rf_signals: - freq_key = f"{signal['frequency']:.3f}" - if freq_key not in [f"{s['frequency']:.3f}" for s in all_rf]: - all_rf.append(signal) - is_threat = False - # Analyze RF signal for threats - threat = detector.analyze_rf_signal(signal) - if threat: - _handle_threat(threat) - threats_found += 1 - is_threat = True - sev = threat.get('severity', 'low').lower() - if sev in severity_counts: - severity_counts[sev] += 1 - # Classify signal and get correlation profile - classification = detector.classify_rf_signal(signal) - profile = correlation.analyze_rf_signal(signal) - # Send signal to frontend - _emit_event('rf_signal', { - 'frequency': signal['frequency'], - 'power': signal['power'], - 'band': signal['band'], - 'signal_strength': signal.get('signal_strength', 0), - 'is_threat': is_threat, - 'is_new': not classification.get('in_baseline', False), - 'classification': profile.risk_level.value, - 'reasons': classification.get('reasons', []), - 'score': profile.total_score, - 'indicators': [{'type': i.type.value, 'desc': i.description} for i in profile.indicators], - 'recommended_action': profile.recommended_action, - }) - last_rf_scan = current_time - except Exception as e: - logger.error(f"RF scan error: {e}") - - # Update progress - elapsed = time.time() - start_time - progress = min(100, int((elapsed / duration) * 100)) - - _emit_event('sweep_progress', { - 'progress': progress, - 'elapsed': int(elapsed), - 'duration': duration, - 'wifi_count': len(all_wifi), - 'bt_count': len(all_bt), - 'rf_count': len(all_rf), - 'threats_found': threats_found, - 'severity_counts': severity_counts, - }) - - time.sleep(2) # Update every 2 seconds - - # Complete sweep - if _sweep_running and _current_sweep_id: - # Run cross-protocol correlation analysis - correlations = correlation.correlate_devices() - findings = correlation.get_all_findings() - - # Run baseline comparison if a baseline was provided - baseline_comparison = None - if baseline: - comparator = BaselineComparator(baseline) - baseline_comparison = comparator.compare_all( - wifi_devices=list(all_wifi.values()), - bt_devices=list(all_bt.values()), - rf_signals=all_rf - ) - logger.info( - f"Baseline comparison: {baseline_comparison['total_new']} new, " - f"{baseline_comparison['total_missing']} missing" - ) - - # Finalize identity engine and get MAC-randomization resistant clusters - identity_engine.finalize_all_sessions() - identity_summary = identity_engine.get_summary() - identity_clusters = [c.to_dict() for c in identity_engine.get_clusters()] - - if verbose_results: - wifi_payload = list(all_wifi.values()) - bt_payload = list(all_bt.values()) - rf_payload = list(all_rf) - else: - wifi_payload = [ - { - 'bssid': d.get('bssid') or d.get('mac'), - 'essid': d.get('essid') or d.get('ssid'), - 'ssid': d.get('ssid') or d.get('essid'), - 'channel': d.get('channel'), - 'power': d.get('power', d.get('signal')), - 'privacy': d.get('privacy', d.get('encryption')), - 'encryption': d.get('encryption', d.get('privacy')), - } - for d in all_wifi.values() - ] - bt_payload = [ - { - 'mac': d.get('mac') or d.get('address'), - 'name': d.get('name'), - 'rssi': d.get('rssi'), - 'manufacturer': d.get('manufacturer', d.get('manufacturer_name')), - } - for d in all_bt.values() - ] - rf_payload = [ - { - 'frequency': s.get('frequency'), - 'power': s.get('power', s.get('level')), - 'modulation': s.get('modulation'), - 'band': s.get('band'), - } - for s in all_rf - ] - - update_tscm_sweep( - _current_sweep_id, - status='completed', - results={ - 'wifi_devices': wifi_payload, - 'bt_devices': bt_payload, - 'rf_signals': rf_payload, - 'wifi_count': len(all_wifi), - 'bt_count': len(all_bt), - 'rf_count': len(all_rf), - 'severity_counts': severity_counts, - 'correlation_summary': findings.get('summary', {}), - 'identity_summary': identity_summary.get('statistics', {}), - 'baseline_comparison': baseline_comparison, - 'results_detail_level': 'full' if verbose_results else 'compact', - }, - threats_found=threats_found, - completed=True - ) - - # Emit correlation findings - _emit_event('correlation_findings', { - 'correlations': correlations, - 'high_interest_count': findings['summary'].get('high_interest', 0), - 'needs_review_count': findings['summary'].get('needs_review', 0), - }) - - # Emit baseline comparison if a baseline was used - if baseline_comparison: - _emit_event('baseline_comparison', { - 'baseline_id': baseline.get('id'), - 'baseline_name': baseline.get('name'), - 'total_new': baseline_comparison['total_new'], - 'total_missing': baseline_comparison['total_missing'], - 'wifi': baseline_comparison.get('wifi'), - 'bluetooth': baseline_comparison.get('bluetooth'), - 'rf': baseline_comparison.get('rf'), - }) - - # Emit device identity cluster findings (MAC-randomization resistant) - _emit_event('identity_clusters', { - 'total_clusters': identity_summary.get('statistics', {}).get('total_clusters', 0), - 'high_risk_count': identity_summary.get('statistics', {}).get('high_risk_count', 0), - 'medium_risk_count': identity_summary.get('statistics', {}).get('medium_risk_count', 0), - 'unique_fingerprints': identity_summary.get('statistics', {}).get('unique_fingerprints', 0), - 'clusters': identity_clusters, - }) - - _emit_event('sweep_completed', { - 'sweep_id': _current_sweep_id, - 'threats_found': threats_found, - 'wifi_count': len(all_wifi), - 'bt_count': len(all_bt), - 'rf_count': len(all_rf), - 'severity_counts': severity_counts, - 'high_interest_devices': findings['summary'].get('high_interest', 0), - 'needs_review_devices': findings['summary'].get('needs_review', 0), - 'correlations_found': len(correlations), - 'identity_clusters': identity_summary['statistics'].get('total_clusters', 0), - 'baseline_new_devices': baseline_comparison['total_new'] if baseline_comparison else 0, - 'baseline_missing_devices': baseline_comparison['total_missing'] if baseline_comparison else 0, - }) - - except Exception as e: - logger.error(f"Sweep error: {e}") - _emit_event('sweep_error', {'error': str(e)}) - if _current_sweep_id: - update_tscm_sweep(_current_sweep_id, status='error', completed=True) - - finally: - _sweep_running = False - - -def _handle_threat(threat: dict) -> None: - """Handle a detected threat.""" - if not _current_sweep_id: - return - - # Add to database - threat_id = add_tscm_threat( - sweep_id=_current_sweep_id, - threat_type=threat['threat_type'], - severity=threat['severity'], - source=threat['source'], - identifier=threat['identifier'], - name=threat.get('name'), - signal_strength=threat.get('signal_strength'), - frequency=threat.get('frequency'), - details=threat.get('details') - ) - - # Emit event - _emit_event('threat_detected', { - 'threat_id': threat_id, - **threat - }) - - logger.warning( - f"TSCM threat detected: {threat['threat_type']} - " - f"{threat['identifier']} ({threat['severity']})" - ) - - -# ============================================================================= -# Baseline Endpoints -# ============================================================================= - -@tscm_bp.route('/baseline/record', methods=['POST']) -def record_baseline(): - """Start recording a new baseline.""" - data = request.get_json() or {} - name = data.get('name', f'Baseline {datetime.now().strftime("%Y-%m-%d %H:%M")}') - location = data.get('location') - description = data.get('description') - - baseline_id = _baseline_recorder.start_recording(name, location, description) - - return jsonify({ - 'status': 'success', - 'message': 'Baseline recording started', - 'baseline_id': baseline_id - }) - - -@tscm_bp.route('/baseline/stop', methods=['POST']) -def stop_baseline(): - """Stop baseline recording.""" - result = _baseline_recorder.stop_recording() - - if 'error' in result: - return jsonify({'status': 'error', 'message': result['error']}) - - return jsonify({ - 'status': 'success', - 'message': 'Baseline recording complete', - **result - }) - - -@tscm_bp.route('/baseline/status') -def baseline_status(): - """Get baseline recording status.""" - return jsonify(_baseline_recorder.get_recording_status()) - - -@tscm_bp.route('/baselines') -def list_baselines(): - """List all baselines.""" - baselines = get_all_tscm_baselines() - return jsonify({'status': 'success', 'baselines': baselines}) - - -@tscm_bp.route('/baseline/') -def get_baseline(baseline_id: int): - """Get a specific baseline.""" - baseline = get_tscm_baseline(baseline_id) - if not baseline: - return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404 - - return jsonify({'status': 'success', 'baseline': baseline}) - - -@tscm_bp.route('/baseline//activate', methods=['POST']) -def activate_baseline(baseline_id: int): - """Set a baseline as active.""" - success = set_active_tscm_baseline(baseline_id) - if not success: - return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404 - - return jsonify({'status': 'success', 'message': 'Baseline activated'}) - - -@tscm_bp.route('/baseline/', methods=['DELETE']) -def remove_baseline(baseline_id: int): - """Delete a baseline.""" - success = delete_tscm_baseline(baseline_id) - if not success: - return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404 - - return jsonify({'status': 'success', 'message': 'Baseline deleted'}) - - -@tscm_bp.route('/baseline/active') -def get_active_baseline(): - """Get the currently active baseline.""" - baseline = get_active_tscm_baseline() - if not baseline: - return jsonify({'status': 'success', 'baseline': None}) - - return jsonify({'status': 'success', 'baseline': baseline}) - - -@tscm_bp.route('/baseline/compare', methods=['POST']) -def compare_against_baseline(): - """ - Compare provided device data against the active baseline. - - Expects JSON body with: - - wifi_devices: list of WiFi devices (optional) - - bt_devices: list of Bluetooth devices (optional) - - rf_signals: list of RF signals (optional) - - Returns comparison showing new, missing, and matching devices. - """ - data = request.get_json() or {} - - wifi_devices = data.get('wifi_devices') - bt_devices = data.get('bt_devices') - rf_signals = data.get('rf_signals') - - # Use the convenience function that gets active baseline - comparison = get_comparison_for_active_baseline( - wifi_devices=wifi_devices, - bt_devices=bt_devices, - rf_signals=rf_signals - ) - - if comparison is None: - return jsonify({ - 'status': 'error', - 'message': 'No active baseline set' - }), 400 - - return jsonify({ - 'status': 'success', - 'comparison': comparison - }) - - -# ============================================================================= -# Threat Endpoints -# ============================================================================= - -@tscm_bp.route('/threats') -def list_threats(): - """List threats with optional filters.""" - sweep_id = request.args.get('sweep_id', type=int) - severity = request.args.get('severity') - acknowledged = request.args.get('acknowledged') - limit = request.args.get('limit', 100, type=int) - - ack_filter = None - if acknowledged is not None: - ack_filter = acknowledged.lower() in ('true', '1', 'yes') - - threats = get_tscm_threats( - sweep_id=sweep_id, - severity=severity, - acknowledged=ack_filter, - limit=limit - ) - - return jsonify({'status': 'success', 'threats': threats}) - - -@tscm_bp.route('/threats/summary') -def threat_summary(): - """Get threat count summary by severity.""" - summary = get_tscm_threat_summary() - return jsonify({'status': 'success', 'summary': summary}) - - -@tscm_bp.route('/threats/', methods=['PUT']) -def update_threat(threat_id: int): - """Update a threat (acknowledge, add notes).""" - data = request.get_json() or {} - - if data.get('acknowledge'): - notes = data.get('notes') - success = acknowledge_tscm_threat(threat_id, notes) - if not success: - return jsonify({'status': 'error', 'message': 'Threat not found'}), 404 - - return jsonify({'status': 'success', 'message': 'Threat updated'}) - - -# ============================================================================= -# Preset Endpoints -# ============================================================================= - -@tscm_bp.route('/presets') -def list_presets(): - """List available sweep presets.""" - presets = get_all_sweep_presets() - return jsonify({'status': 'success', 'presets': presets}) - - -@tscm_bp.route('/presets/') -def get_preset(preset_name: str): - """Get details for a specific preset.""" - preset = get_sweep_preset(preset_name) - if not preset: - return jsonify({'status': 'error', 'message': 'Preset not found'}), 404 - - return jsonify({'status': 'success', 'preset': preset}) - - -# ============================================================================= -# Data Feed Endpoints (for adding data during sweeps/baselines) -# ============================================================================= - -@tscm_bp.route('/feed/wifi', methods=['POST']) -def feed_wifi(): - """Feed WiFi device data for baseline recording.""" - data = request.get_json() - if data: - _baseline_recorder.add_wifi_device(data) - return jsonify({'status': 'success'}) - - -@tscm_bp.route('/feed/bluetooth', methods=['POST']) -def feed_bluetooth(): - """Feed Bluetooth device data for baseline recording.""" - data = request.get_json() - if data: - _baseline_recorder.add_bt_device(data) - return jsonify({'status': 'success'}) - - -@tscm_bp.route('/feed/rf', methods=['POST']) -def feed_rf(): - """Feed RF signal data for baseline recording.""" - data = request.get_json() - if data: - _baseline_recorder.add_rf_signal(data) - return jsonify({'status': 'success'}) - - -# ============================================================================= -# Correlation & Findings Endpoints -# ============================================================================= - -@tscm_bp.route('/findings') -def get_findings(): - """ - Get comprehensive TSCM findings from the correlation engine. - - Returns all device profiles organized by risk level, cross-protocol - correlations, and summary statistics with client-safe disclaimers. - """ - correlation = get_correlation_engine() - findings = correlation.get_all_findings() - - # Add client-safe disclaimer - findings['legal_disclaimer'] = ( - "DISCLAIMER: This TSCM screening system identifies wireless and RF anomalies " - "and indicators. Results represent potential items of interest, NOT confirmed " - "surveillance devices. No content has been intercepted or decoded. Findings " - "require professional analysis and verification. This tool does not prove " - "malicious intent or illegal activity." - ) - - return jsonify({ - 'status': 'success', - 'findings': findings - }) - - -@tscm_bp.route('/findings/high-interest') -def get_high_interest(): - """Get only high-interest devices (score >= 6).""" - correlation = get_correlation_engine() - high_interest = correlation.get_high_interest_devices() - - return jsonify({ - 'status': 'success', - 'count': len(high_interest), - 'devices': [d.to_dict() for d in high_interest], - 'disclaimer': ( - "High-interest classification indicates multiple indicators warrant " - "investigation. This does NOT confirm surveillance activity." - ) - }) - - -@tscm_bp.route('/findings/correlations') -def get_correlations(): - """Get cross-protocol correlation analysis.""" - correlation = get_correlation_engine() - correlations = correlation.correlate_devices() - - return jsonify({ - 'status': 'success', - 'count': len(correlations), - 'correlations': correlations, - 'explanation': ( - "Correlations identify devices across different protocols (Bluetooth, " - "WiFi, RF) that exhibit related behavior patterns. Cross-protocol " - "activity is one indicator among many in TSCM analysis." - ) - }) - - -@tscm_bp.route('/findings/device/') -def get_device_profile(identifier: str): - """Get detailed profile for a specific device.""" - correlation = get_correlation_engine() - - # Search all protocols for the identifier - for protocol in ['bluetooth', 'wifi', 'rf']: - key = f"{protocol}:{identifier}" - if key in correlation.device_profiles: - profile = correlation.device_profiles[key] - return jsonify({ - 'status': 'success', - 'profile': profile.to_dict() - }) - - return jsonify({ - 'status': 'error', - 'message': 'Device not found' - }), 404 - - -# ============================================================================= -# Meeting Window Endpoints (for time correlation) -# ============================================================================= - -@tscm_bp.route('/meeting/start', methods=['POST']) -def start_meeting(): - """ - Mark the start of a sensitive period (meeting, briefing, etc.). - - Devices detected during this window will receive additional scoring - for meeting-correlated activity. - """ - correlation = get_correlation_engine() - correlation.start_meeting_window() - - _emit_event('meeting_started', { - 'timestamp': datetime.now().isoformat(), - 'message': 'Sensitive period monitoring active' - }) - - return jsonify({ - 'status': 'success', - 'message': 'Meeting window started - devices detected now will be flagged' - }) - - -@tscm_bp.route('/meeting/end', methods=['POST']) -def end_meeting(): - """Mark the end of a sensitive period.""" - correlation = get_correlation_engine() - correlation.end_meeting_window() - - _emit_event('meeting_ended', { - 'timestamp': datetime.now().isoformat() - }) - - return jsonify({ - 'status': 'success', - 'message': 'Meeting window ended' - }) - - -@tscm_bp.route('/meeting/status') -def meeting_status(): - """Check if currently in a meeting window.""" - correlation = get_correlation_engine() - in_meeting = correlation.is_during_meeting() - - return jsonify({ - 'status': 'success', - 'in_meeting': in_meeting, - 'windows': [ - { - 'start': start.isoformat(), - 'end': end.isoformat() if end else None - } - for start, end in correlation.meeting_windows - ] - }) - - -# ============================================================================= -# Report Generation Endpoints -# ============================================================================= - -@tscm_bp.route('/report') -def generate_report(): - """ - Generate a comprehensive TSCM sweep report. - - Includes all findings, correlations, indicators, and recommended actions - in a client-presentable format with appropriate disclaimers. - """ - correlation = get_correlation_engine() - findings = correlation.get_all_findings() - - # Build the report structure - report = { - 'generated_at': datetime.now().isoformat(), - 'report_type': 'TSCM Wireless Surveillance Screening', - - 'executive_summary': { - 'total_devices_analyzed': findings['summary']['total_devices'], - 'high_interest_items': findings['summary']['high_interest'], - 'items_requiring_review': findings['summary']['needs_review'], - 'cross_protocol_correlations': findings['summary']['correlations_found'], - 'assessment': _generate_assessment(findings['summary']), - }, - - 'methodology': { - 'protocols_scanned': ['Bluetooth Low Energy', 'WiFi 802.11', 'RF Spectrum'], - 'analysis_techniques': [ - 'Device fingerprinting', - 'Signal stability analysis', - 'Cross-protocol correlation', - 'Time-based pattern detection', - 'Manufacturer identification', - ], - 'scoring_model': { - 'informational': '0-2 points - Known or expected devices', - 'needs_review': '3-5 points - Unusual devices requiring assessment', - 'high_interest': '6+ points - Multiple indicators warrant investigation', - } - }, - - 'findings': { - 'high_interest': findings['devices']['high_interest'], - 'needs_review': findings['devices']['needs_review'], - 'informational': findings['devices']['informational'], - }, - - 'correlations': findings['correlations'], - - 'disclaimers': { - 'legal': ( - "This report documents findings from a wireless and RF surveillance " - "screening. Results indicate anomalies and items of interest, NOT " - "confirmed surveillance devices. No communications content has been " - "intercepted, recorded, or decoded. This screening does not prove " - "malicious intent, illegal activity, or the presence of surveillance " - "equipment. All findings require professional verification." - ), - 'technical': ( - "Detection capabilities are limited by equipment sensitivity, " - "environmental factors, and the technical sophistication of any " - "potential devices. Absence of findings does NOT guarantee absence " - "of surveillance equipment." - ), - 'recommendations': ( - "High-interest items should be investigated by qualified TSCM " - "professionals using appropriate physical inspection techniques. " - "This electronic sweep is one component of comprehensive TSCM." - ) - } - } - - return jsonify({ - 'status': 'success', - 'report': report - }) - - -def _generate_assessment(summary: dict) -> str: - """Generate an assessment summary based on findings.""" - high = summary.get('high_interest', 0) - review = summary.get('needs_review', 0) - correlations = summary.get('correlations_found', 0) - - if high > 0 or correlations > 0: - return ( - f"ELEVATED CONCERN: {high} high-interest item(s) and " - f"{correlations} cross-protocol correlation(s) detected. " - "Professional TSCM inspection recommended." - ) - elif review > 3: - return ( - f"MODERATE CONCERN: {review} items requiring review. " - "Further analysis recommended to characterize unknown devices." - ) - elif review > 0: - return ( - f"LOW CONCERN: {review} item(s) flagged for review. " - "Likely benign but verification recommended." - ) - else: - return ( - "BASELINE ENVIRONMENT: No significant anomalies detected. " - "Environment appears consistent with expected wireless activity." - ) - - -# ============================================================================= -# Device Identity Endpoints (MAC-Randomization Resistant Detection) -# ============================================================================= - -@tscm_bp.route('/identity/ingest/ble', methods=['POST']) -def ingest_ble_observation(): - """ - Ingest a BLE observation for device identity clustering. - - This endpoint accepts BLE advertisement data and feeds it into the - MAC-randomization resistant device detection engine. - - Expected JSON payload: - { - "timestamp": "2024-01-01T12:00:00", // ISO format or omit for now - "addr": "AA:BB:CC:DD:EE:FF", // BLE address (may be randomized) - "addr_type": "rpa", // public/random_static/rpa/nrpa/unknown - "rssi": -65, // dBm - "tx_power": -10, // dBm (optional) - "adv_type": "ADV_IND", // Advertisement type - "manufacturer_id": 1234, // Company ID (optional) - "manufacturer_data": "0102030405", // Hex string (optional) - "service_uuids": ["uuid1", "uuid2"], // List of UUIDs (optional) - "local_name": "Device Name", // Advertised name (optional) - "appearance": 960, // BLE appearance (optional) - "packet_length": 31 // Total packet length (optional) - } - """ - try: - from utils.tscm.device_identity import ingest_ble_dict - - data = request.get_json() - if not data: - return jsonify({'status': 'error', 'message': 'No data provided'}), 400 - - session = ingest_ble_dict(data) - - return jsonify({ - 'status': 'success', - 'session_id': session.session_id, - 'observation_count': len(session.observations), - }) - - except Exception as e: - logger.error(f"BLE ingestion error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -@tscm_bp.route('/identity/ingest/wifi', methods=['POST']) -def ingest_wifi_observation(): - """ - Ingest a WiFi observation for device identity clustering. - - Expected JSON payload: - { - "timestamp": "2024-01-01T12:00:00", - "src_mac": "AA:BB:CC:DD:EE:FF", // Client MAC (may be randomized) - "dst_mac": "11:22:33:44:55:66", // Destination MAC - "bssid": "11:22:33:44:55:66", // AP BSSID - "ssid": "NetworkName", // SSID if available - "frame_type": "probe_request", // Frame type - "rssi": -70, // dBm - "channel": 6, // WiFi channel - "ht_capable": true, // 802.11n capable - "vht_capable": true, // 802.11ac capable - "he_capable": false, // 802.11ax capable - "supported_rates": [1, 2, 5.5, 11], // Supported rates - "vendor_ies": [["001122", 10]], // [(OUI, length), ...] - "probed_ssids": ["ssid1", "ssid2"] // For probe requests - } - """ - try: - from utils.tscm.device_identity import ingest_wifi_dict - - data = request.get_json() - if not data: - return jsonify({'status': 'error', 'message': 'No data provided'}), 400 - - session = ingest_wifi_dict(data) - - return jsonify({ - 'status': 'success', - 'session_id': session.session_id, - 'observation_count': len(session.observations), - }) - - except Exception as e: - logger.error(f"WiFi ingestion error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -@tscm_bp.route('/identity/ingest/batch', methods=['POST']) -def ingest_batch_observations(): - """ - Ingest multiple observations in a single request. - - Expected JSON payload: - { - "ble": [, ...], - "wifi": [, ...] - } - """ - try: - from utils.tscm.device_identity import ingest_ble_dict, ingest_wifi_dict - - data = request.get_json() - if not data: - return jsonify({'status': 'error', 'message': 'No data provided'}), 400 - - ble_count = 0 - wifi_count = 0 - - for ble_obs in data.get('ble', []): - ingest_ble_dict(ble_obs) - ble_count += 1 - - for wifi_obs in data.get('wifi', []): - ingest_wifi_dict(wifi_obs) - wifi_count += 1 - - return jsonify({ - 'status': 'success', - 'ble_ingested': ble_count, - 'wifi_ingested': wifi_count, - }) - - except Exception as e: - logger.error(f"Batch ingestion error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -@tscm_bp.route('/identity/clusters') -def get_device_clusters(): - """ - Get all device clusters (probable physical device identities). - - Query parameters: - - min_confidence: Minimum cluster confidence (0-1, default 0) - - protocol: Filter by protocol ('ble' or 'wifi') - - risk_level: Filter by risk level ('high', 'medium', 'low', 'informational') - """ - try: - from utils.tscm.device_identity import get_identity_engine - - engine = get_identity_engine() - min_conf = request.args.get('min_confidence', 0, type=float) - protocol = request.args.get('protocol') - risk_filter = request.args.get('risk_level') - - clusters = engine.get_clusters(min_confidence=min_conf) - - if protocol: - clusters = [c for c in clusters if c.protocol == protocol] - - if risk_filter: - clusters = [c for c in clusters if c.risk_level.value == risk_filter] - - return jsonify({ - 'status': 'success', - 'count': len(clusters), - 'clusters': [c.to_dict() for c in clusters], - 'disclaimer': ( - "Clusters represent PROBABLE device identities based on passive " - "fingerprinting. Results are statistical correlations, not " - "confirmed matches. False positives/negatives are expected." - ) - }) - - except Exception as e: - logger.error(f"Get clusters error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -@tscm_bp.route('/identity/clusters/high-risk') -def get_high_risk_clusters(): - """Get device clusters with HIGH risk level.""" - try: - from utils.tscm.device_identity import get_identity_engine - - engine = get_identity_engine() - clusters = engine.get_high_risk_clusters() - - return jsonify({ - 'status': 'success', - 'count': len(clusters), - 'clusters': [c.to_dict() for c in clusters], - 'disclaimer': ( - "High-risk classification indicates multiple behavioral indicators " - "consistent with potential surveillance devices. This does NOT " - "confirm surveillance activity. Professional verification required." - ) - }) - - except Exception as e: - logger.error(f"Get high-risk clusters error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -@tscm_bp.route('/identity/summary') -def get_identity_summary(): - """ - Get summary of device identity analysis. - - Returns statistics, cluster counts by risk level, and monitoring period. - """ - try: - from utils.tscm.device_identity import get_identity_engine - - engine = get_identity_engine() - summary = engine.get_summary() - - return jsonify({ - 'status': 'success', - 'summary': summary - }) - - except Exception as e: - logger.error(f"Get identity summary error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -@tscm_bp.route('/identity/finalize', methods=['POST']) -def finalize_identity_sessions(): - """ - Finalize all active sessions and complete clustering. - - Call this at the end of a monitoring period to ensure all observations - are properly clustered and assessed. - """ - try: - from utils.tscm.device_identity import get_identity_engine - - engine = get_identity_engine() - engine.finalize_all_sessions() - summary = engine.get_summary() - - return jsonify({ - 'status': 'success', - 'message': 'All sessions finalized', - 'summary': summary - }) - - except Exception as e: - logger.error(f"Finalize sessions error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -@tscm_bp.route('/identity/reset', methods=['POST']) -def reset_identity_engine(): - """ - Reset the device identity engine. - - Clears all sessions, clusters, and monitoring state. - """ - try: - from utils.tscm.device_identity import reset_identity_engine as reset_engine - - reset_engine() - - return jsonify({ - 'status': 'success', - 'message': 'Device identity engine reset' - }) - - except Exception as e: - logger.error(f"Reset identity engine error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -@tscm_bp.route('/identity/cluster/') -def get_cluster_detail(cluster_id: str): - """Get detailed information for a specific cluster.""" - try: - from utils.tscm.device_identity import get_identity_engine - - engine = get_identity_engine() - - if cluster_id not in engine.clusters: - return jsonify({ - 'status': 'error', - 'message': 'Cluster not found' - }), 404 - - cluster = engine.clusters[cluster_id] - - return jsonify({ - 'status': 'success', - 'cluster': cluster.to_dict() - }) - - except Exception as e: - logger.error(f"Get cluster detail error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -# ============================================================================= -# Capabilities & Coverage Endpoints -# ============================================================================= - -@tscm_bp.route('/capabilities') -def get_capabilities(): - """ - Get current system capabilities for TSCM sweeping. - - Returns what the system CAN and CANNOT detect based on OS, - privileges, adapters, and SDR hardware. - """ - try: - from utils.tscm.advanced import detect_sweep_capabilities - - wifi_interface = request.args.get('wifi_interface', '') - bt_adapter = request.args.get('bt_adapter', '') - - caps = detect_sweep_capabilities( - wifi_interface=wifi_interface, - bt_adapter=bt_adapter - ) - - return jsonify({ - 'status': 'success', - 'capabilities': caps.to_dict() - }) - - except Exception as e: - logger.error(f"Get capabilities error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -@tscm_bp.route('/sweep//capabilities') -def get_sweep_stored_capabilities(sweep_id: int): - """Get stored capabilities for a specific sweep.""" - from utils.database import get_sweep_capabilities - - caps = get_sweep_capabilities(sweep_id) - if not caps: - return jsonify({'status': 'error', 'message': 'No capabilities stored for this sweep'}), 404 - - return jsonify({ - 'status': 'success', - 'capabilities': caps - }) - - -# ============================================================================= -# Baseline Diff & Health Endpoints -# ============================================================================= - -@tscm_bp.route('/baseline/diff//') -def get_baseline_diff(baseline_id: int, sweep_id: int): - """ - Get comprehensive diff between a baseline and a sweep. - - Shows new devices, missing devices, changed characteristics, - and baseline health assessment. - """ - try: - from utils.tscm.advanced import calculate_baseline_diff - - baseline = get_tscm_baseline(baseline_id) - if not baseline: - return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404 - - sweep = get_tscm_sweep(sweep_id) - if not sweep: - return jsonify({'status': 'error', 'message': 'Sweep not found'}), 404 - - # Get current devices from sweep results - results = sweep.get('results', {}) - if isinstance(results, str): - import json - results = json.loads(results) - - current_wifi = results.get('wifi_devices', []) - current_bt = results.get('bt_devices', []) - current_rf = results.get('rf_signals', []) - - diff = calculate_baseline_diff( - baseline=baseline, - current_wifi=current_wifi, - current_bt=current_bt, - current_rf=current_rf, - sweep_id=sweep_id - ) - - return jsonify({ - 'status': 'success', - 'diff': diff.to_dict() - }) - - except Exception as e: - logger.error(f"Get baseline diff error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -@tscm_bp.route('/baseline//health') -def get_baseline_health(baseline_id: int): - """Get health assessment for a baseline.""" - try: - from utils.tscm.advanced import BaselineHealth - from datetime import datetime - - baseline = get_tscm_baseline(baseline_id) - if not baseline: - return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404 - - # Calculate age - created_at = baseline.get('created_at') - age_hours = 0 - if created_at: - if isinstance(created_at, str): - created = datetime.fromisoformat(created_at.replace('Z', '+00:00')) - age_hours = (datetime.now() - created.replace(tzinfo=None)).total_seconds() / 3600 - elif isinstance(created_at, datetime): - age_hours = (datetime.now() - created_at).total_seconds() / 3600 - - # Count devices - total_devices = ( - len(baseline.get('wifi_networks', [])) + - len(baseline.get('bt_devices', [])) + - len(baseline.get('rf_frequencies', [])) - ) - - # Determine health - health = 'healthy' - score = 1.0 - reasons = [] - - if age_hours > 168: - health = 'stale' - score = 0.3 - reasons.append(f'Baseline is {age_hours:.0f} hours old (over 1 week)') - elif age_hours > 72: - health = 'noisy' - score = 0.6 - reasons.append(f'Baseline is {age_hours:.0f} hours old (over 3 days)') - - if total_devices < 3: - score -= 0.2 - reasons.append(f'Baseline has few devices ({total_devices})') - if health == 'healthy': - health = 'noisy' - - return jsonify({ - 'status': 'success', - 'health': { - 'status': health, - 'score': round(max(0, score), 2), - 'age_hours': round(age_hours, 1), - 'total_devices': total_devices, - 'reasons': reasons, - } - }) - - except Exception as e: - logger.error(f"Get baseline health error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -# ============================================================================= -# Device Timeline Endpoints -# ============================================================================= - -@tscm_bp.route('/device//timeline') -def get_device_timeline_endpoint(identifier: str): - """ - Get timeline of observations for a device. - - Shows behavior over time including RSSI stability, presence, - and meeting window correlation. - """ - try: - from utils.tscm.advanced import get_timeline_manager - from utils.database import get_device_timeline - - protocol = request.args.get('protocol', 'bluetooth') - since_hours = request.args.get('since_hours', 24, type=int) - - # Try in-memory timeline first - manager = get_timeline_manager() - timeline = manager.get_timeline(identifier, protocol) - - # Also get stored timeline from database - stored = get_device_timeline(identifier, since_hours=since_hours) - - result = { - 'identifier': identifier, - 'protocol': protocol, - 'observations': stored, - } - - if timeline: - result['metrics'] = { - 'first_seen': timeline.first_seen.isoformat() if timeline.first_seen else None, - 'last_seen': timeline.last_seen.isoformat() if timeline.last_seen else None, - 'total_observations': timeline.total_observations, - 'presence_ratio': round(timeline.presence_ratio, 2), - } - result['signal'] = { - 'rssi_min': timeline.rssi_min, - 'rssi_max': timeline.rssi_max, - 'rssi_mean': round(timeline.rssi_mean, 1) if timeline.rssi_mean else None, - 'stability': round(timeline.rssi_stability, 2), - } - result['movement'] = { - 'appears_stationary': timeline.appears_stationary, - 'pattern': timeline.movement_pattern, - } - result['meeting_correlation'] = { - 'correlated': timeline.meeting_correlated, - 'observations_during_meeting': timeline.meeting_observations, - } - - return jsonify({ - 'status': 'success', - 'timeline': result - }) - - except Exception as e: - logger.error(f"Get device timeline error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -@tscm_bp.route('/timelines') -def get_all_device_timelines(): - """Get all device timelines.""" - try: - from utils.tscm.advanced import get_timeline_manager - - manager = get_timeline_manager() - timelines = manager.get_all_timelines() - - return jsonify({ - 'status': 'success', - 'count': len(timelines), - 'timelines': [t.to_dict() for t in timelines] - }) - - except Exception as e: - logger.error(f"Get all timelines error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -# ============================================================================= -# Known-Good Registry (Whitelist) Endpoints -# ============================================================================= - -@tscm_bp.route('/known-devices', methods=['GET']) -def list_known_devices(): - """List all known-good devices.""" - from utils.database import get_all_known_devices - - location = request.args.get('location') - scope = request.args.get('scope') - - devices = get_all_known_devices(location=location, scope=scope) - - return jsonify({ - 'status': 'success', - 'count': len(devices), - 'devices': devices - }) - - -@tscm_bp.route('/known-devices', methods=['POST']) -def add_known_device_endpoint(): - """ - Add a device to the known-good registry. - - Known devices remain visible but receive reduced risk scores. - They are NOT suppressed from reports (preserves audit trail). - """ - from utils.database import add_known_device - - data = request.get_json() or {} - - identifier = data.get('identifier') - protocol = data.get('protocol') - - if not identifier or not protocol: - return jsonify({ - 'status': 'error', - 'message': 'identifier and protocol are required' - }), 400 - - device_id = add_known_device( - identifier=identifier, - protocol=protocol, - name=data.get('name'), - description=data.get('description'), - location=data.get('location'), - scope=data.get('scope', 'global'), - added_by=data.get('added_by'), - score_modifier=data.get('score_modifier', -2), - metadata=data.get('metadata') - ) - - return jsonify({ - 'status': 'success', - 'message': 'Device added to known-good registry', - 'device_id': device_id - }) - - -@tscm_bp.route('/known-devices/', methods=['GET']) -def get_known_device_endpoint(identifier: str): - """Get a known device by identifier.""" - from utils.database import get_known_device - - device = get_known_device(identifier) - if not device: - return jsonify({'status': 'error', 'message': 'Device not found'}), 404 - - return jsonify({ - 'status': 'success', - 'device': device - }) - - -@tscm_bp.route('/known-devices/', methods=['DELETE']) -def delete_known_device_endpoint(identifier: str): - """Remove a device from the known-good registry.""" - from utils.database import delete_known_device - - success = delete_known_device(identifier) - if not success: - return jsonify({'status': 'error', 'message': 'Device not found'}), 404 - - return jsonify({ - 'status': 'success', - 'message': 'Device removed from known-good registry' - }) - - -@tscm_bp.route('/known-devices/check/') -def check_known_device(identifier: str): - """Check if a device is in the known-good registry.""" - from utils.database import is_known_good_device - - location = request.args.get('location') - result = is_known_good_device(identifier, location=location) - - return jsonify({ - 'status': 'success', - 'is_known': result is not None, - 'details': result - }) - - -# ============================================================================= -# Case Management Endpoints -# ============================================================================= - -@tscm_bp.route('/cases', methods=['GET']) -def list_cases(): - """List all TSCM cases.""" - from utils.database import get_all_tscm_cases - - status = request.args.get('status') - limit = request.args.get('limit', 50, type=int) - - cases = get_all_tscm_cases(status=status, limit=limit) - - return jsonify({ - 'status': 'success', - 'count': len(cases), - 'cases': cases - }) - - -@tscm_bp.route('/cases', methods=['POST']) -def create_case(): - """Create a new TSCM case.""" - from utils.database import create_tscm_case - - data = request.get_json() or {} - - name = data.get('name') - if not name: - return jsonify({'status': 'error', 'message': 'name is required'}), 400 - - case_id = create_tscm_case( - name=name, - description=data.get('description'), - location=data.get('location'), - priority=data.get('priority', 'normal'), - created_by=data.get('created_by'), - metadata=data.get('metadata') - ) - - return jsonify({ - 'status': 'success', - 'message': 'Case created', - 'case_id': case_id - }) - - -@tscm_bp.route('/cases/', methods=['GET']) -def get_case(case_id: int): - """Get a TSCM case with all linked sweeps, threats, and notes.""" - from utils.database import get_tscm_case - - case = get_tscm_case(case_id) - if not case: - return jsonify({'status': 'error', 'message': 'Case not found'}), 404 - - return jsonify({ - 'status': 'success', - 'case': case - }) - - -@tscm_bp.route('/cases/', methods=['PUT']) -def update_case(case_id: int): - """Update a TSCM case.""" - from utils.database import update_tscm_case - - data = request.get_json() or {} - - success = update_tscm_case( - case_id=case_id, - status=data.get('status'), - priority=data.get('priority'), - assigned_to=data.get('assigned_to'), - notes=data.get('notes') - ) - - if not success: - return jsonify({'status': 'error', 'message': 'Case not found'}), 404 - - return jsonify({ - 'status': 'success', - 'message': 'Case updated' - }) - - -@tscm_bp.route('/cases//sweeps/', methods=['POST']) -def link_sweep_to_case(case_id: int, sweep_id: int): - """Link a sweep to a case.""" - from utils.database import add_sweep_to_case - - success = add_sweep_to_case(case_id, sweep_id) - - return jsonify({ - 'status': 'success' if success else 'error', - 'message': 'Sweep linked to case' if success else 'Already linked or not found' - }) - - -@tscm_bp.route('/cases//threats/', methods=['POST']) -def link_threat_to_case(case_id: int, threat_id: int): - """Link a threat to a case.""" - from utils.database import add_threat_to_case - - success = add_threat_to_case(case_id, threat_id) - - return jsonify({ - 'status': 'success' if success else 'error', - 'message': 'Threat linked to case' if success else 'Already linked or not found' - }) - - -@tscm_bp.route('/cases//notes', methods=['POST']) -def add_note_to_case(case_id: int): - """Add a note to a case.""" - from utils.database import add_case_note - - data = request.get_json() or {} - - content = data.get('content') - if not content: - return jsonify({'status': 'error', 'message': 'content is required'}), 400 - - note_id = add_case_note( - case_id=case_id, - content=content, - note_type=data.get('note_type', 'general'), - created_by=data.get('created_by') - ) - - return jsonify({ - 'status': 'success', - 'message': 'Note added', - 'note_id': note_id - }) - - -# ============================================================================= -# Meeting Window Enhanced Endpoints -# ============================================================================= - -@tscm_bp.route('/meeting/start-tracked', methods=['POST']) -def start_tracked_meeting(): - """ - Start a tracked meeting window with database persistence. - - Tracks devices first seen during meeting and behavior changes. - """ - from utils.database import start_meeting_window - from utils.tscm.advanced import get_timeline_manager - - data = request.get_json() or {} - - meeting_id = start_meeting_window( - sweep_id=_current_sweep_id, - name=data.get('name'), - location=data.get('location'), - notes=data.get('notes') - ) - - # Start meeting in correlation engine - correlation = get_correlation_engine() - correlation.start_meeting_window() - - # Start in timeline manager - manager = get_timeline_manager() - manager.start_meeting_window() - - _emit_event('meeting_started', { - 'meeting_id': meeting_id, - 'timestamp': datetime.now().isoformat(), - 'name': data.get('name'), - }) - - return jsonify({ - 'status': 'success', - 'message': 'Tracked meeting window started', - 'meeting_id': meeting_id - }) - - -@tscm_bp.route('/meeting//end', methods=['POST']) -def end_tracked_meeting(meeting_id: int): - """End a tracked meeting window.""" - from utils.database import end_meeting_window - from utils.tscm.advanced import get_timeline_manager - - success = end_meeting_window(meeting_id) - if not success: - return jsonify({'status': 'error', 'message': 'Meeting not found or already ended'}), 404 - - # End in correlation engine - correlation = get_correlation_engine() - correlation.end_meeting_window() - - # End in timeline manager - manager = get_timeline_manager() - manager.end_meeting_window() - - _emit_event('meeting_ended', { - 'meeting_id': meeting_id, - 'timestamp': datetime.now().isoformat() - }) - - return jsonify({ - 'status': 'success', - 'message': 'Meeting window ended' - }) - - -@tscm_bp.route('/meeting//summary') -def get_meeting_summary_endpoint(meeting_id: int): - """Get detailed summary of device activity during a meeting.""" - try: - from utils.database import get_meeting_windows - from utils.tscm.advanced import generate_meeting_summary, get_timeline_manager - - # Get meeting window - windows = get_meeting_windows(_current_sweep_id or 0) - meeting = None - for w in windows: - if w.get('id') == meeting_id: - meeting = w - break - - if not meeting: - return jsonify({'status': 'error', 'message': 'Meeting not found'}), 404 - - # Get timelines and profiles - manager = get_timeline_manager() - timelines = manager.get_all_timelines() - - correlation = get_correlation_engine() - profiles = [p.to_dict() for p in correlation.device_profiles.values()] - - summary = generate_meeting_summary(meeting, timelines, profiles) - - return jsonify({ - 'status': 'success', - 'summary': summary.to_dict() - }) - - except Exception as e: - logger.error(f"Get meeting summary error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -@tscm_bp.route('/meeting/active') -def get_active_meeting(): - """Get currently active meeting window.""" - from utils.database import get_active_meeting_window - - meeting = get_active_meeting_window(_current_sweep_id) - - return jsonify({ - 'status': 'success', - 'meeting': meeting, - 'is_active': meeting is not None - }) - - -# ============================================================================= -# PDF Report & Technical Annex Endpoints -# ============================================================================= - -@tscm_bp.route('/report/pdf') -def get_pdf_report(): - """ - Generate client-safe PDF report. - - Contains executive summary, findings by risk tier, meeting window - summary, and mandatory disclaimers. - """ - try: - from utils.tscm.reports import generate_report, get_pdf_report - from utils.tscm.advanced import detect_sweep_capabilities, get_timeline_manager - - sweep_id = request.args.get('sweep_id', _current_sweep_id, type=int) - if not sweep_id: - return jsonify({'status': 'error', 'message': 'No sweep specified'}), 400 - - sweep = get_tscm_sweep(sweep_id) - if not sweep: - return jsonify({'status': 'error', 'message': 'Sweep not found'}), 404 - - # Get data for report - correlation = get_correlation_engine() - profiles = [p.to_dict() for p in correlation.device_profiles.values()] - caps = detect_sweep_capabilities().to_dict() - - manager = get_timeline_manager() - timelines = [t.to_dict() for t in manager.get_all_timelines()] - - # Generate report - report = generate_report( - sweep_id=sweep_id, - sweep_data=sweep, - device_profiles=profiles, - capabilities=caps, - timelines=timelines - ) - - pdf_content = get_pdf_report(report) - - return Response( - pdf_content, - mimetype='text/plain', - headers={ - 'Content-Disposition': f'attachment; filename=tscm_report_{sweep_id}.txt' - } - ) - - except Exception as e: - logger.error(f"Generate PDF report error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -@tscm_bp.route('/report/annex') -def get_technical_annex(): - """ - Generate technical annex (JSON + CSV). - - Contains device timelines, all indicators, and detailed data - for audit purposes. No packet data included. - """ - try: - from utils.tscm.reports import generate_report, get_json_annex, get_csv_annex - from utils.tscm.advanced import detect_sweep_capabilities, get_timeline_manager - - sweep_id = request.args.get('sweep_id', _current_sweep_id, type=int) - format_type = request.args.get('format', 'json') - - if not sweep_id: - return jsonify({'status': 'error', 'message': 'No sweep specified'}), 400 - - sweep = get_tscm_sweep(sweep_id) - if not sweep: - return jsonify({'status': 'error', 'message': 'Sweep not found'}), 404 - - # Get data for report - correlation = get_correlation_engine() - profiles = [p.to_dict() for p in correlation.device_profiles.values()] - caps = detect_sweep_capabilities().to_dict() - - manager = get_timeline_manager() - timelines = [t.to_dict() for t in manager.get_all_timelines()] - - # Generate report - report = generate_report( - sweep_id=sweep_id, - sweep_data=sweep, - device_profiles=profiles, - capabilities=caps, - timelines=timelines - ) - - if format_type == 'csv': - csv_content = get_csv_annex(report) - return Response( - csv_content, - mimetype='text/csv', - headers={ - 'Content-Disposition': f'attachment; filename=tscm_annex_{sweep_id}.csv' - } - ) - else: - annex = get_json_annex(report) - return jsonify({ - 'status': 'success', - 'annex': annex - }) - - except Exception as e: - logger.error(f"Generate technical annex error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -# ============================================================================= -# WiFi Advanced Indicators Endpoints -# ============================================================================= - -@tscm_bp.route('/wifi/advanced-indicators') -def get_wifi_advanced_indicators(): - """ - Get advanced WiFi indicators (Evil Twin, Probes, Deauth). - - These indicators require analysis of WiFi patterns. - Some features require monitor mode. - """ - try: - from utils.tscm.advanced import get_wifi_detector - - detector = get_wifi_detector() - - return jsonify({ - 'status': 'success', - 'indicators': detector.get_all_indicators(), - 'unavailable_features': detector.get_unavailable_features(), - 'disclaimer': ( - "All indicators represent pattern detections, NOT confirmed attacks. " - "Further investigation is required." - ) - }) - - except Exception as e: - logger.error(f"Get WiFi indicators error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -@tscm_bp.route('/wifi/analyze-network', methods=['POST']) -def analyze_wifi_network(): - """ - Analyze a WiFi network for evil twin patterns. - - Compares against known networks to detect SSID spoofing. - """ - try: - from utils.tscm.advanced import get_wifi_detector - - data = request.get_json() or {} - detector = get_wifi_detector() - - # Set known networks from baseline if available - baseline = get_active_tscm_baseline() - if baseline: - detector.set_known_networks(baseline.get('wifi_networks', [])) - - indicators = detector.analyze_network(data) - - return jsonify({ - 'status': 'success', - 'indicators': [i.to_dict() for i in indicators] - }) - - except Exception as e: - logger.error(f"Analyze WiFi network error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -# ============================================================================= -# Bluetooth Risk Explainability Endpoints -# ============================================================================= - -@tscm_bp.route('/bluetooth//explain') -def explain_bluetooth_risk(identifier: str): - """ - Get human-readable risk explanation for a BLE device. - - Includes proximity estimate, tracker explanation, and - recommended actions. - """ - try: - from utils.tscm.advanced import generate_ble_risk_explanation - - # Get device from correlation engine - correlation = get_correlation_engine() - profile = None - key = f"bluetooth:{identifier.upper()}" - if key in correlation.device_profiles: - profile = correlation.device_profiles[key].to_dict() - - # Try to find device info - device = {'mac': identifier} - if profile: - device['name'] = profile.get('name') - device['rssi'] = profile.get('rssi_samples', [None])[-1] if profile.get('rssi_samples') else None - - # Check meeting status - is_meeting = correlation.is_during_meeting() - - explanation = generate_ble_risk_explanation(device, profile, is_meeting) - - return jsonify({ - 'status': 'success', - 'explanation': explanation.to_dict() - }) - - except Exception as e: - logger.error(f"Explain BLE risk error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -@tscm_bp.route('/bluetooth//proximity') -def get_bluetooth_proximity(identifier: str): - """Get proximity estimate for a BLE device.""" - try: - from utils.tscm.advanced import estimate_ble_proximity - - rssi = request.args.get('rssi', type=int) - if rssi is None: - # Try to get from correlation engine - correlation = get_correlation_engine() - key = f"bluetooth:{identifier.upper()}" - if key in correlation.device_profiles: - profile = correlation.device_profiles[key] - if profile.rssi_samples: - rssi = profile.rssi_samples[-1] - - if rssi is None: - return jsonify({ - 'status': 'error', - 'message': 'RSSI value required' - }), 400 - - proximity, explanation, distance = estimate_ble_proximity(rssi) - - return jsonify({ - 'status': 'success', - 'proximity': { - 'estimate': proximity.value, - 'explanation': explanation, - 'estimated_distance': distance, - 'rssi_used': rssi, - }, - 'disclaimer': ( - "Proximity estimates are approximate and affected by " - "environment, obstacles, and device characteristics." - ) - }) - - except Exception as e: - logger.error(f"Get BLE proximity error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -# ============================================================================= -# Operator Playbook Endpoints -# ============================================================================= - -@tscm_bp.route('/playbooks') -def list_playbooks(): - """List all available operator playbooks.""" - try: - from utils.tscm.advanced import PLAYBOOKS - - # Return as array with id field for JavaScript compatibility - playbooks_list = [] - for pid, pb in PLAYBOOKS.items(): - pb_dict = pb.to_dict() - pb_dict['id'] = pid - pb_dict['name'] = pb_dict.get('title', pid) - pb_dict['category'] = pb_dict.get('risk_level', 'general') - playbooks_list.append(pb_dict) - - return jsonify({ - 'status': 'success', - 'playbooks': playbooks_list - }) - - except Exception as e: - logger.error(f"List playbooks error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -@tscm_bp.route('/playbooks/') -def get_playbook(playbook_id: str): - """Get a specific playbook.""" - try: - from utils.tscm.advanced import PLAYBOOKS - - if playbook_id not in PLAYBOOKS: - return jsonify({'status': 'error', 'message': 'Playbook not found'}), 404 - - return jsonify({ - 'status': 'success', - 'playbook': PLAYBOOKS[playbook_id].to_dict() - }) - - except Exception as e: - logger.error(f"Get playbook error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -@tscm_bp.route('/findings//playbook') -def get_finding_playbook(identifier: str): - """Get recommended playbook for a specific finding.""" - try: - from utils.tscm.advanced import get_playbook_for_finding - - # Get profile - correlation = get_correlation_engine() - profile = None - - for protocol in ['bluetooth', 'wifi', 'rf']: - key = f"{protocol}:{identifier.upper()}" - if key in correlation.device_profiles: - profile = correlation.device_profiles[key].to_dict() - break - - if not profile: - return jsonify({'status': 'error', 'message': 'Finding not found'}), 404 - - playbook = get_playbook_for_finding( - risk_level=profile.get('risk_level', 'informational'), - indicators=profile.get('indicators', []) - ) - - return jsonify({ - 'status': 'success', - 'playbook': playbook.to_dict(), - 'suggested_next_steps': [ - f"Step {s.step_number}: {s.action}" - for s in playbook.steps[:3] - ] - }) - - except Exception as e: - logger.error(f"Get finding playbook error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 +""" +TSCM (Technical Surveillance Countermeasures) Routes + +Provides endpoints for counter-surveillance sweeps, baseline management, +threat detection, and reporting. +""" + +from __future__ import annotations + +import json +import logging +import queue +import threading +import time +from datetime import datetime, timedelta, timezone +from typing import Any + +from flask import Blueprint, Response, jsonify, request + +from data.tscm_frequencies import ( + SWEEP_PRESETS, + get_all_sweep_presets, + get_sweep_preset, +) +from utils.database import ( + add_device_timeline_entry, + add_tscm_threat, + acknowledge_tscm_threat, + cleanup_old_timeline_entries, + create_tscm_schedule, + create_tscm_sweep, + delete_tscm_baseline, + delete_tscm_schedule, + get_active_tscm_baseline, + get_all_tscm_baselines, + get_all_tscm_schedules, + get_tscm_baseline, + get_tscm_schedule, + get_tscm_sweep, + get_tscm_threat_summary, + get_tscm_threats, + set_active_tscm_baseline, + update_tscm_schedule, + update_tscm_sweep, +) +from utils.tscm.baseline import ( + BaselineComparator, + BaselineRecorder, + get_comparison_for_active_baseline, +) +from utils.tscm.correlation import ( + CorrelationEngine, + get_correlation_engine, + reset_correlation_engine, +) +from utils.tscm.detector import ThreatDetector +from utils.tscm.device_identity import ( + get_identity_engine, + reset_identity_engine, + ingest_ble_dict, + ingest_wifi_dict, +) + +# Import unified Bluetooth scanner helper for TSCM integration +try: + from routes.bluetooth_v2 import get_tscm_bluetooth_snapshot + _USE_UNIFIED_BT_SCANNER = True +except ImportError: + _USE_UNIFIED_BT_SCANNER = False + +logger = logging.getLogger('intercept.tscm') + +tscm_bp = Blueprint('tscm', __name__, url_prefix='/tscm') + +try: + from zoneinfo import ZoneInfo +except ImportError: # pragma: no cover - fallback for older Python + ZoneInfo = None + +# ============================================================================= +# Global State (will be initialized from app.py) +# ============================================================================= + +# These will be set by app.py +tscm_queue: queue.Queue | None = None +tscm_lock: threading.Lock | None = None + +# Local state +_sweep_thread: threading.Thread | None = None +_sweep_running = False +_current_sweep_id: int | None = None +_baseline_recorder = BaselineRecorder() +_schedule_thread: threading.Thread | None = None +_schedule_running = False + + +def init_tscm_state(tscm_q: queue.Queue, lock: threading.Lock) -> None: + """Initialize TSCM state from app.py.""" + global tscm_queue, tscm_lock + tscm_queue = tscm_q + tscm_lock = lock + start_tscm_scheduler() + + +def _emit_event(event_type: str, data: dict) -> None: + """Emit an event to the SSE queue.""" + if tscm_queue: + try: + tscm_queue.put_nowait({ + 'type': event_type, + 'timestamp': datetime.now().isoformat(), + **data + }) + except queue.Full: + logger.warning("TSCM queue full, dropping event") + + +# ============================================================================= +# Schedule Helpers +# ============================================================================= + +def _get_schedule_timezone(zone_name: str | None) -> Any: + """Resolve schedule timezone from a zone name or fallback to local.""" + if zone_name and ZoneInfo: + try: + return ZoneInfo(zone_name) + except Exception: + logger.warning(f"Invalid timezone '{zone_name}', using local time") + return datetime.now().astimezone().tzinfo or timezone.utc + + +def _parse_cron_field(field: str, min_value: int, max_value: int) -> set[int]: + """Parse a single cron field into a set of valid integers.""" + field = field.strip() + if not field: + raise ValueError("Empty cron field") + + values: set[int] = set() + parts = field.split(',') + for part in parts: + part = part.strip() + if part == '*': + values.update(range(min_value, max_value + 1)) + continue + if part.startswith('*/'): + step = int(part[2:]) + if step <= 0: + raise ValueError("Invalid step value") + values.update(range(min_value, max_value + 1, step)) + continue + range_part = part + step = 1 + if '/' in part: + range_part, step_str = part.split('/', 1) + step = int(step_str) + if step <= 0: + raise ValueError("Invalid step value") + if '-' in range_part: + start_str, end_str = range_part.split('-', 1) + start = int(start_str) + end = int(end_str) + if start > end: + start, end = end, start + values.update(range(start, end + 1, step)) + else: + values.add(int(range_part)) + + return {v for v in values if min_value <= v <= max_value} + + +def _parse_cron_expression(expr: str) -> tuple[dict[str, set[int]], dict[str, bool]]: + """Parse a cron expression into value sets and wildcard flags.""" + fields = (expr or '').split() + if len(fields) != 5: + raise ValueError("Cron expression must have 5 fields") + + minute_field, hour_field, dom_field, month_field, dow_field = fields + + sets = { + 'minute': _parse_cron_field(minute_field, 0, 59), + 'hour': _parse_cron_field(hour_field, 0, 23), + 'dom': _parse_cron_field(dom_field, 1, 31), + 'month': _parse_cron_field(month_field, 1, 12), + 'dow': _parse_cron_field(dow_field, 0, 7), + } + + # Normalize Sunday (7 -> 0) + if 7 in sets['dow']: + sets['dow'].add(0) + sets['dow'].discard(7) + + wildcards = { + 'dom': dom_field.strip() == '*', + 'dow': dow_field.strip() == '*', + } + return sets, wildcards + + +def _cron_matches(dt: datetime, sets: dict[str, set[int]], wildcards: dict[str, bool]) -> bool: + """Check if a datetime matches cron sets.""" + if dt.minute not in sets['minute']: + return False + if dt.hour not in sets['hour']: + return False + if dt.month not in sets['month']: + return False + + dom_match = dt.day in sets['dom'] + # Cron DOW: Sunday=0 + cron_dow = (dt.weekday() + 1) % 7 + dow_match = cron_dow in sets['dow'] + + if wildcards['dom'] and wildcards['dow']: + return True + if wildcards['dom']: + return dow_match + if wildcards['dow']: + return dom_match + return dom_match or dow_match + + +def _next_run_from_cron(expr: str, after_dt: datetime) -> datetime | None: + """Calculate next run time from cron expression after a given datetime.""" + sets, wildcards = _parse_cron_expression(expr) + # Round to next minute + candidate = after_dt.replace(second=0, microsecond=0) + timedelta(minutes=1) + # Search up to 366 days ahead + for _ in range(366 * 24 * 60): + if _cron_matches(candidate, sets, wildcards): + return candidate + candidate += timedelta(minutes=1) + return None + + +def _parse_schedule_timestamp(value: Any) -> datetime | None: + """Parse stored schedule timestamp to aware datetime.""" + if not value: + return None + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + try: + parsed = datetime.fromisoformat(str(value)) + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + except Exception: + return None + + +def _schedule_loop() -> None: + """Background loop to trigger scheduled sweeps.""" + global _schedule_running + + while _schedule_running: + try: + schedules = get_all_tscm_schedules(enabled=True, limit=200) + now_utc = datetime.now(timezone.utc) + + for schedule in schedules: + schedule_id = schedule.get('id') + cron_expr = schedule.get('cron_expression') or '' + tz = _get_schedule_timezone(schedule.get('zone_name')) + now_local = datetime.now(tz) + + next_run = _parse_schedule_timestamp(schedule.get('next_run')) + + if not next_run: + try: + computed = _next_run_from_cron(cron_expr, now_local) + except Exception as e: + logger.error(f"Schedule {schedule_id} cron parse error: {e}") + continue + if computed: + update_tscm_schedule( + schedule_id, + next_run=computed.astimezone(timezone.utc).isoformat() + ) + continue + + if next_run <= now_utc: + if _sweep_running: + logger.info(f"Schedule {schedule_id} due but sweep running; skipping") + try: + computed = _next_run_from_cron(cron_expr, now_local) + except Exception as e: + logger.error(f"Schedule {schedule_id} cron parse error: {e}") + continue + if computed: + update_tscm_schedule( + schedule_id, + next_run=computed.astimezone(timezone.utc).isoformat() + ) + continue + + # Trigger sweep + result = _start_sweep_internal( + sweep_type=schedule.get('sweep_type') or 'standard', + baseline_id=schedule.get('baseline_id'), + wifi_enabled=True, + bt_enabled=True, + rf_enabled=True, + wifi_interface='', + bt_interface='', + sdr_device=None, + verbose_results=False + ) + + if result.get('status') == 'success': + try: + computed = _next_run_from_cron(cron_expr, now_local) + except Exception as e: + logger.error(f"Schedule {schedule_id} cron parse error: {e}") + computed = None + + update_tscm_schedule( + schedule_id, + last_run=now_utc.isoformat(), + next_run=computed.astimezone(timezone.utc).isoformat() if computed else None + ) + logger.info(f"Scheduled sweep started for schedule {schedule_id}") + else: + try: + computed = _next_run_from_cron(cron_expr, now_local) + except Exception as e: + logger.error(f"Schedule {schedule_id} cron parse error: {e}") + computed = None + if computed: + update_tscm_schedule( + schedule_id, + next_run=computed.astimezone(timezone.utc).isoformat() + ) + logger.warning(f"Scheduled sweep failed for schedule {schedule_id}: {result.get('message')}") + + except Exception as e: + logger.error(f"TSCM schedule loop error: {e}") + + time.sleep(30) + + +def start_tscm_scheduler() -> None: + """Start background scheduler thread for TSCM sweeps.""" + global _schedule_thread, _schedule_running + if _schedule_thread and _schedule_thread.is_alive(): + return + _schedule_running = True + _schedule_thread = threading.Thread(target=_schedule_loop, daemon=True) + _schedule_thread.start() + + +# ============================================================================= +# Sweep Endpoints +# ============================================================================= + +def _check_available_devices(wifi: bool, bt: bool, rf: bool) -> dict: + """Check which scanning devices are available.""" + import os + import platform + import shutil + import subprocess + + available = { + 'wifi': False, + 'bluetooth': False, + 'rf': False, + 'wifi_reason': 'Not checked', + 'bt_reason': 'Not checked', + 'rf_reason': 'Not checked', + } + + # Check WiFi + if wifi: + if platform.system() == 'Darwin': + # macOS: Check for airport utility + airport_path = '/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport' + if os.path.exists(airport_path): + try: + result = subprocess.run( + [airport_path, '-I'], + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode == 0: + available['wifi'] = True + available['wifi_reason'] = 'macOS WiFi available' + else: + available['wifi_reason'] = 'WiFi interface not active' + except (subprocess.TimeoutExpired, subprocess.SubprocessError): + available['wifi_reason'] = 'Cannot access WiFi interface' + else: + available['wifi_reason'] = 'macOS airport utility not found' + else: + # Linux: Check for wireless tools + if shutil.which('airodump-ng') or shutil.which('iwlist') or shutil.which('iw'): + try: + result = subprocess.run( + ['iwconfig'], + capture_output=True, + text=True, + timeout=5 + ) + if 'no wireless extensions' not in result.stderr.lower() and result.stdout.strip(): + available['wifi'] = True + available['wifi_reason'] = 'Wireless interface detected' + else: + available['wifi_reason'] = 'No wireless interfaces found' + except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError): + # Try iw as fallback + try: + result = subprocess.run( + ['iw', 'dev'], + capture_output=True, + text=True, + timeout=5 + ) + if 'Interface' in result.stdout: + available['wifi'] = True + available['wifi_reason'] = 'Wireless interface detected' + else: + # Check /sys/class/net for wireless interfaces + try: + import glob + wireless_devs = glob.glob('/sys/class/net/*/wireless') + if wireless_devs: + available['wifi'] = True + available['wifi_reason'] = 'Wireless interface detected' + else: + available['wifi_reason'] = 'No wireless interfaces found' + except Exception: + available['wifi_reason'] = 'No wireless interfaces found' + except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError): + # Last resort: check /sys/class/net + try: + import glob + wireless_devs = glob.glob('/sys/class/net/*/wireless') + if wireless_devs: + available['wifi'] = True + available['wifi_reason'] = 'Wireless interface detected' + else: + available['wifi_reason'] = 'Cannot detect wireless interfaces' + except Exception: + available['wifi_reason'] = 'Cannot detect wireless interfaces' + else: + # Fallback: check /sys/class/net even without tools + try: + import glob + wireless_devs = glob.glob('/sys/class/net/*/wireless') + if wireless_devs: + available['wifi'] = True + available['wifi_reason'] = 'Wireless interface detected (no scan tools)' + else: + available['wifi_reason'] = 'WiFi tools not installed (wireless-tools)' + except Exception: + available['wifi_reason'] = 'WiFi tools not installed (wireless-tools)' + + # Check Bluetooth + if bt: + if platform.system() == 'Darwin': + # macOS: Check for Bluetooth via system_profiler + try: + result = subprocess.run( + ['system_profiler', 'SPBluetoothDataType'], + capture_output=True, + text=True, + timeout=10 + ) + if 'Bluetooth' in result.stdout and result.returncode == 0: + available['bluetooth'] = True + available['bt_reason'] = 'macOS Bluetooth available' + else: + available['bt_reason'] = 'Bluetooth not available' + except (subprocess.TimeoutExpired, FileNotFoundError): + available['bt_reason'] = 'Cannot detect Bluetooth' + else: + # Linux: Check for Bluetooth tools + if shutil.which('bluetoothctl') or shutil.which('hcitool') or shutil.which('hciconfig'): + try: + result = subprocess.run( + ['hciconfig'], + capture_output=True, + text=True, + timeout=5 + ) + if 'hci' in result.stdout.lower(): + available['bluetooth'] = True + available['bt_reason'] = 'Bluetooth adapter detected' + else: + available['bt_reason'] = 'No Bluetooth adapters found' + except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError): + # Try bluetoothctl as fallback + try: + result = subprocess.run( + ['bluetoothctl', 'list'], + capture_output=True, + text=True, + timeout=5 + ) + if result.stdout.strip(): + available['bluetooth'] = True + available['bt_reason'] = 'Bluetooth adapter detected' + else: + # Check /sys for Bluetooth + try: + import glob + bt_devs = glob.glob('/sys/class/bluetooth/hci*') + if bt_devs: + available['bluetooth'] = True + available['bt_reason'] = 'Bluetooth adapter detected' + else: + available['bt_reason'] = 'No Bluetooth adapters found' + except Exception: + available['bt_reason'] = 'No Bluetooth adapters found' + except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError): + # Check /sys for Bluetooth + try: + import glob + bt_devs = glob.glob('/sys/class/bluetooth/hci*') + if bt_devs: + available['bluetooth'] = True + available['bt_reason'] = 'Bluetooth adapter detected' + else: + available['bt_reason'] = 'Cannot detect Bluetooth adapters' + except Exception: + available['bt_reason'] = 'Cannot detect Bluetooth adapters' + else: + # Fallback: check /sys even without tools + try: + import glob + bt_devs = glob.glob('/sys/class/bluetooth/hci*') + if bt_devs: + available['bluetooth'] = True + available['bt_reason'] = 'Bluetooth adapter detected (no scan tools)' + else: + available['bt_reason'] = 'Bluetooth tools not installed (bluez)' + except Exception: + available['bt_reason'] = 'Bluetooth tools not installed (bluez)' + + # Check RF/SDR + if rf: + try: + from utils.sdr import SDRFactory + devices = SDRFactory.detect_devices() + if devices: + available['rf'] = True + available['rf_reason'] = f'{len(devices)} SDR device(s) detected' + else: + available['rf_reason'] = 'No SDR devices found' + except ImportError: + available['rf_reason'] = 'SDR detection unavailable' + + return available + + +def _start_sweep_internal( + sweep_type: str, + baseline_id: int | None, + wifi_enabled: bool, + bt_enabled: bool, + rf_enabled: bool, + wifi_interface: str = '', + bt_interface: str = '', + sdr_device: int | None = None, + verbose_results: bool = False, +) -> dict: + """Start a TSCM sweep without request context.""" + global _sweep_running, _sweep_thread, _current_sweep_id + + if _sweep_running: + return {'status': 'error', 'message': 'Sweep already running', 'http_status': 409} + + # Check for available devices + devices = _check_available_devices(wifi_enabled, bt_enabled, rf_enabled) + + warnings = [] + if wifi_enabled and not devices['wifi']: + warnings.append(f"WiFi: {devices['wifi_reason']}") + if bt_enabled and not devices['bluetooth']: + warnings.append(f"Bluetooth: {devices['bt_reason']}") + if rf_enabled and not devices['rf']: + warnings.append(f"RF: {devices['rf_reason']}") + + # If no devices available at all, return error + if not any([devices['wifi'], devices['bluetooth'], devices['rf']]): + return { + 'status': 'error', + 'message': 'No scanning devices available', + 'details': warnings, + 'http_status': 400, + } + + # Create sweep record + _current_sweep_id = create_tscm_sweep( + sweep_type=sweep_type, + baseline_id=baseline_id, + wifi_enabled=wifi_enabled, + bt_enabled=bt_enabled, + rf_enabled=rf_enabled + ) + + _sweep_running = True + + # Start sweep thread + _sweep_thread = threading.Thread( + target=_run_sweep, + args=(sweep_type, baseline_id, wifi_enabled, bt_enabled, rf_enabled, + wifi_interface, bt_interface, sdr_device, verbose_results), + daemon=True + ) + _sweep_thread.start() + + logger.info(f"Started TSCM sweep: type={sweep_type}, id={_current_sweep_id}") + + return { + 'status': 'success', + 'message': 'Sweep started', + 'sweep_id': _current_sweep_id, + 'sweep_type': sweep_type, + 'warnings': warnings if warnings else None, + 'devices': { + 'wifi': devices['wifi'], + 'bluetooth': devices['bluetooth'], + 'rf': devices['rf'] + } + } + + +@tscm_bp.route('/sweep/start', methods=['POST']) +def start_sweep(): + """Start a TSCM sweep.""" + data = request.get_json() or {} + sweep_type = data.get('sweep_type', 'standard') + baseline_id = data.get('baseline_id') + if baseline_id in ('', None): + baseline_id = None + wifi_enabled = data.get('wifi', True) + bt_enabled = data.get('bluetooth', True) + rf_enabled = data.get('rf', True) + verbose_results = bool(data.get('verbose_results', False)) + + # Get interface selections + wifi_interface = data.get('wifi_interface', '') + bt_interface = data.get('bt_interface', '') + sdr_device = data.get('sdr_device') + + result = _start_sweep_internal( + sweep_type=sweep_type, + baseline_id=baseline_id, + wifi_enabled=wifi_enabled, + bt_enabled=bt_enabled, + rf_enabled=rf_enabled, + wifi_interface=wifi_interface, + bt_interface=bt_interface, + sdr_device=sdr_device, + verbose_results=verbose_results, + ) + http_status = result.pop('http_status', 200) + return jsonify(result), http_status + + +@tscm_bp.route('/sweep/stop', methods=['POST']) +def stop_sweep(): + """Stop the current TSCM sweep.""" + global _sweep_running + + if not _sweep_running: + return jsonify({'status': 'error', 'message': 'No sweep running'}) + + _sweep_running = False + + if _current_sweep_id: + update_tscm_sweep(_current_sweep_id, status='aborted', completed=True) + + _emit_event('sweep_stopped', {'reason': 'user_requested'}) + + logger.info("TSCM sweep stopped by user") + + return jsonify({'status': 'success', 'message': 'Sweep stopped'}) + + +@tscm_bp.route('/sweep/status') +def sweep_status(): + """Get current sweep status.""" + status = { + 'running': _sweep_running, + 'sweep_id': _current_sweep_id, + } + + if _current_sweep_id: + sweep = get_tscm_sweep(_current_sweep_id) + if sweep: + status['sweep'] = sweep + + return jsonify(status) + + +@tscm_bp.route('/sweep/stream') +def sweep_stream(): + """SSE stream for real-time sweep updates.""" + def generate(): + while True: + try: + if tscm_queue: + msg = tscm_queue.get(timeout=1) + yield f"data: {json.dumps(msg)}\n\n" + else: + time.sleep(1) + yield f"data: {json.dumps({'type': 'keepalive'})}\n\n" + except queue.Empty: + yield f"data: {json.dumps({'type': 'keepalive'})}\n\n" + + return Response( + generate(), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' + } + ) + + +# ============================================================================= +# Schedule Endpoints +# ============================================================================= + +@tscm_bp.route('/schedules', methods=['GET']) +def list_schedules(): + """List all TSCM sweep schedules.""" + enabled_param = request.args.get('enabled') + enabled = None + if enabled_param is not None: + enabled = enabled_param.lower() in ('1', 'true', 'yes') + + schedules = get_all_tscm_schedules(enabled=enabled, limit=200) + return jsonify({ + 'status': 'success', + 'count': len(schedules), + 'schedules': schedules, + }) + + +@tscm_bp.route('/schedules', methods=['POST']) +def create_schedule(): + """Create a new sweep schedule.""" + data = request.get_json() or {} + name = (data.get('name') or '').strip() + cron_expression = (data.get('cron_expression') or '').strip() + sweep_type = data.get('sweep_type', 'standard') + baseline_id = data.get('baseline_id') + zone_name = data.get('zone_name') + enabled = bool(data.get('enabled', True)) + notify_on_threat = bool(data.get('notify_on_threat', True)) + notify_email = data.get('notify_email') + + if not name: + return jsonify({'status': 'error', 'message': 'Schedule name required'}), 400 + if not cron_expression: + return jsonify({'status': 'error', 'message': 'cron_expression required'}), 400 + + next_run = None + if enabled: + try: + tz = _get_schedule_timezone(zone_name) + next_local = _next_run_from_cron(cron_expression, datetime.now(tz)) + next_run = next_local.astimezone(timezone.utc).isoformat() if next_local else None + except Exception as e: + return jsonify({'status': 'error', 'message': f'Invalid cron: {e}'}), 400 + + schedule_id = create_tscm_schedule( + name=name, + cron_expression=cron_expression, + sweep_type=sweep_type, + baseline_id=baseline_id, + zone_name=zone_name, + enabled=enabled, + notify_on_threat=notify_on_threat, + notify_email=notify_email, + next_run=next_run, + ) + schedule = get_tscm_schedule(schedule_id) + return jsonify({ + 'status': 'success', + 'message': 'Schedule created', + 'schedule': schedule + }) + + +@tscm_bp.route('/schedules/', methods=['PUT', 'PATCH']) +def update_schedule(schedule_id: int): + """Update a sweep schedule.""" + schedule = get_tscm_schedule(schedule_id) + if not schedule: + return jsonify({'status': 'error', 'message': 'Schedule not found'}), 404 + + data = request.get_json() or {} + updates: dict[str, Any] = {} + + for key in ('name', 'cron_expression', 'sweep_type', 'baseline_id', 'zone_name', 'notify_email'): + if key in data: + updates[key] = data[key] + + if 'baseline_id' in updates and updates['baseline_id'] in ('', None): + updates['baseline_id'] = None + + if 'enabled' in data: + updates['enabled'] = 1 if data['enabled'] else 0 + if 'notify_on_threat' in data: + updates['notify_on_threat'] = 1 if data['notify_on_threat'] else 0 + + # Recalculate next_run when cron/zone/enabled changes + if any(k in updates for k in ('cron_expression', 'zone_name', 'enabled')): + if updates.get('enabled', schedule.get('enabled', 1)): + cron_expr = updates.get('cron_expression', schedule.get('cron_expression', '')) + zone_name = updates.get('zone_name', schedule.get('zone_name')) + try: + tz = _get_schedule_timezone(zone_name) + next_local = _next_run_from_cron(cron_expr, datetime.now(tz)) + updates['next_run'] = next_local.astimezone(timezone.utc).isoformat() if next_local else None + except Exception as e: + return jsonify({'status': 'error', 'message': f'Invalid cron: {e}'}), 400 + else: + updates['next_run'] = None + + if not updates: + return jsonify({'status': 'error', 'message': 'No updates provided'}), 400 + + update_tscm_schedule(schedule_id, **updates) + schedule = get_tscm_schedule(schedule_id) + return jsonify({'status': 'success', 'schedule': schedule}) + + +@tscm_bp.route('/schedules/', methods=['DELETE']) +def delete_schedule(schedule_id: int): + """Delete a sweep schedule.""" + success = delete_tscm_schedule(schedule_id) + if not success: + return jsonify({'status': 'error', 'message': 'Schedule not found'}), 404 + return jsonify({'status': 'success', 'message': 'Schedule deleted'}) + + +@tscm_bp.route('/schedules//run', methods=['POST']) +def run_schedule_now(schedule_id: int): + """Trigger a scheduled sweep immediately.""" + schedule = get_tscm_schedule(schedule_id) + if not schedule: + return jsonify({'status': 'error', 'message': 'Schedule not found'}), 404 + + result = _start_sweep_internal( + sweep_type=schedule.get('sweep_type') or 'standard', + baseline_id=schedule.get('baseline_id'), + wifi_enabled=True, + bt_enabled=True, + rf_enabled=True, + wifi_interface='', + bt_interface='', + sdr_device=None, + verbose_results=False, + ) + + if result.get('status') != 'success': + status_code = result.pop('http_status', 400) + return jsonify(result), status_code + + # Update schedule run timestamps + cron_expr = schedule.get('cron_expression') or '' + tz = _get_schedule_timezone(schedule.get('zone_name')) + now_utc = datetime.now(timezone.utc) + try: + next_local = _next_run_from_cron(cron_expr, datetime.now(tz)) + except Exception: + next_local = None + + update_tscm_schedule( + schedule_id, + last_run=now_utc.isoformat(), + next_run=next_local.astimezone(timezone.utc).isoformat() if next_local else None, + ) + + return jsonify(result) + + +@tscm_bp.route('/devices') +def get_tscm_devices(): + """Get available scanning devices for TSCM sweeps.""" + import platform + import shutil + import subprocess + + devices = { + 'wifi_interfaces': [], + 'bt_adapters': [], + 'sdr_devices': [] + } + + # Detect WiFi interfaces + if platform.system() == 'Darwin': # macOS + try: + result = subprocess.run( + ['networksetup', '-listallhardwareports'], + capture_output=True, text=True, timeout=5 + ) + lines = result.stdout.split('\n') + for i, line in enumerate(lines): + if 'Wi-Fi' in line or 'AirPort' in line: + # Get the hardware port name (e.g., "Wi-Fi") + port_name = line.replace('Hardware Port:', '').strip() + for j in range(i + 1, min(i + 3, len(lines))): + if 'Device:' in lines[j]: + device = lines[j].split('Device:')[1].strip() + devices['wifi_interfaces'].append({ + 'name': device, + 'display_name': f'{port_name} ({device})', + 'type': 'internal', + 'monitor_capable': False + }) + break + except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): + pass + else: # Linux + try: + result = subprocess.run( + ['iw', 'dev'], + capture_output=True, text=True, timeout=5 + ) + current_iface = None + for line in result.stdout.split('\n'): + line = line.strip() + if line.startswith('Interface'): + current_iface = line.split()[1] + elif current_iface and 'type' in line: + iface_type = line.split()[-1] + devices['wifi_interfaces'].append({ + 'name': current_iface, + 'display_name': f'Wireless ({current_iface}) - {iface_type}', + 'type': iface_type, + 'monitor_capable': True + }) + current_iface = None + except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): + # Fall back to iwconfig + try: + result = subprocess.run( + ['iwconfig'], + capture_output=True, text=True, timeout=5 + ) + for line in result.stdout.split('\n'): + if 'IEEE 802.11' in line: + iface = line.split()[0] + devices['wifi_interfaces'].append({ + 'name': iface, + 'display_name': f'Wireless ({iface})', + 'type': 'managed', + 'monitor_capable': True + }) + except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): + pass + + # Detect Bluetooth adapters + if platform.system() == 'Linux': + try: + result = subprocess.run( + ['hciconfig'], + capture_output=True, text=True, timeout=5 + ) + import re + blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE) + for idx, block in enumerate(blocks): + if block.strip(): + first_line = block.split('\n')[0] + match = re.match(r'(hci\d+):', first_line) + if match: + iface_name = match.group(1) + is_up = 'UP RUNNING' in block or '\tUP ' in block + devices['bt_adapters'].append({ + 'name': iface_name, + 'display_name': f'Bluetooth Adapter ({iface_name})', + 'type': 'hci', + 'status': 'up' if is_up else 'down' + }) + except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): + # Try bluetoothctl as fallback + try: + result = subprocess.run( + ['bluetoothctl', 'list'], + capture_output=True, text=True, timeout=5 + ) + for line in result.stdout.split('\n'): + if 'Controller' in line: + # Format: Controller XX:XX:XX:XX:XX:XX Name + parts = line.split() + if len(parts) >= 3: + addr = parts[1] + name = ' '.join(parts[2:]) if len(parts) > 2 else 'Bluetooth' + devices['bt_adapters'].append({ + 'name': addr, + 'display_name': f'{name} ({addr[-8:]})', + 'type': 'controller', + 'status': 'available' + }) + except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): + pass + elif platform.system() == 'Darwin': + # macOS has built-in Bluetooth - get more info via system_profiler + try: + result = subprocess.run( + ['system_profiler', 'SPBluetoothDataType'], + capture_output=True, text=True, timeout=10 + ) + # Extract controller info + bt_name = 'Built-in Bluetooth' + bt_addr = '' + for line in result.stdout.split('\n'): + if 'Address:' in line: + bt_addr = line.split('Address:')[1].strip() + break + devices['bt_adapters'].append({ + 'name': 'default', + 'display_name': f'{bt_name}' + (f' ({bt_addr[-8:]})' if bt_addr else ''), + 'type': 'macos', + 'status': 'available' + }) + except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): + devices['bt_adapters'].append({ + 'name': 'default', + 'display_name': 'Built-in Bluetooth', + 'type': 'macos', + 'status': 'available' + }) + + # Detect SDR devices + try: + from utils.sdr import SDRFactory + sdr_list = SDRFactory.detect_devices() + for sdr in sdr_list: + # SDRDevice is a dataclass with attributes, not a dict + sdr_type_name = sdr.sdr_type.value if hasattr(sdr.sdr_type, 'value') else str(sdr.sdr_type) + # Create a friendly display name + display_name = sdr.name + if sdr.serial and sdr.serial not in ('N/A', 'Unknown'): + display_name = f'{sdr.name} (SN: {sdr.serial[-8:]})' + devices['sdr_devices'].append({ + 'index': sdr.index, + 'name': sdr.name, + 'display_name': display_name, + 'type': sdr_type_name, + 'serial': sdr.serial, + 'driver': sdr.driver + }) + except ImportError: + logger.debug("SDR module not available") + except Exception as e: + logger.warning(f"Error detecting SDR devices: {e}") + + # Check if running as root + import os + from flask import current_app + running_as_root = current_app.config.get('RUNNING_AS_ROOT', os.geteuid() == 0) + + warnings = [] + if not running_as_root: + warnings.append({ + 'type': 'privileges', + 'message': 'Not running as root. WiFi monitor mode and some Bluetooth features require sudo.', + 'action': 'Run with: sudo -E venv/bin/python intercept.py' + }) + + return jsonify({ + 'status': 'success', + 'devices': devices, + 'running_as_root': running_as_root, + 'warnings': warnings + }) + + +def _scan_wifi_networks(interface: str) -> list[dict]: + """ + Scan for WiFi networks using the unified WiFi scanner. + + This is a facade that maintains backwards compatibility with TSCM + while using the new unified scanner module. + + Automatically detects monitor mode interfaces and uses deep scan + (airodump-ng) when appropriate. + + Args: + interface: WiFi interface name (optional). + + Returns: + List of network dicts with: bssid, essid, power, channel, privacy + """ + try: + from utils.wifi import get_wifi_scanner + + scanner = get_wifi_scanner() + + # Check if interface is in monitor mode + is_monitor = False + if interface: + is_monitor = scanner._is_monitor_mode_interface(interface) + + if is_monitor: + # Use deep scan for monitor mode interfaces + logger.info(f"Interface {interface} is in monitor mode, using deep scan") + + # Check if airodump-ng is available + caps = scanner.check_capabilities() + if not caps.has_airodump_ng: + logger.warning("airodump-ng not available for monitor mode scanning") + return [] + + # Start a short deep scan + if not scanner.is_scanning: + scanner.start_deep_scan(interface=interface, band='all') + + # Wait briefly for some results + import time + time.sleep(5) + + # Get current access points + networks = [] + for ap in scanner.access_points: + networks.append(ap.to_legacy_dict()) + + logger.info(f"WiFi deep scan found {len(networks)} networks") + return networks + else: + # Use quick scan for managed mode interfaces + result = scanner.quick_scan(interface=interface, timeout=15) + + if result.error: + logger.warning(f"WiFi scan error: {result.error}") + + # Convert to legacy format for TSCM + networks = [] + for ap in result.access_points: + networks.append(ap.to_legacy_dict()) + + logger.info(f"WiFi scan found {len(networks)} networks") + return networks + + except ImportError as e: + logger.error(f"Failed to import wifi scanner: {e}") + return [] + except Exception as e: + logger.exception(f"WiFi scan failed: {e}") + return [] + + +def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]: + """ + Scan for Bluetooth devices with manufacturer data detection. + + Uses the BLE scanner module (bleak library) for proper manufacturer ID + detection, with fallback to system tools if bleak is unavailable. + """ + import platform + import os + import re + import shutil + import subprocess + + devices = [] + seen_macs = set() + + logger.info(f"Starting Bluetooth scan (duration={duration}s, interface={interface})") + + # Try the BLE scanner module first (uses bleak for proper manufacturer detection) + try: + from utils.tscm.ble_scanner import get_ble_scanner, scan_ble_devices + + logger.info("Using BLE scanner module with manufacturer detection") + ble_devices = scan_ble_devices(duration) + + for ble_dev in ble_devices: + mac = ble_dev.get('mac', '').upper() + if mac and mac not in seen_macs: + seen_macs.add(mac) + + device = { + 'mac': mac, + 'name': ble_dev.get('name', 'Unknown'), + 'rssi': ble_dev.get('rssi'), + 'type': 'ble', + 'manufacturer': ble_dev.get('manufacturer_name'), + 'manufacturer_id': ble_dev.get('manufacturer_id'), + 'is_tracker': ble_dev.get('is_tracker', False), + 'tracker_type': ble_dev.get('tracker_type'), + 'is_airtag': ble_dev.get('is_airtag', False), + 'is_tile': ble_dev.get('is_tile', False), + 'is_smarttag': ble_dev.get('is_smarttag', False), + 'is_espressif': ble_dev.get('is_espressif', False), + 'service_uuids': ble_dev.get('service_uuids', []), + } + devices.append(device) + + if devices: + logger.info(f"BLE scanner found {len(devices)} devices") + trackers = [d for d in devices if d.get('is_tracker')] + if trackers: + logger.info(f"Trackers detected: {[d.get('tracker_type') for d in trackers]}") + return devices + + except ImportError: + logger.warning("BLE scanner module not available, using fallback") + except Exception as e: + logger.warning(f"BLE scanner failed: {e}, using fallback") + + if platform.system() == 'Darwin': + # macOS: Use system_profiler for basic Bluetooth info + try: + result = subprocess.run( + ['system_profiler', 'SPBluetoothDataType', '-json'], + capture_output=True, text=True, timeout=15 + ) + import json + data = json.loads(result.stdout) + bt_data = data.get('SPBluetoothDataType', [{}])[0] + + # Get connected/paired devices + for section in ['device_connected', 'device_title']: + section_data = bt_data.get(section, {}) + if isinstance(section_data, dict): + for name, info in section_data.items(): + if isinstance(info, dict): + mac = info.get('device_address', '') + if mac and mac not in seen_macs: + seen_macs.add(mac) + devices.append({ + 'mac': mac.upper(), + 'name': name, + 'type': info.get('device_minorType', 'unknown'), + 'connected': section == 'device_connected' + }) + logger.info(f"macOS Bluetooth scan found {len(devices)} devices") + except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError, json.JSONDecodeError) as e: + logger.warning(f"macOS Bluetooth scan failed: {e}") + + else: + # Linux: Try multiple methods + iface = interface or 'hci0' + + # Method 1: Try hcitool scan (simpler, more reliable) + if shutil.which('hcitool'): + try: + logger.info("Trying hcitool scan...") + result = subprocess.run( + ['hcitool', '-i', iface, 'scan', '--flush'], + capture_output=True, text=True, timeout=duration + 5 + ) + for line in result.stdout.split('\n'): + line = line.strip() + if line and '\t' in line: + parts = line.split('\t') + if len(parts) >= 1 and ':' in parts[0]: + mac = parts[0].strip().upper() + name = parts[1].strip() if len(parts) > 1 else 'Unknown' + if mac not in seen_macs: + seen_macs.add(mac) + devices.append({'mac': mac, 'name': name}) + logger.info(f"hcitool scan found {len(devices)} classic BT devices") + except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e: + logger.warning(f"hcitool scan failed: {e}") + + # Method 2: Try btmgmt for BLE devices + if shutil.which('btmgmt'): + try: + logger.info("Trying btmgmt find...") + result = subprocess.run( + ['btmgmt', 'find'], + capture_output=True, text=True, timeout=duration + 5 + ) + for line in result.stdout.split('\n'): + # Parse btmgmt output: "dev_found: XX:XX:XX:XX:XX:XX type LE..." + if 'dev_found' in line.lower() or ('type' in line.lower() and ':' in line): + mac_match = re.search( + r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:' + r'[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})', + line + ) + if mac_match: + mac = mac_match.group(1).upper() + if mac not in seen_macs: + seen_macs.add(mac) + # Try to extract name + name_match = re.search(r'name\s+(.+?)(?:\s|$)', line, re.I) + name = name_match.group(1) if name_match else 'Unknown BLE' + devices.append({ + 'mac': mac, + 'name': name, + 'type': 'ble' if 'le' in line.lower() else 'classic' + }) + logger.info(f"btmgmt found {len(devices)} total devices") + except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e: + logger.warning(f"btmgmt find failed: {e}") + + # Method 3: Try bluetoothctl as last resort + if not devices and shutil.which('bluetoothctl'): + try: + import pty + import select + + logger.info("Trying bluetoothctl scan...") + master_fd, slave_fd = pty.openpty() + process = subprocess.Popen( + ['bluetoothctl'], + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + close_fds=True + ) + os.close(slave_fd) + + # Start scanning + time.sleep(0.3) + os.write(master_fd, b'power on\n') + time.sleep(0.3) + os.write(master_fd, b'scan on\n') + + # Collect devices for specified duration + scan_end = time.time() + min(duration, 10) # Cap at 10 seconds + buffer = '' + + while time.time() < scan_end: + readable, _, _ = select.select([master_fd], [], [], 1.0) + if readable: + try: + data = os.read(master_fd, 4096) + if not data: + break + buffer += data.decode('utf-8', errors='replace') + + while '\n' in buffer: + line, buffer = buffer.split('\n', 1) + line = re.sub(r'\x1b\[[0-9;]*m', '', line).strip() + + if 'Device' in line: + match = re.search( + r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:' + r'[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})\s*(.*)', + line + ) + if match: + mac = match.group(1).upper() + name = match.group(2).strip() + # Remove RSSI from name if present + name = re.sub(r'\s*RSSI:\s*-?\d+\s*', '', name).strip() + + if mac not in seen_macs: + seen_macs.add(mac) + devices.append({ + 'mac': mac, + 'name': name or '[Unknown]' + }) + except OSError: + break + + # Stop scanning and cleanup + try: + os.write(master_fd, b'scan off\n') + time.sleep(0.2) + os.write(master_fd, b'quit\n') + except OSError: + pass + + process.terminate() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + + try: + os.close(master_fd) + except OSError: + pass + + logger.info(f"bluetoothctl scan found {len(devices)} devices") + + except (FileNotFoundError, subprocess.SubprocessError) as e: + logger.warning(f"bluetoothctl scan failed: {e}") + + return devices + + +def _scan_rf_signals( + sdr_device: int | None, + duration: int = 30, + stop_check: callable | None = None, + sweep_ranges: list[dict] | None = None +) -> list[dict]: + """ + Scan for RF signals using SDR (rtl_power). + + Scans common surveillance frequency bands: + - 88-108 MHz: FM broadcast (potential FM bugs) + - 315 MHz: Common ISM band (wireless devices) + - 433 MHz: ISM band (European wireless devices, car keys) + - 868 MHz: European ISM band + - 915 MHz: US ISM band + - 1.2 GHz: Video transmitters + - 2.4 GHz: WiFi, Bluetooth, video transmitters + + Args: + sdr_device: SDR device index + duration: Scan duration per band + stop_check: Optional callable that returns True if scan should stop. + Defaults to checking module-level _sweep_running. + sweep_ranges: Optional preset ranges (MHz) from SWEEP_PRESETS. + """ + # Default stop check uses module-level _sweep_running + if stop_check is None: + stop_check = lambda: not _sweep_running + import os + import shutil + import subprocess + import tempfile + + signals = [] + + logger.info(f"Starting RF scan (device={sdr_device})") + + rtl_power_path = shutil.which('rtl_power') + if not rtl_power_path: + logger.warning("rtl_power not found in PATH, RF scanning unavailable") + _emit_event('rf_status', { + 'status': 'error', + 'message': 'rtl_power not installed. Install rtl-sdr package for RF scanning.', + }) + return signals + + logger.info(f"Found rtl_power at: {rtl_power_path}") + + # Test if RTL-SDR device is accessible + rtl_test_path = shutil.which('rtl_test') + if rtl_test_path: + try: + test_result = subprocess.run( + [rtl_test_path, '-t'], + capture_output=True, + text=True, + timeout=5 + ) + if 'No supported devices found' in test_result.stderr or test_result.returncode != 0: + logger.warning("No RTL-SDR device found") + _emit_event('rf_status', { + 'status': 'error', + 'message': 'No RTL-SDR device connected. Connect an RTL-SDR dongle for RF scanning.', + }) + return signals + except subprocess.TimeoutExpired: + pass # Device might be busy, continue anyway + except Exception as e: + logger.debug(f"rtl_test check failed: {e}") + + # Define frequency bands to scan (in Hz) + # Format: (start_freq, end_freq, bin_size, description) + scan_bands: list[tuple[int, int, int, str]] = [] + + if sweep_ranges: + for rng in sweep_ranges: + try: + start_mhz = float(rng.get('start', 0)) + end_mhz = float(rng.get('end', 0)) + step_mhz = float(rng.get('step', 0.1)) + name = rng.get('name') or f"{start_mhz:.1f}-{end_mhz:.1f} MHz" + if start_mhz > 0 and end_mhz > start_mhz: + bin_size = max(1000, int(step_mhz * 1_000_000)) + scan_bands.append(( + int(start_mhz * 1_000_000), + int(end_mhz * 1_000_000), + bin_size, + name + )) + except (TypeError, ValueError): + continue + + if not scan_bands: + # Fallback: focus on common bug frequencies + scan_bands = [ + (88000000, 108000000, 100000, 'FM Broadcast'), # FM bugs + (315000000, 316000000, 10000, '315 MHz ISM'), # US ISM + (433000000, 434000000, 10000, '433 MHz ISM'), # EU ISM + (868000000, 869000000, 10000, '868 MHz ISM'), # EU ISM + (902000000, 928000000, 100000, '915 MHz ISM'), # US ISM + (1200000000, 1300000000, 100000, '1.2 GHz Video'), # Video TX + (2400000000, 2500000000, 500000, '2.4 GHz ISM'), # WiFi/BT/Video + ] + + # Create temp file for output + with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as tmp: + tmp_path = tmp.name + + try: + # Build device argument + device_arg = ['-d', str(sdr_device if sdr_device is not None else 0)] + + # Scan each band and look for strong signals + for start_freq, end_freq, bin_size, band_name in scan_bands: + if stop_check(): + break + + logger.info(f"Scanning {band_name} ({start_freq/1e6:.1f}-{end_freq/1e6:.1f} MHz)") + + try: + # Run rtl_power for a quick sweep of this band + cmd = [ + rtl_power_path, + '-f', f'{start_freq}:{end_freq}:{bin_size}', + '-g', '40', # Gain + '-i', '1', # Integration interval (1 second) + '-1', # Single shot mode + '-c', '20%', # Crop 20% of edges + ] + device_arg + [tmp_path] + + logger.debug(f"Running: {' '.join(cmd)}") + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode != 0: + logger.warning(f"rtl_power returned {result.returncode}: {result.stderr}") + + # Parse the CSV output + if os.path.exists(tmp_path) and os.path.getsize(tmp_path) > 0: + with open(tmp_path, 'r') as f: + for line in f: + parts = line.strip().split(',') + if len(parts) >= 7: + try: + # CSV format: date, time, hz_low, hz_high, hz_step, samples, db_values... + hz_low = int(parts[2]) + hz_high = int(parts[3]) + hz_step = float(parts[4]) + db_values = [float(x) for x in parts[6:] if x.strip()] + + # Find peaks above noise floor + # RTL-SDR dongles have higher noise figures, so use permissive thresholds + noise_floor = sum(db_values) / len(db_values) if db_values else -100 + threshold = noise_floor + 6 # Signal must be 6dB above noise + + for idx, db in enumerate(db_values): + if db > threshold and db > -90: # Detect signals above -90dBm + freq_hz = hz_low + (idx * hz_step) + freq_mhz = freq_hz / 1000000 + + signals.append({ + 'frequency': freq_mhz, + 'frequency_hz': freq_hz, + 'power': db, + 'band': band_name, + 'noise_floor': noise_floor, + 'signal_strength': db - noise_floor + }) + except (ValueError, IndexError): + continue + + # Clear file for next band + open(tmp_path, 'w').close() + + except subprocess.TimeoutExpired: + logger.warning(f"RF scan timeout for band {band_name}") + except Exception as e: + logger.warning(f"RF scan error for band {band_name}: {e}") + + finally: + # Cleanup temp file + try: + os.unlink(tmp_path) + except OSError: + pass + + # Deduplicate nearby frequencies (within 100kHz) + if signals: + signals.sort(key=lambda x: x['frequency']) + deduped = [signals[0]] + for sig in signals[1:]: + if sig['frequency'] - deduped[-1]['frequency'] > 0.1: # 100 kHz + deduped.append(sig) + elif sig['power'] > deduped[-1]['power']: + deduped[-1] = sig # Keep stronger signal + signals = deduped + + logger.info(f"RF scan found {len(signals)} signals") + return signals + + +def _run_sweep( + sweep_type: str, + baseline_id: int | None, + wifi_enabled: bool, + bt_enabled: bool, + rf_enabled: bool, + wifi_interface: str = '', + bt_interface: str = '', + sdr_device: int | None = None, + verbose_results: bool = False +) -> None: + """ + Run the TSCM sweep in a background thread. + + This orchestrates data collection from WiFi, BT, and RF sources, + then analyzes results for threats using the correlation engine. + """ + global _sweep_running, _current_sweep_id + + try: + # Get baseline for comparison if specified + baseline = None + if baseline_id: + baseline = get_tscm_baseline(baseline_id) + + # Get sweep preset + preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard') + duration = preset.get('duration_seconds', 300) + + _emit_event('sweep_started', { + 'sweep_id': _current_sweep_id, + 'sweep_type': sweep_type, + 'duration': duration, + 'wifi': wifi_enabled, + 'bluetooth': bt_enabled, + 'rf': rf_enabled, + }) + + # Initialize detector and correlation engine + detector = ThreatDetector(baseline) + correlation = get_correlation_engine() + # Clear old profiles from previous sweeps (keep 24h history) + correlation.clear_old_profiles(24) + + # Initialize device identity engine for MAC-randomization resistant detection + identity_engine = get_identity_engine() + identity_engine.clear() # Start fresh for this sweep + from utils.tscm.advanced import get_timeline_manager + timeline_manager = get_timeline_manager() + try: + cleanup_old_timeline_entries(72) + except Exception as e: + logger.debug(f"TSCM timeline cleanup skipped: {e}") + + last_timeline_write: dict[str, float] = {} + timeline_bucket = getattr(timeline_manager, 'bucket_seconds', 30) + + def _maybe_store_timeline( + identifier: str, + protocol: str, + rssi: int | None = None, + channel: int | None = None, + frequency: float | None = None, + attributes: dict | None = None + ) -> None: + if not identifier: + return + + identifier_norm = identifier.upper() if isinstance(identifier, str) else str(identifier) + key = f"{protocol}:{identifier_norm}" + now_ts = time.time() + last_ts = last_timeline_write.get(key) + if last_ts and (now_ts - last_ts) < timeline_bucket: + return + + last_timeline_write[key] = now_ts + try: + add_device_timeline_entry( + device_identifier=identifier_norm, + protocol=protocol, + sweep_id=_current_sweep_id, + rssi=rssi, + channel=channel, + frequency=frequency, + attributes=attributes + ) + except Exception as e: + logger.debug(f"TSCM timeline store error: {e}") + + # Collect and analyze data + threats_found = 0 + severity_counts = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0} + all_wifi = {} # Use dict for deduplication by BSSID + all_bt = {} # Use dict for deduplication by MAC + all_rf = [] + + start_time = time.time() + last_wifi_scan = 0 + last_bt_scan = 0 + last_rf_scan = 0 + wifi_scan_interval = 15 # Scan WiFi every 15 seconds + bt_scan_interval = 20 # Scan Bluetooth every 20 seconds + rf_scan_interval = 30 # Scan RF every 30 seconds + + while _sweep_running and (time.time() - start_time) < duration: + current_time = time.time() + + # Perform WiFi scan + if wifi_enabled and (current_time - last_wifi_scan) >= wifi_scan_interval: + try: + wifi_networks = _scan_wifi_networks(wifi_interface) + last_wifi_scan = current_time + if not wifi_networks and not all_wifi: + logger.warning("TSCM WiFi scan returned 0 networks") + _emit_event('sweep_progress', { + 'progress': min(95, int(((current_time - start_time) / duration) * 100)), + 'status': f'Scanning WiFi... ({len(wifi_networks)} found)', + 'wifi_count': len(all_wifi) + len([n for n in wifi_networks if n.get('bssid') and n.get('bssid') not in all_wifi]), + 'bt_count': len(all_bt), + 'rf_count': len(all_rf), + }) + for network in wifi_networks: + try: + bssid = network.get('bssid', '') + ssid = network.get('essid', network.get('ssid')) + try: + rssi_val = int(network.get('power', network.get('signal'))) + except (ValueError, TypeError): + rssi_val = None + if bssid: + try: + timeline_manager.add_observation( + identifier=bssid, + protocol='wifi', + rssi=rssi_val, + channel=network.get('channel'), + name=ssid, + attributes={'ssid': ssid, 'encryption': network.get('privacy')} + ) + except Exception as e: + logger.debug(f"WiFi timeline observation error: {e}") + _maybe_store_timeline( + identifier=bssid, + protocol='wifi', + rssi=rssi_val, + channel=network.get('channel'), + attributes={'ssid': ssid, 'encryption': network.get('privacy')} + ) + if bssid and bssid not in all_wifi: + all_wifi[bssid] = network + # Emit device event for frontend + is_threat = False + # Analyze for threats + threat = detector.analyze_wifi_device(network) + if threat: + _handle_threat(threat) + threats_found += 1 + is_threat = True + sev = threat.get('severity', 'low').lower() + if sev in severity_counts: + severity_counts[sev] += 1 + # Classify device and get correlation profile + classification = detector.classify_wifi_device(network) + profile = correlation.analyze_wifi_device(network) + + # Feed to identity engine for MAC-randomization resistant clustering + # Note: WiFi APs don't typically use randomized MACs, but clients do + try: + wifi_obs = { + 'timestamp': datetime.now().isoformat(), + 'src_mac': bssid, + 'bssid': bssid, + 'ssid': network.get('essid'), + 'rssi': network.get('power'), + 'channel': network.get('channel'), + 'encryption': network.get('privacy'), + 'frame_type': 'beacon', + } + ingest_wifi_dict(wifi_obs) + except Exception as e: + logger.debug(f"Identity engine WiFi ingest error: {e}") + + # Send device to frontend + _emit_event('wifi_device', { + 'bssid': bssid, + 'ssid': network.get('essid', 'Hidden'), + 'channel': network.get('channel', ''), + 'signal': network.get('power', ''), + 'security': network.get('privacy', ''), + 'is_threat': is_threat, + 'is_new': not classification.get('in_baseline', False), + 'classification': profile.risk_level.value, + 'reasons': classification.get('reasons', []), + 'score': profile.total_score, + 'score_modifier': profile.score_modifier, + 'known_device': profile.known_device, + 'known_device_name': profile.known_device_name, + 'indicators': [{'type': i.type.value, 'desc': i.description} for i in profile.indicators], + 'recommended_action': profile.recommended_action, + }) + except Exception as e: + logger.error(f"WiFi device processing error for {network.get('bssid', '?')}: {e}") + except Exception as e: + last_wifi_scan = current_time + logger.error(f"WiFi scan error: {e}") + + # Perform Bluetooth scan + if bt_enabled and (current_time - last_bt_scan) >= bt_scan_interval: + try: + # Use unified Bluetooth scanner if available + if _USE_UNIFIED_BT_SCANNER: + logger.info("TSCM: Using unified BT scanner for snapshot") + bt_devices = get_tscm_bluetooth_snapshot(duration=8) + logger.info(f"TSCM: Unified scanner returned {len(bt_devices)} devices") + else: + logger.info(f"TSCM: Using legacy BT scanner on {bt_interface}") + bt_devices = _scan_bluetooth_devices(bt_interface, duration=8) + logger.info(f"TSCM: Legacy scanner returned {len(bt_devices)} devices") + last_bt_scan = current_time + for device in bt_devices: + try: + mac = device.get('mac', '') + try: + rssi_val = int(device.get('rssi', device.get('signal'))) + except (ValueError, TypeError): + rssi_val = None + if mac: + try: + timeline_manager.add_observation( + identifier=mac, + protocol='bluetooth', + rssi=rssi_val, + name=device.get('name'), + attributes={'device_type': device.get('type')} + ) + except Exception as e: + logger.debug(f"BT timeline observation error: {e}") + _maybe_store_timeline( + identifier=mac, + protocol='bluetooth', + rssi=rssi_val, + attributes={'device_type': device.get('type')} + ) + if mac and mac not in all_bt: + all_bt[mac] = device + is_threat = False + # Analyze for threats + threat = detector.analyze_bt_device(device) + if threat: + _handle_threat(threat) + threats_found += 1 + is_threat = True + sev = threat.get('severity', 'low').lower() + if sev in severity_counts: + severity_counts[sev] += 1 + # Classify device and get correlation profile + classification = detector.classify_bt_device(device) + profile = correlation.analyze_bluetooth_device(device) + + # Feed to identity engine for MAC-randomization resistant clustering + try: + ble_obs = { + 'timestamp': datetime.now().isoformat(), + 'addr': mac, + 'rssi': device.get('rssi'), + 'manufacturer_id': device.get('manufacturer_id') or device.get('company_id'), + 'manufacturer_data': device.get('manufacturer_data'), + 'service_uuids': device.get('services', []), + 'local_name': device.get('name'), + } + ingest_ble_dict(ble_obs) + except Exception as e: + logger.debug(f"Identity engine BLE ingest error: {e}") + + # Send device to frontend + _emit_event('bt_device', { + 'mac': mac, + 'name': device.get('name', 'Unknown'), + 'device_type': device.get('type', ''), + 'rssi': device.get('rssi', ''), + 'is_threat': is_threat, + 'is_new': not classification.get('in_baseline', False), + 'classification': profile.risk_level.value, + 'reasons': classification.get('reasons', []), + 'is_audio_capable': classification.get('is_audio_capable', False), + 'score': profile.total_score, + 'score_modifier': profile.score_modifier, + 'known_device': profile.known_device, + 'known_device_name': profile.known_device_name, + 'indicators': [{'type': i.type.value, 'desc': i.description} for i in profile.indicators], + 'recommended_action': profile.recommended_action, + }) + except Exception as e: + logger.error(f"BT device processing error for {device.get('mac', '?')}: {e}") + except Exception as e: + last_bt_scan = current_time + import traceback + logger.error(f"Bluetooth scan error: {e}\n{traceback.format_exc()}") + + # Perform RF scan using SDR + if rf_enabled and (current_time - last_rf_scan) >= rf_scan_interval: + try: + _emit_event('sweep_progress', { + 'progress': min(100, int(((current_time - start_time) / duration) * 100)), + 'status': 'Scanning RF spectrum...', + 'wifi_count': len(all_wifi), + 'bt_count': len(all_bt), + 'rf_count': len(all_rf), + }) + # Try RF scan even if sdr_device is None (will use device 0) + rf_signals = _scan_rf_signals(sdr_device, sweep_ranges=preset.get('ranges')) + + # If no signals and this is first RF scan, send info event + if not rf_signals and last_rf_scan == 0: + _emit_event('rf_status', { + 'status': 'no_signals', + 'message': 'RF scan completed - no signals above threshold. This may be normal in a quiet RF environment.', + }) + + for signal in rf_signals: + freq_key = f"{signal['frequency']:.3f}" + try: + power_val = int(float(signal.get('power', signal.get('level')))) + except (ValueError, TypeError): + power_val = None + try: + timeline_manager.add_observation( + identifier=freq_key, + protocol='rf', + rssi=power_val, + frequency=signal.get('frequency'), + name=f"{freq_key} MHz", + attributes={'band': signal.get('band')} + ) + except Exception as e: + logger.debug(f"RF timeline observation error: {e}") + _maybe_store_timeline( + identifier=freq_key, + protocol='rf', + rssi=power_val, + frequency=signal.get('frequency'), + attributes={'band': signal.get('band')} + ) + if freq_key not in [f"{s['frequency']:.3f}" for s in all_rf]: + all_rf.append(signal) + is_threat = False + # Analyze RF signal for threats + threat = detector.analyze_rf_signal(signal) + if threat: + _handle_threat(threat) + threats_found += 1 + is_threat = True + sev = threat.get('severity', 'low').lower() + if sev in severity_counts: + severity_counts[sev] += 1 + # Classify signal and get correlation profile + classification = detector.classify_rf_signal(signal) + profile = correlation.analyze_rf_signal(signal) + # Send signal to frontend + _emit_event('rf_signal', { + 'frequency': signal['frequency'], + 'power': signal['power'], + 'band': signal['band'], + 'signal_strength': signal.get('signal_strength', 0), + 'is_threat': is_threat, + 'is_new': not classification.get('in_baseline', False), + 'classification': profile.risk_level.value, + 'reasons': classification.get('reasons', []), + 'score': profile.total_score, + 'score_modifier': profile.score_modifier, + 'known_device': profile.known_device, + 'known_device_name': profile.known_device_name, + 'indicators': [{'type': i.type.value, 'desc': i.description} for i in profile.indicators], + 'recommended_action': profile.recommended_action, + }) + last_rf_scan = current_time + except Exception as e: + logger.error(f"RF scan error: {e}") + + # Update progress + elapsed = time.time() - start_time + progress = min(100, int((elapsed / duration) * 100)) + + _emit_event('sweep_progress', { + 'progress': progress, + 'elapsed': int(elapsed), + 'duration': duration, + 'wifi_count': len(all_wifi), + 'bt_count': len(all_bt), + 'rf_count': len(all_rf), + 'threats_found': threats_found, + 'severity_counts': severity_counts, + }) + + time.sleep(2) # Update every 2 seconds + + # Complete sweep + if _sweep_running and _current_sweep_id: + # Run cross-protocol correlation analysis + correlations = correlation.correlate_devices() + findings = correlation.get_all_findings() + + # Run baseline comparison if a baseline was provided + baseline_comparison = None + if baseline: + comparator = BaselineComparator(baseline) + baseline_comparison = comparator.compare_all( + wifi_devices=list(all_wifi.values()), + bt_devices=list(all_bt.values()), + rf_signals=all_rf + ) + logger.info( + f"Baseline comparison: {baseline_comparison['total_new']} new, " + f"{baseline_comparison['total_missing']} missing" + ) + + # Finalize identity engine and get MAC-randomization resistant clusters + identity_engine.finalize_all_sessions() + identity_summary = identity_engine.get_summary() + identity_clusters = [c.to_dict() for c in identity_engine.get_clusters()] + + if verbose_results: + wifi_payload = list(all_wifi.values()) + bt_payload = list(all_bt.values()) + rf_payload = list(all_rf) + else: + wifi_payload = [ + { + 'bssid': d.get('bssid') or d.get('mac'), + 'essid': d.get('essid') or d.get('ssid'), + 'ssid': d.get('ssid') or d.get('essid'), + 'channel': d.get('channel'), + 'power': d.get('power', d.get('signal')), + 'privacy': d.get('privacy', d.get('encryption')), + 'encryption': d.get('encryption', d.get('privacy')), + } + for d in all_wifi.values() + ] + bt_payload = [ + { + 'mac': d.get('mac') or d.get('address'), + 'name': d.get('name'), + 'rssi': d.get('rssi'), + 'manufacturer': d.get('manufacturer', d.get('manufacturer_name')), + } + for d in all_bt.values() + ] + rf_payload = [ + { + 'frequency': s.get('frequency'), + 'power': s.get('power', s.get('level')), + 'modulation': s.get('modulation'), + 'band': s.get('band'), + } + for s in all_rf + ] + + update_tscm_sweep( + _current_sweep_id, + status='completed', + results={ + 'wifi_devices': wifi_payload, + 'bt_devices': bt_payload, + 'rf_signals': rf_payload, + 'wifi_count': len(all_wifi), + 'bt_count': len(all_bt), + 'rf_count': len(all_rf), + 'severity_counts': severity_counts, + 'correlation_summary': findings.get('summary', {}), + 'identity_summary': identity_summary.get('statistics', {}), + 'baseline_comparison': baseline_comparison, + 'results_detail_level': 'full' if verbose_results else 'compact', + }, + threats_found=threats_found, + completed=True + ) + + # Emit correlation findings + _emit_event('correlation_findings', { + 'correlations': correlations, + 'high_interest_count': findings['summary'].get('high_interest', 0), + 'needs_review_count': findings['summary'].get('needs_review', 0), + }) + + # Emit baseline comparison if a baseline was used + if baseline_comparison: + _emit_event('baseline_comparison', { + 'baseline_id': baseline.get('id'), + 'baseline_name': baseline.get('name'), + 'total_new': baseline_comparison['total_new'], + 'total_missing': baseline_comparison['total_missing'], + 'wifi': baseline_comparison.get('wifi'), + 'bluetooth': baseline_comparison.get('bluetooth'), + 'rf': baseline_comparison.get('rf'), + }) + + # Emit device identity cluster findings (MAC-randomization resistant) + _emit_event('identity_clusters', { + 'total_clusters': identity_summary.get('statistics', {}).get('total_clusters', 0), + 'high_risk_count': identity_summary.get('statistics', {}).get('high_risk_count', 0), + 'medium_risk_count': identity_summary.get('statistics', {}).get('medium_risk_count', 0), + 'unique_fingerprints': identity_summary.get('statistics', {}).get('unique_fingerprints', 0), + 'clusters': identity_clusters, + }) + + _emit_event('sweep_completed', { + 'sweep_id': _current_sweep_id, + 'threats_found': threats_found, + 'wifi_count': len(all_wifi), + 'bt_count': len(all_bt), + 'rf_count': len(all_rf), + 'severity_counts': severity_counts, + 'high_interest_devices': findings['summary'].get('high_interest', 0), + 'needs_review_devices': findings['summary'].get('needs_review', 0), + 'correlations_found': len(correlations), + 'identity_clusters': identity_summary['statistics'].get('total_clusters', 0), + 'baseline_new_devices': baseline_comparison['total_new'] if baseline_comparison else 0, + 'baseline_missing_devices': baseline_comparison['total_missing'] if baseline_comparison else 0, + }) + + except Exception as e: + logger.error(f"Sweep error: {e}") + _emit_event('sweep_error', {'error': str(e)}) + if _current_sweep_id: + update_tscm_sweep(_current_sweep_id, status='error', completed=True) + + finally: + _sweep_running = False + + +def _handle_threat(threat: dict) -> None: + """Handle a detected threat.""" + if not _current_sweep_id: + return + + # Add to database + threat_id = add_tscm_threat( + sweep_id=_current_sweep_id, + threat_type=threat['threat_type'], + severity=threat['severity'], + source=threat['source'], + identifier=threat['identifier'], + name=threat.get('name'), + signal_strength=threat.get('signal_strength'), + frequency=threat.get('frequency'), + details=threat.get('details') + ) + + # Emit event + _emit_event('threat_detected', { + 'threat_id': threat_id, + **threat + }) + + logger.warning( + f"TSCM threat detected: {threat['threat_type']} - " + f"{threat['identifier']} ({threat['severity']})" + ) + + +# ============================================================================= +# Baseline Endpoints +# ============================================================================= + +@tscm_bp.route('/baseline/record', methods=['POST']) +def record_baseline(): + """Start recording a new baseline.""" + data = request.get_json() or {} + name = data.get('name', f'Baseline {datetime.now().strftime("%Y-%m-%d %H:%M")}') + location = data.get('location') + description = data.get('description') + + baseline_id = _baseline_recorder.start_recording(name, location, description) + + return jsonify({ + 'status': 'success', + 'message': 'Baseline recording started', + 'baseline_id': baseline_id + }) + + +@tscm_bp.route('/baseline/stop', methods=['POST']) +def stop_baseline(): + """Stop baseline recording.""" + result = _baseline_recorder.stop_recording() + + if 'error' in result: + return jsonify({'status': 'error', 'message': result['error']}) + + return jsonify({ + 'status': 'success', + 'message': 'Baseline recording complete', + **result + }) + + +@tscm_bp.route('/baseline/status') +def baseline_status(): + """Get baseline recording status.""" + return jsonify(_baseline_recorder.get_recording_status()) + + +@tscm_bp.route('/baselines') +def list_baselines(): + """List all baselines.""" + baselines = get_all_tscm_baselines() + return jsonify({'status': 'success', 'baselines': baselines}) + + +@tscm_bp.route('/baseline/') +def get_baseline(baseline_id: int): + """Get a specific baseline.""" + baseline = get_tscm_baseline(baseline_id) + if not baseline: + return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404 + + return jsonify({'status': 'success', 'baseline': baseline}) + + +@tscm_bp.route('/baseline//activate', methods=['POST']) +def activate_baseline(baseline_id: int): + """Set a baseline as active.""" + success = set_active_tscm_baseline(baseline_id) + if not success: + return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404 + + return jsonify({'status': 'success', 'message': 'Baseline activated'}) + + +@tscm_bp.route('/baseline/', methods=['DELETE']) +def remove_baseline(baseline_id: int): + """Delete a baseline.""" + success = delete_tscm_baseline(baseline_id) + if not success: + return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404 + + return jsonify({'status': 'success', 'message': 'Baseline deleted'}) + + +@tscm_bp.route('/baseline/active') +def get_active_baseline(): + """Get the currently active baseline.""" + baseline = get_active_tscm_baseline() + if not baseline: + return jsonify({'status': 'success', 'baseline': None}) + + return jsonify({'status': 'success', 'baseline': baseline}) + + +@tscm_bp.route('/baseline/compare', methods=['POST']) +def compare_against_baseline(): + """ + Compare provided device data against the active baseline. + + Expects JSON body with: + - wifi_devices: list of WiFi devices (optional) + - bt_devices: list of Bluetooth devices (optional) + - rf_signals: list of RF signals (optional) + + Returns comparison showing new, missing, and matching devices. + """ + data = request.get_json() or {} + + wifi_devices = data.get('wifi_devices') + bt_devices = data.get('bt_devices') + rf_signals = data.get('rf_signals') + + # Use the convenience function that gets active baseline + comparison = get_comparison_for_active_baseline( + wifi_devices=wifi_devices, + bt_devices=bt_devices, + rf_signals=rf_signals + ) + + if comparison is None: + return jsonify({ + 'status': 'error', + 'message': 'No active baseline set' + }), 400 + + return jsonify({ + 'status': 'success', + 'comparison': comparison + }) + + +# ============================================================================= +# Threat Endpoints +# ============================================================================= + +@tscm_bp.route('/threats') +def list_threats(): + """List threats with optional filters.""" + sweep_id = request.args.get('sweep_id', type=int) + severity = request.args.get('severity') + acknowledged = request.args.get('acknowledged') + limit = request.args.get('limit', 100, type=int) + + ack_filter = None + if acknowledged is not None: + ack_filter = acknowledged.lower() in ('true', '1', 'yes') + + threats = get_tscm_threats( + sweep_id=sweep_id, + severity=severity, + acknowledged=ack_filter, + limit=limit + ) + + return jsonify({'status': 'success', 'threats': threats}) + + +@tscm_bp.route('/threats/summary') +def threat_summary(): + """Get threat count summary by severity.""" + summary = get_tscm_threat_summary() + return jsonify({'status': 'success', 'summary': summary}) + + +@tscm_bp.route('/threats/', methods=['PUT']) +def update_threat(threat_id: int): + """Update a threat (acknowledge, add notes).""" + data = request.get_json() or {} + + if data.get('acknowledge'): + notes = data.get('notes') + success = acknowledge_tscm_threat(threat_id, notes) + if not success: + return jsonify({'status': 'error', 'message': 'Threat not found'}), 404 + + return jsonify({'status': 'success', 'message': 'Threat updated'}) + + +# ============================================================================= +# Preset Endpoints +# ============================================================================= + +@tscm_bp.route('/presets') +def list_presets(): + """List available sweep presets.""" + presets = get_all_sweep_presets() + return jsonify({'status': 'success', 'presets': presets}) + + +@tscm_bp.route('/presets/') +def get_preset(preset_name: str): + """Get details for a specific preset.""" + preset = get_sweep_preset(preset_name) + if not preset: + return jsonify({'status': 'error', 'message': 'Preset not found'}), 404 + + return jsonify({'status': 'success', 'preset': preset}) + + +# ============================================================================= +# Data Feed Endpoints (for adding data during sweeps/baselines) +# ============================================================================= + +@tscm_bp.route('/feed/wifi', methods=['POST']) +def feed_wifi(): + """Feed WiFi device data for baseline recording.""" + data = request.get_json() + if data: + _baseline_recorder.add_wifi_device(data) + return jsonify({'status': 'success'}) + + +@tscm_bp.route('/feed/bluetooth', methods=['POST']) +def feed_bluetooth(): + """Feed Bluetooth device data for baseline recording.""" + data = request.get_json() + if data: + _baseline_recorder.add_bt_device(data) + return jsonify({'status': 'success'}) + + +@tscm_bp.route('/feed/rf', methods=['POST']) +def feed_rf(): + """Feed RF signal data for baseline recording.""" + data = request.get_json() + if data: + _baseline_recorder.add_rf_signal(data) + return jsonify({'status': 'success'}) + + +# ============================================================================= +# Correlation & Findings Endpoints +# ============================================================================= + +@tscm_bp.route('/findings') +def get_findings(): + """ + Get comprehensive TSCM findings from the correlation engine. + + Returns all device profiles organized by risk level, cross-protocol + correlations, and summary statistics with client-safe disclaimers. + """ + correlation = get_correlation_engine() + findings = correlation.get_all_findings() + + # Add client-safe disclaimer + findings['legal_disclaimer'] = ( + "DISCLAIMER: This TSCM screening system identifies wireless and RF anomalies " + "and indicators. Results represent potential items of interest, NOT confirmed " + "surveillance devices. No content has been intercepted or decoded. Findings " + "require professional analysis and verification. This tool does not prove " + "malicious intent or illegal activity." + ) + + return jsonify({ + 'status': 'success', + 'findings': findings + }) + + +@tscm_bp.route('/findings/high-interest') +def get_high_interest(): + """Get only high-interest devices (score >= 6).""" + correlation = get_correlation_engine() + high_interest = correlation.get_high_interest_devices() + + return jsonify({ + 'status': 'success', + 'count': len(high_interest), + 'devices': [d.to_dict() for d in high_interest], + 'disclaimer': ( + "High-interest classification indicates multiple indicators warrant " + "investigation. This does NOT confirm surveillance activity." + ) + }) + + +@tscm_bp.route('/findings/correlations') +def get_correlations(): + """Get cross-protocol correlation analysis.""" + correlation = get_correlation_engine() + correlations = correlation.correlate_devices() + + return jsonify({ + 'status': 'success', + 'count': len(correlations), + 'correlations': correlations, + 'explanation': ( + "Correlations identify devices across different protocols (Bluetooth, " + "WiFi, RF) that exhibit related behavior patterns. Cross-protocol " + "activity is one indicator among many in TSCM analysis." + ) + }) + + +@tscm_bp.route('/findings/device/') +def get_device_profile(identifier: str): + """Get detailed profile for a specific device.""" + correlation = get_correlation_engine() + + # Search all protocols for the identifier + for protocol in ['bluetooth', 'wifi', 'rf']: + key = f"{protocol}:{identifier}" + if key in correlation.device_profiles: + profile = correlation.device_profiles[key] + return jsonify({ + 'status': 'success', + 'profile': profile.to_dict() + }) + + return jsonify({ + 'status': 'error', + 'message': 'Device not found' + }), 404 + + +# ============================================================================= +# Meeting Window Endpoints (for time correlation) +# ============================================================================= + +@tscm_bp.route('/meeting/start', methods=['POST']) +def start_meeting(): + """ + Mark the start of a sensitive period (meeting, briefing, etc.). + + Devices detected during this window will receive additional scoring + for meeting-correlated activity. + """ + correlation = get_correlation_engine() + correlation.start_meeting_window() + + _emit_event('meeting_started', { + 'timestamp': datetime.now().isoformat(), + 'message': 'Sensitive period monitoring active' + }) + + return jsonify({ + 'status': 'success', + 'message': 'Meeting window started - devices detected now will be flagged' + }) + + +@tscm_bp.route('/meeting/end', methods=['POST']) +def end_meeting(): + """Mark the end of a sensitive period.""" + correlation = get_correlation_engine() + correlation.end_meeting_window() + + _emit_event('meeting_ended', { + 'timestamp': datetime.now().isoformat() + }) + + return jsonify({ + 'status': 'success', + 'message': 'Meeting window ended' + }) + + +@tscm_bp.route('/meeting/status') +def meeting_status(): + """Check if currently in a meeting window.""" + correlation = get_correlation_engine() + in_meeting = correlation.is_during_meeting() + + return jsonify({ + 'status': 'success', + 'in_meeting': in_meeting, + 'windows': [ + { + 'start': start.isoformat(), + 'end': end.isoformat() if end else None + } + for start, end in correlation.meeting_windows + ] + }) + + +# ============================================================================= +# Report Generation Endpoints +# ============================================================================= + +@tscm_bp.route('/report') +def generate_report(): + """ + Generate a comprehensive TSCM sweep report. + + Includes all findings, correlations, indicators, and recommended actions + in a client-presentable format with appropriate disclaimers. + """ + correlation = get_correlation_engine() + findings = correlation.get_all_findings() + + # Build the report structure + report = { + 'generated_at': datetime.now().isoformat(), + 'report_type': 'TSCM Wireless Surveillance Screening', + + 'executive_summary': { + 'total_devices_analyzed': findings['summary']['total_devices'], + 'high_interest_items': findings['summary']['high_interest'], + 'items_requiring_review': findings['summary']['needs_review'], + 'cross_protocol_correlations': findings['summary']['correlations_found'], + 'assessment': _generate_assessment(findings['summary']), + }, + + 'methodology': { + 'protocols_scanned': ['Bluetooth Low Energy', 'WiFi 802.11', 'RF Spectrum'], + 'analysis_techniques': [ + 'Device fingerprinting', + 'Signal stability analysis', + 'Cross-protocol correlation', + 'Time-based pattern detection', + 'Manufacturer identification', + ], + 'scoring_model': { + 'informational': '0-2 points - Known or expected devices', + 'needs_review': '3-5 points - Unusual devices requiring assessment', + 'high_interest': '6+ points - Multiple indicators warrant investigation', + } + }, + + 'findings': { + 'high_interest': findings['devices']['high_interest'], + 'needs_review': findings['devices']['needs_review'], + 'informational': findings['devices']['informational'], + }, + + 'correlations': findings['correlations'], + + 'disclaimers': { + 'legal': ( + "This report documents findings from a wireless and RF surveillance " + "screening. Results indicate anomalies and items of interest, NOT " + "confirmed surveillance devices. No communications content has been " + "intercepted, recorded, or decoded. This screening does not prove " + "malicious intent, illegal activity, or the presence of surveillance " + "equipment. All findings require professional verification." + ), + 'technical': ( + "Detection capabilities are limited by equipment sensitivity, " + "environmental factors, and the technical sophistication of any " + "potential devices. Absence of findings does NOT guarantee absence " + "of surveillance equipment." + ), + 'recommendations': ( + "High-interest items should be investigated by qualified TSCM " + "professionals using appropriate physical inspection techniques. " + "This electronic sweep is one component of comprehensive TSCM." + ) + } + } + + return jsonify({ + 'status': 'success', + 'report': report + }) + + +def _generate_assessment(summary: dict) -> str: + """Generate an assessment summary based on findings.""" + high = summary.get('high_interest', 0) + review = summary.get('needs_review', 0) + correlations = summary.get('correlations_found', 0) + + if high > 0 or correlations > 0: + return ( + f"ELEVATED CONCERN: {high} high-interest item(s) and " + f"{correlations} cross-protocol correlation(s) detected. " + "Professional TSCM inspection recommended." + ) + elif review > 3: + return ( + f"MODERATE CONCERN: {review} items requiring review. " + "Further analysis recommended to characterize unknown devices." + ) + elif review > 0: + return ( + f"LOW CONCERN: {review} item(s) flagged for review. " + "Likely benign but verification recommended." + ) + else: + return ( + "BASELINE ENVIRONMENT: No significant anomalies detected. " + "Environment appears consistent with expected wireless activity." + ) + + +# ============================================================================= +# Device Identity Endpoints (MAC-Randomization Resistant Detection) +# ============================================================================= + +@tscm_bp.route('/identity/ingest/ble', methods=['POST']) +def ingest_ble_observation(): + """ + Ingest a BLE observation for device identity clustering. + + This endpoint accepts BLE advertisement data and feeds it into the + MAC-randomization resistant device detection engine. + + Expected JSON payload: + { + "timestamp": "2024-01-01T12:00:00", // ISO format or omit for now + "addr": "AA:BB:CC:DD:EE:FF", // BLE address (may be randomized) + "addr_type": "rpa", // public/random_static/rpa/nrpa/unknown + "rssi": -65, // dBm + "tx_power": -10, // dBm (optional) + "adv_type": "ADV_IND", // Advertisement type + "manufacturer_id": 1234, // Company ID (optional) + "manufacturer_data": "0102030405", // Hex string (optional) + "service_uuids": ["uuid1", "uuid2"], // List of UUIDs (optional) + "local_name": "Device Name", // Advertised name (optional) + "appearance": 960, // BLE appearance (optional) + "packet_length": 31 // Total packet length (optional) + } + """ + try: + from utils.tscm.device_identity import ingest_ble_dict + + data = request.get_json() + if not data: + return jsonify({'status': 'error', 'message': 'No data provided'}), 400 + + session = ingest_ble_dict(data) + + return jsonify({ + 'status': 'success', + 'session_id': session.session_id, + 'observation_count': len(session.observations), + }) + + except Exception as e: + logger.error(f"BLE ingestion error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@tscm_bp.route('/identity/ingest/wifi', methods=['POST']) +def ingest_wifi_observation(): + """ + Ingest a WiFi observation for device identity clustering. + + Expected JSON payload: + { + "timestamp": "2024-01-01T12:00:00", + "src_mac": "AA:BB:CC:DD:EE:FF", // Client MAC (may be randomized) + "dst_mac": "11:22:33:44:55:66", // Destination MAC + "bssid": "11:22:33:44:55:66", // AP BSSID + "ssid": "NetworkName", // SSID if available + "frame_type": "probe_request", // Frame type + "rssi": -70, // dBm + "channel": 6, // WiFi channel + "ht_capable": true, // 802.11n capable + "vht_capable": true, // 802.11ac capable + "he_capable": false, // 802.11ax capable + "supported_rates": [1, 2, 5.5, 11], // Supported rates + "vendor_ies": [["001122", 10]], // [(OUI, length), ...] + "probed_ssids": ["ssid1", "ssid2"] // For probe requests + } + """ + try: + from utils.tscm.device_identity import ingest_wifi_dict + + data = request.get_json() + if not data: + return jsonify({'status': 'error', 'message': 'No data provided'}), 400 + + session = ingest_wifi_dict(data) + + return jsonify({ + 'status': 'success', + 'session_id': session.session_id, + 'observation_count': len(session.observations), + }) + + except Exception as e: + logger.error(f"WiFi ingestion error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@tscm_bp.route('/identity/ingest/batch', methods=['POST']) +def ingest_batch_observations(): + """ + Ingest multiple observations in a single request. + + Expected JSON payload: + { + "ble": [, ...], + "wifi": [, ...] + } + """ + try: + from utils.tscm.device_identity import ingest_ble_dict, ingest_wifi_dict + + data = request.get_json() + if not data: + return jsonify({'status': 'error', 'message': 'No data provided'}), 400 + + ble_count = 0 + wifi_count = 0 + + for ble_obs in data.get('ble', []): + ingest_ble_dict(ble_obs) + ble_count += 1 + + for wifi_obs in data.get('wifi', []): + ingest_wifi_dict(wifi_obs) + wifi_count += 1 + + return jsonify({ + 'status': 'success', + 'ble_ingested': ble_count, + 'wifi_ingested': wifi_count, + }) + + except Exception as e: + logger.error(f"Batch ingestion error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@tscm_bp.route('/identity/clusters') +def get_device_clusters(): + """ + Get all device clusters (probable physical device identities). + + Query parameters: + - min_confidence: Minimum cluster confidence (0-1, default 0) + - protocol: Filter by protocol ('ble' or 'wifi') + - risk_level: Filter by risk level ('high', 'medium', 'low', 'informational') + """ + try: + from utils.tscm.device_identity import get_identity_engine + + engine = get_identity_engine() + min_conf = request.args.get('min_confidence', 0, type=float) + protocol = request.args.get('protocol') + risk_filter = request.args.get('risk_level') + + clusters = engine.get_clusters(min_confidence=min_conf) + + if protocol: + clusters = [c for c in clusters if c.protocol == protocol] + + if risk_filter: + clusters = [c for c in clusters if c.risk_level.value == risk_filter] + + return jsonify({ + 'status': 'success', + 'count': len(clusters), + 'clusters': [c.to_dict() for c in clusters], + 'disclaimer': ( + "Clusters represent PROBABLE device identities based on passive " + "fingerprinting. Results are statistical correlations, not " + "confirmed matches. False positives/negatives are expected." + ) + }) + + except Exception as e: + logger.error(f"Get clusters error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@tscm_bp.route('/identity/clusters/high-risk') +def get_high_risk_clusters(): + """Get device clusters with HIGH risk level.""" + try: + from utils.tscm.device_identity import get_identity_engine + + engine = get_identity_engine() + clusters = engine.get_high_risk_clusters() + + return jsonify({ + 'status': 'success', + 'count': len(clusters), + 'clusters': [c.to_dict() for c in clusters], + 'disclaimer': ( + "High-risk classification indicates multiple behavioral indicators " + "consistent with potential surveillance devices. This does NOT " + "confirm surveillance activity. Professional verification required." + ) + }) + + except Exception as e: + logger.error(f"Get high-risk clusters error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@tscm_bp.route('/identity/summary') +def get_identity_summary(): + """ + Get summary of device identity analysis. + + Returns statistics, cluster counts by risk level, and monitoring period. + """ + try: + from utils.tscm.device_identity import get_identity_engine + + engine = get_identity_engine() + summary = engine.get_summary() + + return jsonify({ + 'status': 'success', + 'summary': summary + }) + + except Exception as e: + logger.error(f"Get identity summary error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@tscm_bp.route('/identity/finalize', methods=['POST']) +def finalize_identity_sessions(): + """ + Finalize all active sessions and complete clustering. + + Call this at the end of a monitoring period to ensure all observations + are properly clustered and assessed. + """ + try: + from utils.tscm.device_identity import get_identity_engine + + engine = get_identity_engine() + engine.finalize_all_sessions() + summary = engine.get_summary() + + return jsonify({ + 'status': 'success', + 'message': 'All sessions finalized', + 'summary': summary + }) + + except Exception as e: + logger.error(f"Finalize sessions error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@tscm_bp.route('/identity/reset', methods=['POST']) +def reset_identity_engine(): + """ + Reset the device identity engine. + + Clears all sessions, clusters, and monitoring state. + """ + try: + from utils.tscm.device_identity import reset_identity_engine as reset_engine + + reset_engine() + + return jsonify({ + 'status': 'success', + 'message': 'Device identity engine reset' + }) + + except Exception as e: + logger.error(f"Reset identity engine error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@tscm_bp.route('/identity/cluster/') +def get_cluster_detail(cluster_id: str): + """Get detailed information for a specific cluster.""" + try: + from utils.tscm.device_identity import get_identity_engine + + engine = get_identity_engine() + + if cluster_id not in engine.clusters: + return jsonify({ + 'status': 'error', + 'message': 'Cluster not found' + }), 404 + + cluster = engine.clusters[cluster_id] + + return jsonify({ + 'status': 'success', + 'cluster': cluster.to_dict() + }) + + except Exception as e: + logger.error(f"Get cluster detail error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +# ============================================================================= +# Capabilities & Coverage Endpoints +# ============================================================================= + +@tscm_bp.route('/capabilities') +def get_capabilities(): + """ + Get current system capabilities for TSCM sweeping. + + Returns what the system CAN and CANNOT detect based on OS, + privileges, adapters, and SDR hardware. + """ + try: + from utils.tscm.advanced import detect_sweep_capabilities + + wifi_interface = request.args.get('wifi_interface', '') + bt_adapter = request.args.get('bt_adapter', '') + + caps = detect_sweep_capabilities( + wifi_interface=wifi_interface, + bt_adapter=bt_adapter + ) + + return jsonify({ + 'status': 'success', + 'capabilities': caps.to_dict() + }) + + except Exception as e: + logger.error(f"Get capabilities error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@tscm_bp.route('/sweep//capabilities') +def get_sweep_stored_capabilities(sweep_id: int): + """Get stored capabilities for a specific sweep.""" + from utils.database import get_sweep_capabilities + + caps = get_sweep_capabilities(sweep_id) + if not caps: + return jsonify({'status': 'error', 'message': 'No capabilities stored for this sweep'}), 404 + + return jsonify({ + 'status': 'success', + 'capabilities': caps + }) + + +# ============================================================================= +# Baseline Diff & Health Endpoints +# ============================================================================= + +@tscm_bp.route('/baseline/diff//') +def get_baseline_diff(baseline_id: int, sweep_id: int): + """ + Get comprehensive diff between a baseline and a sweep. + + Shows new devices, missing devices, changed characteristics, + and baseline health assessment. + """ + try: + from utils.tscm.advanced import calculate_baseline_diff + + baseline = get_tscm_baseline(baseline_id) + if not baseline: + return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404 + + sweep = get_tscm_sweep(sweep_id) + if not sweep: + return jsonify({'status': 'error', 'message': 'Sweep not found'}), 404 + + # Get current devices from sweep results + results = sweep.get('results', {}) + if isinstance(results, str): + import json + results = json.loads(results) + + current_wifi = results.get('wifi_devices', []) + current_bt = results.get('bt_devices', []) + current_rf = results.get('rf_signals', []) + + diff = calculate_baseline_diff( + baseline=baseline, + current_wifi=current_wifi, + current_bt=current_bt, + current_rf=current_rf, + sweep_id=sweep_id + ) + + return jsonify({ + 'status': 'success', + 'diff': diff.to_dict() + }) + + except Exception as e: + logger.error(f"Get baseline diff error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@tscm_bp.route('/baseline//health') +def get_baseline_health(baseline_id: int): + """Get health assessment for a baseline.""" + try: + from utils.tscm.advanced import BaselineHealth + from datetime import datetime + + baseline = get_tscm_baseline(baseline_id) + if not baseline: + return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404 + + # Calculate age + created_at = baseline.get('created_at') + age_hours = 0 + if created_at: + if isinstance(created_at, str): + created = datetime.fromisoformat(created_at.replace('Z', '+00:00')) + age_hours = (datetime.now() - created.replace(tzinfo=None)).total_seconds() / 3600 + elif isinstance(created_at, datetime): + age_hours = (datetime.now() - created_at).total_seconds() / 3600 + + # Count devices + total_devices = ( + len(baseline.get('wifi_networks', [])) + + len(baseline.get('bt_devices', [])) + + len(baseline.get('rf_frequencies', [])) + ) + + # Determine health + health = 'healthy' + score = 1.0 + reasons = [] + + if age_hours > 168: + health = 'stale' + score = 0.3 + reasons.append(f'Baseline is {age_hours:.0f} hours old (over 1 week)') + elif age_hours > 72: + health = 'noisy' + score = 0.6 + reasons.append(f'Baseline is {age_hours:.0f} hours old (over 3 days)') + + if total_devices < 3: + score -= 0.2 + reasons.append(f'Baseline has few devices ({total_devices})') + if health == 'healthy': + health = 'noisy' + + return jsonify({ + 'status': 'success', + 'health': { + 'status': health, + 'score': round(max(0, score), 2), + 'age_hours': round(age_hours, 1), + 'total_devices': total_devices, + 'reasons': reasons, + } + }) + + except Exception as e: + logger.error(f"Get baseline health error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +# ============================================================================= +# Device Timeline Endpoints +# ============================================================================= + +@tscm_bp.route('/device//timeline') +def get_device_timeline_endpoint(identifier: str): + """ + Get timeline of observations for a device. + + Shows behavior over time including RSSI stability, presence, + and meeting window correlation. + """ + try: + from utils.tscm.advanced import get_timeline_manager + from utils.database import get_device_timeline + + protocol = request.args.get('protocol', 'bluetooth') + since_hours = request.args.get('since_hours', 24, type=int) + + # Try in-memory timeline first + manager = get_timeline_manager() + timeline = manager.get_timeline(identifier, protocol) + + # Also get stored timeline from database + stored = get_device_timeline(identifier, since_hours=since_hours) + + result = { + 'identifier': identifier, + 'protocol': protocol, + 'observations': stored, + } + + if timeline: + result['metrics'] = { + 'first_seen': timeline.first_seen.isoformat() if timeline.first_seen else None, + 'last_seen': timeline.last_seen.isoformat() if timeline.last_seen else None, + 'total_observations': timeline.total_observations, + 'presence_ratio': round(timeline.presence_ratio, 2), + } + result['signal'] = { + 'rssi_min': timeline.rssi_min, + 'rssi_max': timeline.rssi_max, + 'rssi_mean': round(timeline.rssi_mean, 1) if timeline.rssi_mean else None, + 'stability': round(timeline.rssi_stability, 2), + } + result['movement'] = { + 'appears_stationary': timeline.appears_stationary, + 'pattern': timeline.movement_pattern, + } + result['meeting_correlation'] = { + 'correlated': timeline.meeting_correlated, + 'observations_during_meeting': timeline.meeting_observations, + } + + return jsonify({ + 'status': 'success', + 'timeline': result + }) + + except Exception as e: + logger.error(f"Get device timeline error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@tscm_bp.route('/timelines') +def get_all_device_timelines(): + """Get all device timelines.""" + try: + from utils.tscm.advanced import get_timeline_manager + + manager = get_timeline_manager() + timelines = manager.get_all_timelines() + + return jsonify({ + 'status': 'success', + 'count': len(timelines), + 'timelines': [t.to_dict() for t in timelines] + }) + + except Exception as e: + logger.error(f"Get all timelines error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +# ============================================================================= +# Known-Good Registry (Whitelist) Endpoints +# ============================================================================= + +@tscm_bp.route('/known-devices', methods=['GET']) +def list_known_devices(): + """List all known-good devices.""" + from utils.database import get_all_known_devices + + location = request.args.get('location') + scope = request.args.get('scope') + + devices = get_all_known_devices(location=location, scope=scope) + + return jsonify({ + 'status': 'success', + 'count': len(devices), + 'devices': devices + }) + + +@tscm_bp.route('/known-devices', methods=['POST']) +def add_known_device_endpoint(): + """ + Add a device to the known-good registry. + + Known devices remain visible but receive reduced risk scores. + They are NOT suppressed from reports (preserves audit trail). + """ + from utils.database import add_known_device + + data = request.get_json() or {} + + identifier = data.get('identifier') + protocol = data.get('protocol') + + if not identifier or not protocol: + return jsonify({ + 'status': 'error', + 'message': 'identifier and protocol are required' + }), 400 + + device_id = add_known_device( + identifier=identifier, + protocol=protocol, + name=data.get('name'), + description=data.get('description'), + location=data.get('location'), + scope=data.get('scope', 'global'), + added_by=data.get('added_by'), + score_modifier=data.get('score_modifier', -2), + metadata=data.get('metadata') + ) + + return jsonify({ + 'status': 'success', + 'message': 'Device added to known-good registry', + 'device_id': device_id + }) + + +@tscm_bp.route('/known-devices/', methods=['GET']) +def get_known_device_endpoint(identifier: str): + """Get a known device by identifier.""" + from utils.database import get_known_device + + device = get_known_device(identifier) + if not device: + return jsonify({'status': 'error', 'message': 'Device not found'}), 404 + + return jsonify({ + 'status': 'success', + 'device': device + }) + + +@tscm_bp.route('/known-devices/', methods=['DELETE']) +def delete_known_device_endpoint(identifier: str): + """Remove a device from the known-good registry.""" + from utils.database import delete_known_device + + success = delete_known_device(identifier) + if not success: + return jsonify({'status': 'error', 'message': 'Device not found'}), 404 + + return jsonify({ + 'status': 'success', + 'message': 'Device removed from known-good registry' + }) + + +@tscm_bp.route('/known-devices/check/') +def check_known_device(identifier: str): + """Check if a device is in the known-good registry.""" + from utils.database import is_known_good_device + + location = request.args.get('location') + result = is_known_good_device(identifier, location=location) + + return jsonify({ + 'status': 'success', + 'is_known': result is not None, + 'details': result + }) + + +# ============================================================================= +# Case Management Endpoints +# ============================================================================= + +@tscm_bp.route('/cases', methods=['GET']) +def list_cases(): + """List all TSCM cases.""" + from utils.database import get_all_tscm_cases + + status = request.args.get('status') + limit = request.args.get('limit', 50, type=int) + + cases = get_all_tscm_cases(status=status, limit=limit) + + return jsonify({ + 'status': 'success', + 'count': len(cases), + 'cases': cases + }) + + +@tscm_bp.route('/cases', methods=['POST']) +def create_case(): + """Create a new TSCM case.""" + from utils.database import create_tscm_case + + data = request.get_json() or {} + + name = data.get('name') + if not name: + return jsonify({'status': 'error', 'message': 'name is required'}), 400 + + case_id = create_tscm_case( + name=name, + description=data.get('description'), + location=data.get('location'), + priority=data.get('priority', 'normal'), + created_by=data.get('created_by'), + metadata=data.get('metadata') + ) + + return jsonify({ + 'status': 'success', + 'message': 'Case created', + 'case_id': case_id + }) + + +@tscm_bp.route('/cases/', methods=['GET']) +def get_case(case_id: int): + """Get a TSCM case with all linked sweeps, threats, and notes.""" + from utils.database import get_tscm_case + + case = get_tscm_case(case_id) + if not case: + return jsonify({'status': 'error', 'message': 'Case not found'}), 404 + + return jsonify({ + 'status': 'success', + 'case': case + }) + + +@tscm_bp.route('/cases/', methods=['PUT']) +def update_case(case_id: int): + """Update a TSCM case.""" + from utils.database import update_tscm_case + + data = request.get_json() or {} + + success = update_tscm_case( + case_id=case_id, + status=data.get('status'), + priority=data.get('priority'), + assigned_to=data.get('assigned_to'), + notes=data.get('notes') + ) + + if not success: + return jsonify({'status': 'error', 'message': 'Case not found'}), 404 + + return jsonify({ + 'status': 'success', + 'message': 'Case updated' + }) + + +@tscm_bp.route('/cases//sweeps/', methods=['POST']) +def link_sweep_to_case(case_id: int, sweep_id: int): + """Link a sweep to a case.""" + from utils.database import add_sweep_to_case + + success = add_sweep_to_case(case_id, sweep_id) + + return jsonify({ + 'status': 'success' if success else 'error', + 'message': 'Sweep linked to case' if success else 'Already linked or not found' + }) + + +@tscm_bp.route('/cases//threats/', methods=['POST']) +def link_threat_to_case(case_id: int, threat_id: int): + """Link a threat to a case.""" + from utils.database import add_threat_to_case + + success = add_threat_to_case(case_id, threat_id) + + return jsonify({ + 'status': 'success' if success else 'error', + 'message': 'Threat linked to case' if success else 'Already linked or not found' + }) + + +@tscm_bp.route('/cases//notes', methods=['POST']) +def add_note_to_case(case_id: int): + """Add a note to a case.""" + from utils.database import add_case_note + + data = request.get_json() or {} + + content = data.get('content') + if not content: + return jsonify({'status': 'error', 'message': 'content is required'}), 400 + + note_id = add_case_note( + case_id=case_id, + content=content, + note_type=data.get('note_type', 'general'), + created_by=data.get('created_by') + ) + + return jsonify({ + 'status': 'success', + 'message': 'Note added', + 'note_id': note_id + }) + + +# ============================================================================= +# Meeting Window Enhanced Endpoints +# ============================================================================= + +@tscm_bp.route('/meeting/start-tracked', methods=['POST']) +def start_tracked_meeting(): + """ + Start a tracked meeting window with database persistence. + + Tracks devices first seen during meeting and behavior changes. + """ + from utils.database import start_meeting_window + from utils.tscm.advanced import get_timeline_manager + + data = request.get_json() or {} + + meeting_id = start_meeting_window( + sweep_id=_current_sweep_id, + name=data.get('name'), + location=data.get('location'), + notes=data.get('notes') + ) + + # Start meeting in correlation engine + correlation = get_correlation_engine() + correlation.start_meeting_window() + + # Start in timeline manager + manager = get_timeline_manager() + manager.start_meeting_window() + + _emit_event('meeting_started', { + 'meeting_id': meeting_id, + 'timestamp': datetime.now().isoformat(), + 'name': data.get('name'), + }) + + return jsonify({ + 'status': 'success', + 'message': 'Tracked meeting window started', + 'meeting_id': meeting_id + }) + + +@tscm_bp.route('/meeting//end', methods=['POST']) +def end_tracked_meeting(meeting_id: int): + """End a tracked meeting window.""" + from utils.database import end_meeting_window + from utils.tscm.advanced import get_timeline_manager + + success = end_meeting_window(meeting_id) + if not success: + return jsonify({'status': 'error', 'message': 'Meeting not found or already ended'}), 404 + + # End in correlation engine + correlation = get_correlation_engine() + correlation.end_meeting_window() + + # End in timeline manager + manager = get_timeline_manager() + manager.end_meeting_window() + + _emit_event('meeting_ended', { + 'meeting_id': meeting_id, + 'timestamp': datetime.now().isoformat() + }) + + return jsonify({ + 'status': 'success', + 'message': 'Meeting window ended' + }) + + +@tscm_bp.route('/meeting//summary') +def get_meeting_summary_endpoint(meeting_id: int): + """Get detailed summary of device activity during a meeting.""" + try: + from utils.database import get_meeting_windows + from utils.tscm.advanced import generate_meeting_summary, get_timeline_manager + + # Get meeting window + windows = get_meeting_windows(_current_sweep_id or 0) + meeting = None + for w in windows: + if w.get('id') == meeting_id: + meeting = w + break + + if not meeting: + return jsonify({'status': 'error', 'message': 'Meeting not found'}), 404 + + # Get timelines and profiles + manager = get_timeline_manager() + timelines = manager.get_all_timelines() + + correlation = get_correlation_engine() + profiles = [p.to_dict() for p in correlation.device_profiles.values()] + + summary = generate_meeting_summary(meeting, timelines, profiles) + + return jsonify({ + 'status': 'success', + 'summary': summary.to_dict() + }) + + except Exception as e: + logger.error(f"Get meeting summary error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@tscm_bp.route('/meeting/active') +def get_active_meeting(): + """Get currently active meeting window.""" + from utils.database import get_active_meeting_window + + meeting = get_active_meeting_window(_current_sweep_id) + + return jsonify({ + 'status': 'success', + 'meeting': meeting, + 'is_active': meeting is not None + }) + + +# ============================================================================= +# PDF Report & Technical Annex Endpoints +# ============================================================================= + +@tscm_bp.route('/report/pdf') +def get_pdf_report(): + """ + Generate client-safe PDF report. + + Contains executive summary, findings by risk tier, meeting window + summary, and mandatory disclaimers. + """ + try: + from utils.tscm.reports import generate_report, get_pdf_report + from utils.tscm.advanced import detect_sweep_capabilities, get_timeline_manager + + sweep_id = request.args.get('sweep_id', _current_sweep_id, type=int) + if not sweep_id: + return jsonify({'status': 'error', 'message': 'No sweep specified'}), 400 + + sweep = get_tscm_sweep(sweep_id) + if not sweep: + return jsonify({'status': 'error', 'message': 'Sweep not found'}), 404 + + # Get data for report + correlation = get_correlation_engine() + profiles = [p.to_dict() for p in correlation.device_profiles.values()] + caps = detect_sweep_capabilities().to_dict() + + manager = get_timeline_manager() + timelines = [t.to_dict() for t in manager.get_all_timelines()] + + # Generate report + report = generate_report( + sweep_id=sweep_id, + sweep_data=sweep, + device_profiles=profiles, + capabilities=caps, + timelines=timelines + ) + + pdf_content = get_pdf_report(report) + + return Response( + pdf_content, + mimetype='text/plain', + headers={ + 'Content-Disposition': f'attachment; filename=tscm_report_{sweep_id}.txt' + } + ) + + except Exception as e: + logger.error(f"Generate PDF report error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@tscm_bp.route('/report/annex') +def get_technical_annex(): + """ + Generate technical annex (JSON + CSV). + + Contains device timelines, all indicators, and detailed data + for audit purposes. No packet data included. + """ + try: + from utils.tscm.reports import generate_report, get_json_annex, get_csv_annex + from utils.tscm.advanced import detect_sweep_capabilities, get_timeline_manager + + sweep_id = request.args.get('sweep_id', _current_sweep_id, type=int) + format_type = request.args.get('format', 'json') + + if not sweep_id: + return jsonify({'status': 'error', 'message': 'No sweep specified'}), 400 + + sweep = get_tscm_sweep(sweep_id) + if not sweep: + return jsonify({'status': 'error', 'message': 'Sweep not found'}), 404 + + # Get data for report + correlation = get_correlation_engine() + profiles = [p.to_dict() for p in correlation.device_profiles.values()] + caps = detect_sweep_capabilities().to_dict() + + manager = get_timeline_manager() + timelines = [t.to_dict() for t in manager.get_all_timelines()] + + # Generate report + report = generate_report( + sweep_id=sweep_id, + sweep_data=sweep, + device_profiles=profiles, + capabilities=caps, + timelines=timelines + ) + + if format_type == 'csv': + csv_content = get_csv_annex(report) + return Response( + csv_content, + mimetype='text/csv', + headers={ + 'Content-Disposition': f'attachment; filename=tscm_annex_{sweep_id}.csv' + } + ) + else: + annex = get_json_annex(report) + return jsonify({ + 'status': 'success', + 'annex': annex + }) + + except Exception as e: + logger.error(f"Generate technical annex error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +# ============================================================================= +# WiFi Advanced Indicators Endpoints +# ============================================================================= + +@tscm_bp.route('/wifi/advanced-indicators') +def get_wifi_advanced_indicators(): + """ + Get advanced WiFi indicators (Evil Twin, Probes, Deauth). + + These indicators require analysis of WiFi patterns. + Some features require monitor mode. + """ + try: + from utils.tscm.advanced import get_wifi_detector + + detector = get_wifi_detector() + + return jsonify({ + 'status': 'success', + 'indicators': detector.get_all_indicators(), + 'unavailable_features': detector.get_unavailable_features(), + 'disclaimer': ( + "All indicators represent pattern detections, NOT confirmed attacks. " + "Further investigation is required." + ) + }) + + except Exception as e: + logger.error(f"Get WiFi indicators error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@tscm_bp.route('/wifi/analyze-network', methods=['POST']) +def analyze_wifi_network(): + """ + Analyze a WiFi network for evil twin patterns. + + Compares against known networks to detect SSID spoofing. + """ + try: + from utils.tscm.advanced import get_wifi_detector + + data = request.get_json() or {} + detector = get_wifi_detector() + + # Set known networks from baseline if available + baseline = get_active_tscm_baseline() + if baseline: + detector.set_known_networks(baseline.get('wifi_networks', [])) + + indicators = detector.analyze_network(data) + + return jsonify({ + 'status': 'success', + 'indicators': [i.to_dict() for i in indicators] + }) + + except Exception as e: + logger.error(f"Analyze WiFi network error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +# ============================================================================= +# Bluetooth Risk Explainability Endpoints +# ============================================================================= + +@tscm_bp.route('/bluetooth//explain') +def explain_bluetooth_risk(identifier: str): + """ + Get human-readable risk explanation for a BLE device. + + Includes proximity estimate, tracker explanation, and + recommended actions. + """ + try: + from utils.tscm.advanced import generate_ble_risk_explanation + + # Get device from correlation engine + correlation = get_correlation_engine() + profile = None + key = f"bluetooth:{identifier.upper()}" + if key in correlation.device_profiles: + profile = correlation.device_profiles[key].to_dict() + + # Try to find device info + device = {'mac': identifier} + if profile: + device['name'] = profile.get('name') + device['rssi'] = profile.get('rssi_samples', [None])[-1] if profile.get('rssi_samples') else None + + # Check meeting status + is_meeting = correlation.is_during_meeting() + + explanation = generate_ble_risk_explanation(device, profile, is_meeting) + + return jsonify({ + 'status': 'success', + 'explanation': explanation.to_dict() + }) + + except Exception as e: + logger.error(f"Explain BLE risk error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@tscm_bp.route('/bluetooth//proximity') +def get_bluetooth_proximity(identifier: str): + """Get proximity estimate for a BLE device.""" + try: + from utils.tscm.advanced import estimate_ble_proximity + + rssi = request.args.get('rssi', type=int) + if rssi is None: + # Try to get from correlation engine + correlation = get_correlation_engine() + key = f"bluetooth:{identifier.upper()}" + if key in correlation.device_profiles: + profile = correlation.device_profiles[key] + if profile.rssi_samples: + rssi = profile.rssi_samples[-1] + + if rssi is None: + return jsonify({ + 'status': 'error', + 'message': 'RSSI value required' + }), 400 + + proximity, explanation, distance = estimate_ble_proximity(rssi) + + return jsonify({ + 'status': 'success', + 'proximity': { + 'estimate': proximity.value, + 'explanation': explanation, + 'estimated_distance': distance, + 'rssi_used': rssi, + }, + 'disclaimer': ( + "Proximity estimates are approximate and affected by " + "environment, obstacles, and device characteristics." + ) + }) + + except Exception as e: + logger.error(f"Get BLE proximity error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +# ============================================================================= +# Operator Playbook Endpoints +# ============================================================================= + +@tscm_bp.route('/playbooks') +def list_playbooks(): + """List all available operator playbooks.""" + try: + from utils.tscm.advanced import PLAYBOOKS + + # Return as array with id field for JavaScript compatibility + playbooks_list = [] + for pid, pb in PLAYBOOKS.items(): + pb_dict = pb.to_dict() + pb_dict['id'] = pid + pb_dict['name'] = pb_dict.get('title', pid) + pb_dict['category'] = pb_dict.get('risk_level', 'general') + playbooks_list.append(pb_dict) + + return jsonify({ + 'status': 'success', + 'playbooks': playbooks_list + }) + + except Exception as e: + logger.error(f"List playbooks error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@tscm_bp.route('/playbooks/') +def get_playbook(playbook_id: str): + """Get a specific playbook.""" + try: + from utils.tscm.advanced import PLAYBOOKS + + if playbook_id not in PLAYBOOKS: + return jsonify({'status': 'error', 'message': 'Playbook not found'}), 404 + + return jsonify({ + 'status': 'success', + 'playbook': PLAYBOOKS[playbook_id].to_dict() + }) + + except Exception as e: + logger.error(f"Get playbook error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@tscm_bp.route('/findings//playbook') +def get_finding_playbook(identifier: str): + """Get recommended playbook for a specific finding.""" + try: + from utils.tscm.advanced import get_playbook_for_finding + + # Get profile + correlation = get_correlation_engine() + profile = None + + for protocol in ['bluetooth', 'wifi', 'rf']: + key = f"{protocol}:{identifier.upper()}" + if key in correlation.device_profiles: + profile = correlation.device_profiles[key].to_dict() + break + + if not profile: + return jsonify({'status': 'error', 'message': 'Finding not found'}), 404 + + playbook = get_playbook_for_finding( + risk_level=profile.get('risk_level', 'informational'), + indicators=profile.get('indicators', []) + ) + + return jsonify({ + 'status': 'success', + 'playbook': playbook.to_dict(), + 'suggested_next_steps': [ + f"Step {s.step_number}: {s.action}" + for s in playbook.steps[:3] + ] + }) + + except Exception as e: + logger.error(f"Get finding playbook error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 diff --git a/static/css/modes/tscm.css b/static/css/modes/tscm.css index 63a43a5..89f1153 100644 --- a/static/css/modes/tscm.css +++ b/static/css/modes/tscm.css @@ -45,6 +45,7 @@ padding: 12px; background: rgba(0,0,0,0.3); border-radius: 8px; + flex-wrap: wrap; } .tscm-threat-banner .threat-card { flex: 1; @@ -200,6 +201,17 @@ margin-left: 6px; font-size: 10px; } +.known-badge { + margin-left: 6px; + font-size: 9px; + padding: 1px 4px; + border-radius: 3px; + background: rgba(0, 255, 136, 0.2); + color: #00ff88; + border: 1px solid rgba(0, 255, 136, 0.4); + text-transform: uppercase; + letter-spacing: 0.4px; +} .tscm-device-header { display: flex; justify-content: space-between; @@ -465,6 +477,18 @@ color: var(--text-dim); width: 40%; } +.device-detail-id { + display: inline-block; + margin-left: 6px; + font-size: 10px; + color: var(--text-muted); + font-family: var(--font-mono); +} +.tscm-more-hint { + margin-top: 6px; + font-size: 10px; + color: var(--text-muted); +} .indicator-list { display: flex; flex-direction: column; @@ -882,6 +906,42 @@ margin-left: auto; } +/* Filters */ +.tscm-filter-bar { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px 12px; + margin-bottom: 12px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + align-items: flex-end; +} +.tscm-filter-group { + display: flex; + flex-direction: column; + gap: 4px; +} +.tscm-filter-group label { + font-size: 9px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.6px; +} +.tscm-filter-group select { + background: rgba(0, 0, 0, 0.4); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 4px 6px; + font-size: 10px; +} +.tscm-filter-status { + margin-left: auto; + font-size: 10px; + color: var(--text-muted); +} + /* Advanced Modal Styles */ .tscm-advanced-modal { max-width: 600px; @@ -1461,3 +1521,156 @@ width: 10px; height: 10px; } + +/* Meeting banner actions */ +.tscm-meeting-banner { + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} +.tscm-meeting-banner .meeting-actions { + margin-left: auto; +} + +/* Case linking */ +.tscm-case-link-banner { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 8px 10px; + margin-bottom: 10px; + background: rgba(74, 158, 255, 0.12); + border: 1px solid rgba(74, 158, 255, 0.3); + border-radius: 6px; + font-size: 11px; +} +.case-actions { + margin-top: 8px; +} +.tscm-case-link-btn { + margin-left: auto; + font-size: 9px; + padding: 2px 6px; + background: rgba(74, 158, 255, 0.2); + color: #9ed0ff; + border: 1px solid rgba(74, 158, 255, 0.4); + border-radius: 3px; + cursor: pointer; +} + +/* Schedules */ +.tscm-schedule-form { + display: grid; + gap: 10px; +} +.tscm-schedule-list { + display: grid; + gap: 10px; +} +.tscm-schedule-item { + padding: 10px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.2); + border: 1px solid var(--border-color); +} +.tscm-schedule-item.enabled { + border-color: rgba(0, 255, 136, 0.35); +} +.tscm-schedule-item.disabled { + opacity: 0.7; +} +.tscm-schedule-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; +} +.tscm-schedule-status { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); +} +.tscm-schedule-meta { + font-size: 10px; + color: var(--text-muted); + margin-bottom: 4px; +} +.tscm-schedule-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-top: 6px; +} + +/* Meeting summary */ +.tscm-summary-list { + display: grid; + gap: 8px; +} +.tscm-summary-item { + padding: 8px 10px; + background: rgba(0, 0, 0, 0.2); + border: 1px solid var(--border-color); + border-radius: 6px; +} +.tscm-summary-meta { + font-size: 10px; + color: var(--text-muted); + margin-top: 4px; +} +.tscm-summary-risk { + font-size: 10px; + color: #ff9933; + margin-top: 4px; +} + +/* Case notes */ +.tscm-case-notes { + display: grid; + gap: 8px; + margin-bottom: 10px; +} +.tscm-case-note { + padding: 8px 10px; + background: rgba(0, 0, 0, 0.2); + border: 1px solid var(--border-color); + border-radius: 6px; +} +.tscm-case-note-meta { + display: flex; + justify-content: space-between; + font-size: 9px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; +} +.tscm-case-note-type { + color: var(--accent-cyan); +} +.tscm-case-note-content { + font-size: 11px; + line-height: 1.4; + white-space: pre-wrap; +} +.tscm-case-note-author { + font-size: 9px; + color: var(--text-muted); + margin-top: 4px; +} +.tscm-case-note-form { + display: grid; + gap: 6px; + margin-top: 8px; +} +.tscm-case-note-form textarea { + min-height: 80px; +} +.tscm-case-note-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-top: 6px; +} diff --git a/static/js/core/agents.js b/static/js/core/agents.js index cc4d76b..72515a2 100644 --- a/static/js/core/agents.js +++ b/static/js/core/agents.js @@ -865,15 +865,12 @@ function connectAgentStream(mode, onMessage) { } let streamUrl; - if (currentAgent === 'local') { - streamUrl = `/${mode}/stream`; - } else { - // For remote agents, we could either: - // 1. Use the multi-agent stream: /controller/stream/all - // 2. Or proxy through controller (not implemented yet) - // For now, use multi-agent stream which includes agent_name tagging - streamUrl = '/controller/stream/all'; - } + if (currentAgent === 'local') { + streamUrl = `/${mode}/stream`; + } else { + // For remote agents, proxy SSE through controller + streamUrl = `/controller/agents/${currentAgent}/${mode}/stream`; + } agentEventSource = new EventSource(streamUrl); @@ -881,15 +878,7 @@ function connectAgentStream(mode, onMessage) { try { const data = JSON.parse(event.data); - // If using multi-agent stream, filter by current agent if needed - if (streamUrl === '/controller/stream/all' && currentAgent !== 'local') { - const agent = agents.find(a => a.id == currentAgent); - if (agent && data.agent_name && data.agent_name !== agent.name) { - return; // Skip messages from other agents - } - } - - onMessage(data); + onMessage(data); } catch (e) { console.error('Error parsing SSE message:', e); } diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index dc97516..026cfa8 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -879,6 +879,7 @@ const WiFiMode = (function() { updateNetworkRow(network); updateStats(); updateProximityRadar(); + updateChannelChart(); if (onNetworkUpdate) onNetworkUpdate(network); } @@ -1420,9 +1421,15 @@ const WiFiMode = (function() { return Object.values(stats).filter(s => s.ap_count > 0 || [1, 6, 11, 36, 40, 44, 48, 149, 153, 157, 161, 165].includes(s.channel)); } - function updateChannelChart(band = '2.4') { + function updateChannelChart(band) { if (typeof ChannelChart === 'undefined') return; + // Use the currently active band tab if no band specified + if (!band) { + const activeTab = elements.channelBandTabs && elements.channelBandTabs.querySelector('.channel-band-tab.active'); + band = activeTab ? activeTab.dataset.band : '2.4'; + } + // Recalculate channel stats from networks if needed if (channelStats.length === 0 && networks.size > 0) { channelStats = calculateChannelStats(); diff --git a/templates/index.html b/templates/index.html index c68524b..7aeed4e 100644 --- a/templates/index.html +++ b/templates/index.html @@ -13,6 +13,11 @@ document.write(''); window._showDisclaimerOnLoad = true; } + // If navigating with a mode param (e.g. /?mode=listening), hide welcome immediately + // to prevent flash of welcome screen before JS applies the mode + else if (new URLSearchParams(window.location.search).get('mode')) { + document.write(''); + } diff --git a/templates/partials/modes/tscm.html b/templates/partials/modes/tscm.html index 43f77a9..fe9f617 100644 --- a/templates/partials/modes/tscm.html +++ b/templates/partials/modes/tscm.html @@ -146,6 +146,9 @@ +
Devices detected during meetings get flagged
@@ -159,12 +162,18 @@ + + diff --git a/utils/database.py b/utils/database.py index a11c46b..6467c27 100644 --- a/utils/database.py +++ b/utils/database.py @@ -1205,21 +1205,127 @@ def get_all_known_devices( ] -def delete_known_device(identifier: str) -> bool: - """Remove a device from the known-good registry.""" - with get_db() as conn: - cursor = conn.execute( - 'DELETE FROM tscm_known_devices WHERE identifier = ?', - (identifier.upper(),) - ) - return cursor.rowcount > 0 - - -def is_known_good_device(identifier: str, location: str | None = None) -> dict | None: - """Check if a device is in the known-good registry for a location.""" - with get_db() as conn: - if location: - cursor = conn.execute(''' +def delete_known_device(identifier: str) -> bool: + """Remove a device from the known-good registry.""" + with get_db() as conn: + cursor = conn.execute( + 'DELETE FROM tscm_known_devices WHERE identifier = ?', + (identifier.upper(),) + ) + return cursor.rowcount > 0 + + +# ============================================================================= +# TSCM Schedule Functions +# ============================================================================= + +def create_tscm_schedule( + name: str, + cron_expression: str, + sweep_type: str = 'standard', + baseline_id: int | None = None, + zone_name: str | None = None, + enabled: bool = True, + notify_on_threat: bool = True, + notify_email: str | None = None, + last_run: str | None = None, + next_run: str | None = None, +) -> int: + """Create a new TSCM sweep schedule.""" + with get_db() as conn: + cursor = conn.execute(''' + INSERT INTO tscm_schedules + (name, baseline_id, zone_name, cron_expression, sweep_type, + enabled, last_run, next_run, notify_on_threat, notify_email) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + name, + baseline_id, + zone_name, + cron_expression, + sweep_type, + 1 if enabled else 0, + last_run, + next_run, + 1 if notify_on_threat else 0, + notify_email, + )) + return cursor.lastrowid + + +def get_tscm_schedule(schedule_id: int) -> dict | None: + """Get a TSCM schedule by ID.""" + with get_db() as conn: + cursor = conn.execute( + 'SELECT * FROM tscm_schedules WHERE id = ?', + (schedule_id,) + ) + row = cursor.fetchone() + return dict(row) if row else None + + +def get_all_tscm_schedules( + enabled: bool | None = None, + limit: int = 200 +) -> list[dict]: + """Get all TSCM schedules.""" + conditions = [] + params = [] + + if enabled is not None: + conditions.append('enabled = ?') + params.append(1 if enabled else 0) + + where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else '' + params.append(limit) + + with get_db() as conn: + cursor = conn.execute(f''' + SELECT * FROM tscm_schedules + {where_clause} + ORDER BY id DESC + LIMIT ? + ''', params) + return [dict(row) for row in cursor] + + +def update_tscm_schedule(schedule_id: int, **fields) -> bool: + """Update a TSCM schedule.""" + if not fields: + return False + + updates = [] + params = [] + + for key, value in fields.items(): + updates.append(f'{key} = ?') + params.append(value) + + params.append(schedule_id) + + with get_db() as conn: + cursor = conn.execute( + f'UPDATE tscm_schedules SET {", ".join(updates)} WHERE id = ?', + params + ) + return cursor.rowcount > 0 + + +def delete_tscm_schedule(schedule_id: int) -> bool: + """Delete a TSCM schedule.""" + with get_db() as conn: + cursor = conn.execute( + 'DELETE FROM tscm_schedules WHERE id = ?', + (schedule_id,) + ) + return cursor.rowcount > 0 + + +def is_known_good_device(identifier: str, location: str | None = None) -> dict | None: + """Check if a device is in the known-good registry for a location.""" + with get_db() as conn: + if location: + cursor = conn.execute(''' SELECT * FROM tscm_known_devices WHERE identifier = ? AND (location = ? OR scope = 'global') ''', (identifier.upper(), location)) diff --git a/utils/sstv.py b/utils/sstv.py index 86d179a..973df4f 100644 --- a/utils/sstv.py +++ b/utils/sstv.py @@ -245,10 +245,11 @@ class SSTVDecoder: # Doppler tracking self._doppler_tracker = DopplerTracker('ISS') self._doppler_enabled = False - self._last_doppler_info: DopplerInfo | None = None - - # Ensure output directory exists - self._output_dir.mkdir(parents=True, exist_ok=True) + self._last_doppler_info: DopplerInfo | None = None + self._file_decoder: str | None = None + + # Ensure output directory exists + self._output_dir.mkdir(parents=True, exist_ok=True) # Detect available decoder self._decoder = self._detect_decoder() @@ -265,21 +266,23 @@ class SSTVDecoder: def _detect_decoder(self) -> str | None: """Detect which SSTV decoder is available.""" # Check for slowrx (command-line SSTV decoder) - try: - result = subprocess.run(['which', 'slowrx'], capture_output=True, timeout=5) - if result.returncode == 0: - return 'slowrx' - except Exception: - pass + try: + result = subprocess.run(['which', 'slowrx'], capture_output=True, timeout=5) + if result.returncode == 0: + self._file_decoder = 'slowrx' + return 'slowrx' + except Exception: + pass # Note: qsstv is GUI-only and not suitable for headless/server operation - # Check for Python sstv package - try: - import sstv - return 'python-sstv' - except ImportError: - pass + # Check for Python sstv package + try: + import sstv + self._file_decoder = 'python-sstv' + return None + except ImportError: + pass logger.warning("No SSTV decoder found. Install slowrx (apt install slowrx) or python sstv package. Note: qsstv is GUI-only and not supported for headless operation.") return None @@ -691,11 +694,13 @@ class SSTVDecoder: if not audio_path.exists(): raise FileNotFoundError(f"Audio file not found: {audio_path}") - images = [] - - if self._decoder == 'slowrx': - # Use slowrx with file input - output_file = self._output_dir / f"sstv_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png" + images = [] + + decoder = self._decoder or self._file_decoder + + if decoder == 'slowrx': + # Use slowrx with file input + output_file = self._output_dir / f"sstv_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png" cmd = ['slowrx', '-o', str(self._output_dir), str(audio_path)] result = subprocess.run(cmd, capture_output=True, timeout=300) @@ -715,10 +720,10 @@ class SSTVDecoder: ) images.append(image) - elif self._decoder == 'python-sstv': - # Use Python sstv library - try: - from sstv.decode import SSTVDecoder as PythonSSTVDecoder + elif decoder == 'python-sstv': + # Use Python sstv library + try: + from sstv.decode import SSTVDecoder as PythonSSTVDecoder from PIL import Image decoder = PythonSSTVDecoder(str(audio_path)) diff --git a/utils/tscm/correlation.py b/utils/tscm/correlation.py index 59fba1d..e72e34b 100644 --- a/utils/tscm/correlation.py +++ b/utils/tscm/correlation.py @@ -154,9 +154,12 @@ class DeviceProfile: # Correlation correlated_devices: list[str] = field(default_factory=list) - # Output - confidence: float = 0.0 - recommended_action: str = 'monitor' + # Output + confidence: float = 0.0 + recommended_action: str = 'monitor' + known_device: bool = False + known_device_name: Optional[str] = None + score_modifier: int = 0 def add_rssi_sample(self, rssi: int) -> None: """Add an RSSI sample with timestamp.""" @@ -190,9 +193,9 @@ class DeviceProfile: )) self._recalculate_score() - def _recalculate_score(self) -> None: - """Recalculate total score and risk level.""" - self.total_score = sum(i.score for i in self.indicators) + def _recalculate_score(self) -> None: + """Recalculate total score and risk level.""" + self.total_score = sum(i.score for i in self.indicators) if self.total_score >= 6: self.risk_level = RiskLevel.HIGH_INTEREST @@ -204,9 +207,29 @@ class DeviceProfile: self.risk_level = RiskLevel.INFORMATIONAL self.recommended_action = 'monitor' - # Calculate confidence based on number and quality of indicators - indicator_count = len(self.indicators) - self.confidence = min(1.0, (indicator_count * 0.15) + (self.total_score * 0.05)) + # Calculate confidence based on number and quality of indicators + indicator_count = len(self.indicators) + self.confidence = min(1.0, (indicator_count * 0.15) + (self.total_score * 0.05)) + + def apply_score_modifier(self, modifier: int | None) -> None: + """Apply a score modifier (e.g., known-good device adjustment).""" + base_score = sum(i.score for i in self.indicators) + modifier_val = int(modifier) if modifier is not None else 0 + self.score_modifier = modifier_val + self.total_score = max(0, base_score + modifier_val) + + if self.total_score >= 6: + self.risk_level = RiskLevel.HIGH_INTEREST + self.recommended_action = 'investigate' + elif self.total_score >= 3: + self.risk_level = RiskLevel.NEEDS_REVIEW + self.recommended_action = 'review' + else: + self.risk_level = RiskLevel.INFORMATIONAL + self.recommended_action = 'monitor' + + indicator_count = len(self.indicators) + self.confidence = min(1.0, (indicator_count * 0.15) + (self.total_score * 0.05)) def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" @@ -231,12 +254,15 @@ class DeviceProfile: } for i in self.indicators ], - 'total_score': self.total_score, - 'risk_level': self.risk_level.value, - 'confidence': round(self.confidence, 2), - 'recommended_action': self.recommended_action, - 'correlated_devices': self.correlated_devices, - } + 'total_score': self.total_score, + 'score_modifier': self.score_modifier, + 'risk_level': self.risk_level.value, + 'confidence': round(self.confidence, 2), + 'recommended_action': self.recommended_action, + 'correlated_devices': self.correlated_devices, + 'known_device': self.known_device, + 'known_device_name': self.known_device_name, + } # Known audio-capable BLE service UUIDs @@ -282,10 +308,11 @@ class CorrelationEngine: potential surveillance activity patterns. """ - def __init__(self): - self.device_profiles: dict[str, DeviceProfile] = {} - self.meeting_windows: list[tuple[datetime, datetime]] = [] - self.correlation_window = timedelta(minutes=5) + def __init__(self): + self.device_profiles: dict[str, DeviceProfile] = {} + self.meeting_windows: list[tuple[datetime, datetime]] = [] + self.correlation_window = timedelta(minutes=5) + self._known_device_cache: dict[str, dict | None] = {} def start_meeting_window(self) -> None: """Mark the start of a sensitive period (meeting).""" @@ -299,16 +326,64 @@ class CorrelationEngine: self.meeting_windows[-1] = (start, datetime.now()) logger.info("Meeting window ended") - def is_during_meeting(self, timestamp: datetime = None) -> bool: - """Check if timestamp falls within a meeting window.""" - ts = timestamp or datetime.now() - for start, end in self.meeting_windows: - if end is None: - if ts >= start: - return True - elif start <= ts <= end: - return True - return False + def is_during_meeting(self, timestamp: datetime = None) -> bool: + """Check if timestamp falls within a meeting window.""" + ts = timestamp or datetime.now() + for start, end in self.meeting_windows: + if end is None: + if ts >= start: + return True + elif start <= ts <= end: + return True + return False + + def _lookup_known_device(self, identifier: str, protocol: str) -> dict | None: + """Lookup known-good device details with light normalization.""" + cache_key = f"{protocol}:{identifier}" + if cache_key in self._known_device_cache: + return self._known_device_cache[cache_key] + + try: + from utils.database import is_known_good_device + + candidates = [] + if identifier: + candidates.append(str(identifier)) + + if protocol == 'rf': + try: + freq_val = float(identifier) + candidates.append(f"{freq_val:.3f}") + candidates.append(f"{freq_val:.1f}") + except (ValueError, TypeError): + pass + + known = None + for cand in candidates: + if not cand: + continue + known = is_known_good_device(str(cand).upper()) + if known: + break + except Exception: + known = None + + self._known_device_cache[cache_key] = known + return known + + def _apply_known_device_modifier(self, profile: DeviceProfile, identifier: str, protocol: str) -> None: + """Apply known-good score modifier and update profile metadata.""" + known = self._lookup_known_device(identifier, protocol) + if known: + profile.known_device = True + profile.known_device_name = known.get('name') if isinstance(known, dict) else None + modifier = known.get('score_modifier', 0) if isinstance(known, dict) else 0 + else: + profile.known_device = False + profile.known_device_name = None + modifier = 0 + + profile.apply_score_modifier(modifier) def get_or_create_profile(self, identifier: str, protocol: str) -> DeviceProfile: """Get existing profile or create new one.""" @@ -559,31 +634,33 @@ class CorrelationEngine: ) # Also check name for tracker keywords - if profile.name: - name_lower = profile.name.lower() - if 'airtag' in name_lower or 'findmy' in name_lower: - profile.add_indicator( - IndicatorType.AIRTAG_DETECTED, - f'AirTag identified by name: {profile.name}', - {'name': profile.name} - ) - profile.device_type = 'AirTag' - elif 'tile' in name_lower: - profile.add_indicator( - IndicatorType.TILE_DETECTED, - f'Tile tracker identified by name: {profile.name}', - {'name': profile.name} - ) - profile.device_type = 'Tile Tracker' - elif 'smarttag' in name_lower: - profile.add_indicator( - IndicatorType.SMARTTAG_DETECTED, - f'SmartTag identified by name: {profile.name}', - {'name': profile.name} - ) - profile.device_type = 'Samsung SmartTag' - - return profile + if profile.name: + name_lower = profile.name.lower() + if 'airtag' in name_lower or 'findmy' in name_lower: + profile.add_indicator( + IndicatorType.AIRTAG_DETECTED, + f'AirTag identified by name: {profile.name}', + {'name': profile.name} + ) + profile.device_type = 'AirTag' + elif 'tile' in name_lower: + profile.add_indicator( + IndicatorType.TILE_DETECTED, + f'Tile tracker identified by name: {profile.name}', + {'name': profile.name} + ) + profile.device_type = 'Tile Tracker' + elif 'smarttag' in name_lower: + profile.add_indicator( + IndicatorType.SMARTTAG_DETECTED, + f'SmartTag identified by name: {profile.name}', + {'name': profile.name} + ) + profile.device_type = 'Samsung SmartTag' + + self._apply_known_device_modifier(profile, mac, 'bluetooth') + + return profile def analyze_wifi_device(self, device: dict) -> DeviceProfile: """ @@ -686,16 +763,18 @@ class CorrelationEngine: ) # 8. Strong hidden AP (very suspicious) - if profile.is_hidden and profile.rssi_samples: - latest_rssi = profile.rssi_samples[-1][1] - if latest_rssi > -50: - profile.add_indicator( - IndicatorType.ROGUE_AP, - f'Strong hidden AP (RSSI: {latest_rssi} dBm)', - {'rssi': latest_rssi} - ) - - return profile + if profile.is_hidden and profile.rssi_samples: + latest_rssi = profile.rssi_samples[-1][1] + if latest_rssi > -50: + profile.add_indicator( + IndicatorType.ROGUE_AP, + f'Strong hidden AP (RSSI: {latest_rssi} dBm)', + {'rssi': latest_rssi} + ) + + self._apply_known_device_modifier(profile, bssid, 'wifi') + + return profile def analyze_rf_signal(self, signal: dict) -> DeviceProfile: """ @@ -778,14 +857,16 @@ class CorrelationEngine: ) # 5. Meeting correlation - if self.is_during_meeting(): - profile.add_indicator( - IndicatorType.MEETING_CORRELATED, - 'Signal detected during sensitive period', - {'during_meeting': True} - ) - - return profile + if self.is_during_meeting(): + profile.add_indicator( + IndicatorType.MEETING_CORRELATED, + 'Signal detected during sensitive period', + {'during_meeting': True} + ) + + self._apply_known_device_modifier(profile, freq_key, 'rf') + + return profile def correlate_devices(self) -> list[dict]: """ @@ -872,22 +953,26 @@ class CorrelationEngine: {'correlated_device': ap.identifier} ) - # Correlation 3: Same vendor BLE + WiFi - for bt in bt_devices: - if bt.manufacturer: - for wifi in wifi_devices: - if wifi.manufacturer and bt.manufacturer.lower() in wifi.manufacturer.lower(): - correlation = { + # Correlation 3: Same vendor BLE + WiFi + for bt in bt_devices: + if bt.manufacturer: + for wifi in wifi_devices: + if wifi.manufacturer and bt.manufacturer.lower() in wifi.manufacturer.lower(): + correlation = { 'type': 'same_vendor_bt_wifi', 'description': f'Same vendor ({bt.manufacturer}) on BLE and WiFi', 'devices': [bt.identifier, wifi.identifier], 'protocols': ['bluetooth', 'wifi'], 'score_boost': 2, 'significance': 'medium', - } - correlations.append(correlation) - - return correlations + } + correlations.append(correlation) + + # Re-apply known-good modifiers after correlation boosts + for profile in self.device_profiles.values(): + self._apply_known_device_modifier(profile, profile.identifier, profile.protocol) + + return correlations def get_high_interest_devices(self) -> list[DeviceProfile]: """Get all devices classified as high interest."""