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 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-05 16:07:34 +00:00
parent 11941bedad
commit 5d4b19aef2
11 changed files with 6202 additions and 3555 deletions
+67 -44
View File
@@ -3118,10 +3118,11 @@ class ModeManager:
# Get params for what to scan # Get params for what to scan
scan_wifi = params.get('wifi', True) scan_wifi = params.get('wifi', True)
scan_bt = params.get('bluetooth', True) scan_bt = params.get('bluetooth', True)
scan_rf = params.get('rf', True) scan_rf = params.get('rf', True)
wifi_interface = params.get('wifi_interface') or params.get('interface') wifi_interface = params.get('wifi_interface') or params.get('interface')
bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0') bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0')
sdr_device = params.get('sdr_device', params.get('device', 0)) 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) # Get baseline_id for comparison (same as local mode)
baseline_id = params.get('baseline_id') baseline_id = params.get('baseline_id')
@@ -3129,11 +3130,11 @@ class ModeManager:
started_scans = [] started_scans = []
# Start the combined TSCM scanner thread using existing Intercept functions # Start the combined TSCM scanner thread using existing Intercept functions
thread = threading.Thread( thread = threading.Thread(
target=self._tscm_scanner_thread, target=self._tscm_scanner_thread,
args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id), args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id, sweep_type),
daemon=True daemon=True
) )
thread.start() thread.start()
self.output_threads['tscm'] = thread self.output_threads['tscm'] = thread
@@ -3152,9 +3153,9 @@ class ModeManager:
'scanning': started_scans 'scanning': started_scans
} }
def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool, def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool,
wifi_interface: str | None, bt_adapter: str, sdr_device: int, wifi_interface: str | None, bt_adapter: str, sdr_device: int,
baseline_id: int | None = None): baseline_id: int | None = None, sweep_type: str | None = None):
"""Combined TSCM scanner using existing Intercept functions. """Combined TSCM scanner using existing Intercept functions.
NOTE: This matches local mode behavior exactly: NOTE: This matches local mode behavior exactly:
@@ -3167,11 +3168,20 @@ class ModeManager:
stop_event = self.stop_events.get(mode) stop_event = self.stop_events.get(mode)
# Import existing Intercept TSCM functions # Import existing Intercept TSCM functions
from routes.tscm import _scan_wifi_networks, _scan_bluetooth_devices, _scan_rf_signals from routes.tscm import _scan_wifi_networks, _scan_bluetooth_devices, _scan_rf_signals
logger.info("TSCM imports successful") logger.info("TSCM imports successful")
# Load baseline if specified (same as local mode) sweep_ranges = None
baseline = 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: if baseline_id and HAS_BASELINE_DB and get_tscm_baseline:
baseline = get_tscm_baseline(baseline_id) baseline = get_tscm_baseline(baseline_id)
if baseline: if baseline:
@@ -3239,15 +3249,18 @@ class ModeManager:
enriched['is_new'] = not classification.get('in_baseline', False) enriched['is_new'] = not classification.get('in_baseline', False)
enriched['reasons'] = classification.get('reasons', []) enriched['reasons'] = classification.get('reasons', [])
if self._tscm_correlation: if self._tscm_correlation:
profile = self._tscm_correlation.analyze_wifi_device(enriched) profile = self._tscm_correlation.analyze_wifi_device(enriched)
enriched['classification'] = profile.risk_level.value enriched['classification'] = profile.risk_level.value
enriched['score'] = profile.total_score enriched['score'] = profile.total_score
enriched['indicators'] = [ enriched['score_modifier'] = profile.score_modifier
{'type': i.type.value, 'desc': i.description} enriched['known_device'] = profile.known_device
for i in profile.indicators enriched['known_device_name'] = profile.known_device_name
] enriched['indicators'] = [
enriched['recommended_action'] = profile.recommended_action {'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
enriched['recommended_action'] = profile.recommended_action
self.wifi_networks[bssid] = enriched self.wifi_networks[bssid] = enriched
except Exception as e: except Exception as e:
@@ -3285,15 +3298,18 @@ class ModeManager:
enriched['is_new'] = not classification.get('in_baseline', False) enriched['is_new'] = not classification.get('in_baseline', False)
enriched['reasons'] = classification.get('reasons', []) enriched['reasons'] = classification.get('reasons', [])
if self._tscm_correlation: if self._tscm_correlation:
profile = self._tscm_correlation.analyze_bluetooth_device(enriched) profile = self._tscm_correlation.analyze_bluetooth_device(enriched)
enriched['classification'] = profile.risk_level.value enriched['classification'] = profile.risk_level.value
enriched['score'] = profile.total_score enriched['score'] = profile.total_score
enriched['indicators'] = [ enriched['score_modifier'] = profile.score_modifier
{'type': i.type.value, 'desc': i.description} enriched['known_device'] = profile.known_device
for i in profile.indicators enriched['known_device_name'] = profile.known_device_name
] enriched['indicators'] = [
enriched['recommended_action'] = profile.recommended_action {'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
enriched['recommended_action'] = profile.recommended_action
self.bluetooth_devices[mac] = enriched self.bluetooth_devices[mac] = enriched
except Exception as e: except Exception as e:
@@ -3304,7 +3320,11 @@ class ModeManager:
try: try:
# Pass a stop check that uses our stop_event (not the module's _sweep_running) # 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() 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 # Analyze each RF signal like local mode does
analyzed_signals = [] analyzed_signals = []
@@ -3324,14 +3344,17 @@ class ModeManager:
analyzed['reasons'] = classification.get('reasons', []) analyzed['reasons'] = classification.get('reasons', [])
# Use correlation engine for scoring (same as local mode) # Use correlation engine for scoring (same as local mode)
if hasattr(self, '_tscm_correlation') and self._tscm_correlation: if hasattr(self, '_tscm_correlation') and self._tscm_correlation:
profile = self._tscm_correlation.analyze_rf_signal(signal) profile = self._tscm_correlation.analyze_rf_signal(signal)
analyzed['classification'] = profile.risk_level.value analyzed['classification'] = profile.risk_level.value
analyzed['score'] = profile.total_score analyzed['score'] = profile.total_score
analyzed['indicators'] = [ analyzed['score_modifier'] = profile.score_modifier
{'type': i.type.value, 'desc': i.description} analyzed['known_device'] = profile.known_device
for i in profile.indicators 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['is_threat'] = is_threat
analyzed_signals.append(analyzed) analyzed_signals.append(analyzed)
+69 -26
View File
@@ -10,14 +10,16 @@ This blueprint provides:
from __future__ import annotations from __future__ import annotations
import json import json
import logging import logging
import queue import queue
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Generator from typing import Generator
from flask import Blueprint, jsonify, request, Response import requests
from flask import Blueprint, jsonify, request, Response
from utils.database import ( from utils.database import (
create_agent, get_agent, get_agent_by_name, list_agents, create_agent, get_agent, get_agent_by_name, list_agents,
@@ -450,12 +452,12 @@ def proxy_mode_status(agent_id: int, mode: str):
}), 502 }), 502
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET']) @controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
def proxy_mode_data(agent_id: int, mode: str): def proxy_mode_data(agent_id: int, mode: str):
"""Get current data from a remote agent.""" """Get current data from a remote agent."""
agent = get_agent(agent_id) agent = get_agent(agent_id)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
try: try:
client = create_client_from_agent(agent) client = create_client_from_agent(agent)
@@ -473,18 +475,59 @@ def proxy_mode_data(agent_id: int, mode: str):
'data': result 'data': result
}) })
except (AgentHTTPError, AgentConnectionError) as e: except (AgentHTTPError, AgentConnectionError) as e:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': f'Agent error: {e}' 'message': f'Agent error: {e}'
}), 502 }), 502
@controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST']) @controller_bp.route('/agents/<int:agent_id>/<mode>/stream')
def proxy_wifi_monitor(agent_id: int): def proxy_mode_stream(agent_id: int, mode: str):
"""Toggle monitor mode on a remote agent's WiFi interface.""" """Proxy SSE stream from a remote agent."""
agent = get_agent(agent_id) agent = get_agent(agent_id)
if not agent: 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/<int:agent_id>/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 return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
data = request.json or {} data = request.json or {}
+3887 -3290
View File
File diff suppressed because it is too large Load Diff
+213
View File
@@ -45,6 +45,7 @@
padding: 12px; padding: 12px;
background: rgba(0,0,0,0.3); background: rgba(0,0,0,0.3);
border-radius: 8px; border-radius: 8px;
flex-wrap: wrap;
} }
.tscm-threat-banner .threat-card { .tscm-threat-banner .threat-card {
flex: 1; flex: 1;
@@ -200,6 +201,17 @@
margin-left: 6px; margin-left: 6px;
font-size: 10px; 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 { .tscm-device-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -465,6 +477,18 @@
color: var(--text-dim); color: var(--text-dim);
width: 40%; 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 { .indicator-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -882,6 +906,42 @@
margin-left: auto; 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 */ /* Advanced Modal Styles */
.tscm-advanced-modal { .tscm-advanced-modal {
max-width: 600px; max-width: 600px;
@@ -1461,3 +1521,156 @@
width: 10px; width: 10px;
height: 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;
}
+7 -18
View File
@@ -865,15 +865,12 @@ function connectAgentStream(mode, onMessage) {
} }
let streamUrl; let streamUrl;
if (currentAgent === 'local') { if (currentAgent === 'local') {
streamUrl = `/${mode}/stream`; streamUrl = `/${mode}/stream`;
} else { } else {
// For remote agents, we could either: // For remote agents, proxy SSE through controller
// 1. Use the multi-agent stream: /controller/stream/all streamUrl = `/controller/agents/${currentAgent}/${mode}/stream`;
// 2. Or proxy through controller (not implemented yet) }
// For now, use multi-agent stream which includes agent_name tagging
streamUrl = '/controller/stream/all';
}
agentEventSource = new EventSource(streamUrl); agentEventSource = new EventSource(streamUrl);
@@ -881,15 +878,7 @@ function connectAgentStream(mode, onMessage) {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
// If using multi-agent stream, filter by current agent if needed onMessage(data);
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);
} catch (e) { } catch (e) {
console.error('Error parsing SSE message:', e); console.error('Error parsing SSE message:', e);
} }
+8 -1
View File
@@ -879,6 +879,7 @@ const WiFiMode = (function() {
updateNetworkRow(network); updateNetworkRow(network);
updateStats(); updateStats();
updateProximityRadar(); updateProximityRadar();
updateChannelChart();
if (onNetworkUpdate) onNetworkUpdate(network); 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)); 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; 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 // Recalculate channel stats from networks if needed
if (channelStats.length === 0 && networks.size > 0) { if (channelStats.length === 0 && networks.size > 0) {
channelStats = calculateChannelStats(); channelStats = calculateChannelStats();
+1624 -54
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -146,6 +146,9 @@
<button class="stop-btn" id="tscmEndMeetingBtn" onclick="tscmEndMeeting()" style="width: 100%; padding: 8px; display: none;"> <button class="stop-btn" id="tscmEndMeetingBtn" onclick="tscmEndMeeting()" style="width: 100%; padding: 8px; display: none;">
End Meeting Window End Meeting Window
</button> </button>
<button class="preset-btn" id="tscmMeetingSummaryBtn" onclick="tscmShowMeetingSummary()" style="width: 100%; padding: 8px; margin-top: 6px; display: none;">
View Meeting Summary
</button>
<div style="font-size: 9px; color: var(--text-muted); margin-top: 4px;"> <div style="font-size: 9px; color: var(--text-muted); margin-top: 4px;">
Devices detected during meetings get flagged Devices detected during meetings get flagged
</div> </div>
@@ -159,12 +162,18 @@
<button class="preset-btn" onclick="tscmShowCapabilities()" style="font-size: 10px; padding: 8px;"> <button class="preset-btn" onclick="tscmShowCapabilities()" style="font-size: 10px; padding: 8px;">
Capabilities Capabilities
</button> </button>
<button class="preset-btn" onclick="tscmShowWifiIndicators()" style="font-size: 10px; padding: 8px;">
WiFi Indicators
</button>
<button class="preset-btn" onclick="tscmShowKnownDevices()" style="font-size: 10px; padding: 8px;"> <button class="preset-btn" onclick="tscmShowKnownDevices()" style="font-size: 10px; padding: 8px;">
Known Devices Known Devices
</button> </button>
<button class="preset-btn" onclick="tscmShowCases()" style="font-size: 10px; padding: 8px;"> <button class="preset-btn" onclick="tscmShowCases()" style="font-size: 10px; padding: 8px;">
Cases Cases
</button> </button>
<button class="preset-btn" onclick="tscmShowSchedules()" style="font-size: 10px; padding: 8px;">
Schedules
</button>
<button class="preset-btn" onclick="tscmShowPlaybooks()" style="font-size: 10px; padding: 8px;"> <button class="preset-btn" onclick="tscmShowPlaybooks()" style="font-size: 10px; padding: 8px;">
Playbooks Playbooks
</button> </button>
+121 -15
View File
@@ -1205,21 +1205,127 @@ def get_all_known_devices(
] ]
def delete_known_device(identifier: str) -> bool: def delete_known_device(identifier: str) -> bool:
"""Remove a device from the known-good registry.""" """Remove a device from the known-good registry."""
with get_db() as conn: with get_db() as conn:
cursor = conn.execute( cursor = conn.execute(
'DELETE FROM tscm_known_devices WHERE identifier = ?', 'DELETE FROM tscm_known_devices WHERE identifier = ?',
(identifier.upper(),) (identifier.upper(),)
) )
return cursor.rowcount > 0 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.""" # TSCM Schedule Functions
with get_db() as conn: # =============================================================================
if location:
cursor = conn.execute(''' 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 SELECT * FROM tscm_known_devices
WHERE identifier = ? AND (location = ? OR scope = 'global') WHERE identifier = ? AND (location = ? OR scope = 'global')
''', (identifier.upper(), location)) ''', (identifier.upper(), location))
+30 -25
View File
@@ -245,10 +245,11 @@ class SSTVDecoder:
# Doppler tracking # Doppler tracking
self._doppler_tracker = DopplerTracker('ISS') self._doppler_tracker = DopplerTracker('ISS')
self._doppler_enabled = False self._doppler_enabled = False
self._last_doppler_info: DopplerInfo | None = None 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) # Ensure output directory exists
self._output_dir.mkdir(parents=True, exist_ok=True)
# Detect available decoder # Detect available decoder
self._decoder = self._detect_decoder() self._decoder = self._detect_decoder()
@@ -265,21 +266,23 @@ class SSTVDecoder:
def _detect_decoder(self) -> str | None: def _detect_decoder(self) -> str | None:
"""Detect which SSTV decoder is available.""" """Detect which SSTV decoder is available."""
# Check for slowrx (command-line SSTV decoder) # Check for slowrx (command-line SSTV decoder)
try: try:
result = subprocess.run(['which', 'slowrx'], capture_output=True, timeout=5) result = subprocess.run(['which', 'slowrx'], capture_output=True, timeout=5)
if result.returncode == 0: if result.returncode == 0:
return 'slowrx' self._file_decoder = 'slowrx'
except Exception: return 'slowrx'
pass except Exception:
pass
# Note: qsstv is GUI-only and not suitable for headless/server operation # Note: qsstv is GUI-only and not suitable for headless/server operation
# Check for Python sstv package # Check for Python sstv package
try: try:
import sstv import sstv
return 'python-sstv' self._file_decoder = 'python-sstv'
except ImportError: return None
pass 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.") 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 return None
@@ -691,11 +694,13 @@ class SSTVDecoder:
if not audio_path.exists(): if not audio_path.exists():
raise FileNotFoundError(f"Audio file not found: {audio_path}") raise FileNotFoundError(f"Audio file not found: {audio_path}")
images = [] images = []
if self._decoder == 'slowrx': decoder = self._decoder or self._file_decoder
# Use slowrx with file input
output_file = self._output_dir / f"sstv_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png" 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)] cmd = ['slowrx', '-o', str(self._output_dir), str(audio_path)]
result = subprocess.run(cmd, capture_output=True, timeout=300) result = subprocess.run(cmd, capture_output=True, timeout=300)
@@ -715,10 +720,10 @@ class SSTVDecoder:
) )
images.append(image) images.append(image)
elif self._decoder == 'python-sstv': elif decoder == 'python-sstv':
# Use Python sstv library # Use Python sstv library
try: try:
from sstv.decode import SSTVDecoder as PythonSSTVDecoder from sstv.decode import SSTVDecoder as PythonSSTVDecoder
from PIL import Image from PIL import Image
decoder = PythonSSTVDecoder(str(audio_path)) decoder = PythonSSTVDecoder(str(audio_path))
+167 -82
View File
@@ -154,9 +154,12 @@ class DeviceProfile:
# Correlation # Correlation
correlated_devices: list[str] = field(default_factory=list) correlated_devices: list[str] = field(default_factory=list)
# Output # Output
confidence: float = 0.0 confidence: float = 0.0
recommended_action: str = 'monitor' 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: def add_rssi_sample(self, rssi: int) -> None:
"""Add an RSSI sample with timestamp.""" """Add an RSSI sample with timestamp."""
@@ -190,9 +193,9 @@ class DeviceProfile:
)) ))
self._recalculate_score() self._recalculate_score()
def _recalculate_score(self) -> None: def _recalculate_score(self) -> None:
"""Recalculate total score and risk level.""" """Recalculate total score and risk level."""
self.total_score = sum(i.score for i in self.indicators) self.total_score = sum(i.score for i in self.indicators)
if self.total_score >= 6: if self.total_score >= 6:
self.risk_level = RiskLevel.HIGH_INTEREST self.risk_level = RiskLevel.HIGH_INTEREST
@@ -204,9 +207,29 @@ class DeviceProfile:
self.risk_level = RiskLevel.INFORMATIONAL self.risk_level = RiskLevel.INFORMATIONAL
self.recommended_action = 'monitor' self.recommended_action = 'monitor'
# Calculate confidence based on number and quality of indicators # Calculate confidence based on number and quality of indicators
indicator_count = len(self.indicators) indicator_count = len(self.indicators)
self.confidence = min(1.0, (indicator_count * 0.15) + (self.total_score * 0.05)) 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: def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization.""" """Convert to dictionary for JSON serialization."""
@@ -231,12 +254,15 @@ class DeviceProfile:
} }
for i in self.indicators for i in self.indicators
], ],
'total_score': self.total_score, 'total_score': self.total_score,
'risk_level': self.risk_level.value, 'score_modifier': self.score_modifier,
'confidence': round(self.confidence, 2), 'risk_level': self.risk_level.value,
'recommended_action': self.recommended_action, 'confidence': round(self.confidence, 2),
'correlated_devices': self.correlated_devices, '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 # Known audio-capable BLE service UUIDs
@@ -282,10 +308,11 @@ class CorrelationEngine:
potential surveillance activity patterns. potential surveillance activity patterns.
""" """
def __init__(self): def __init__(self):
self.device_profiles: dict[str, DeviceProfile] = {} self.device_profiles: dict[str, DeviceProfile] = {}
self.meeting_windows: list[tuple[datetime, datetime]] = [] self.meeting_windows: list[tuple[datetime, datetime]] = []
self.correlation_window = timedelta(minutes=5) self.correlation_window = timedelta(minutes=5)
self._known_device_cache: dict[str, dict | None] = {}
def start_meeting_window(self) -> None: def start_meeting_window(self) -> None:
"""Mark the start of a sensitive period (meeting).""" """Mark the start of a sensitive period (meeting)."""
@@ -299,16 +326,64 @@ class CorrelationEngine:
self.meeting_windows[-1] = (start, datetime.now()) self.meeting_windows[-1] = (start, datetime.now())
logger.info("Meeting window ended") logger.info("Meeting window ended")
def is_during_meeting(self, timestamp: datetime = None) -> bool: def is_during_meeting(self, timestamp: datetime = None) -> bool:
"""Check if timestamp falls within a meeting window.""" """Check if timestamp falls within a meeting window."""
ts = timestamp or datetime.now() ts = timestamp or datetime.now()
for start, end in self.meeting_windows: for start, end in self.meeting_windows:
if end is None: if end is None:
if ts >= start: if ts >= start:
return True return True
elif start <= ts <= end: elif start <= ts <= end:
return True return True
return False 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: def get_or_create_profile(self, identifier: str, protocol: str) -> DeviceProfile:
"""Get existing profile or create new one.""" """Get existing profile or create new one."""
@@ -559,31 +634,33 @@ class CorrelationEngine:
) )
# Also check name for tracker keywords # Also check name for tracker keywords
if profile.name: if profile.name:
name_lower = profile.name.lower() name_lower = profile.name.lower()
if 'airtag' in name_lower or 'findmy' in name_lower: if 'airtag' in name_lower or 'findmy' in name_lower:
profile.add_indicator( profile.add_indicator(
IndicatorType.AIRTAG_DETECTED, IndicatorType.AIRTAG_DETECTED,
f'AirTag identified by name: {profile.name}', f'AirTag identified by name: {profile.name}',
{'name': profile.name} {'name': profile.name}
) )
profile.device_type = 'AirTag' profile.device_type = 'AirTag'
elif 'tile' in name_lower: elif 'tile' in name_lower:
profile.add_indicator( profile.add_indicator(
IndicatorType.TILE_DETECTED, IndicatorType.TILE_DETECTED,
f'Tile tracker identified by name: {profile.name}', f'Tile tracker identified by name: {profile.name}',
{'name': profile.name} {'name': profile.name}
) )
profile.device_type = 'Tile Tracker' profile.device_type = 'Tile Tracker'
elif 'smarttag' in name_lower: elif 'smarttag' in name_lower:
profile.add_indicator( profile.add_indicator(
IndicatorType.SMARTTAG_DETECTED, IndicatorType.SMARTTAG_DETECTED,
f'SmartTag identified by name: {profile.name}', f'SmartTag identified by name: {profile.name}',
{'name': profile.name} {'name': profile.name}
) )
profile.device_type = 'Samsung SmartTag' profile.device_type = 'Samsung SmartTag'
return profile self._apply_known_device_modifier(profile, mac, 'bluetooth')
return profile
def analyze_wifi_device(self, device: dict) -> DeviceProfile: def analyze_wifi_device(self, device: dict) -> DeviceProfile:
""" """
@@ -686,16 +763,18 @@ class CorrelationEngine:
) )
# 8. Strong hidden AP (very suspicious) # 8. Strong hidden AP (very suspicious)
if profile.is_hidden and profile.rssi_samples: if profile.is_hidden and profile.rssi_samples:
latest_rssi = profile.rssi_samples[-1][1] latest_rssi = profile.rssi_samples[-1][1]
if latest_rssi > -50: if latest_rssi > -50:
profile.add_indicator( profile.add_indicator(
IndicatorType.ROGUE_AP, IndicatorType.ROGUE_AP,
f'Strong hidden AP (RSSI: {latest_rssi} dBm)', f'Strong hidden AP (RSSI: {latest_rssi} dBm)',
{'rssi': latest_rssi} {'rssi': latest_rssi}
) )
return profile self._apply_known_device_modifier(profile, bssid, 'wifi')
return profile
def analyze_rf_signal(self, signal: dict) -> DeviceProfile: def analyze_rf_signal(self, signal: dict) -> DeviceProfile:
""" """
@@ -778,14 +857,16 @@ class CorrelationEngine:
) )
# 5. Meeting correlation # 5. Meeting correlation
if self.is_during_meeting(): if self.is_during_meeting():
profile.add_indicator( profile.add_indicator(
IndicatorType.MEETING_CORRELATED, IndicatorType.MEETING_CORRELATED,
'Signal detected during sensitive period', 'Signal detected during sensitive period',
{'during_meeting': True} {'during_meeting': True}
) )
return profile self._apply_known_device_modifier(profile, freq_key, 'rf')
return profile
def correlate_devices(self) -> list[dict]: def correlate_devices(self) -> list[dict]:
""" """
@@ -872,22 +953,26 @@ class CorrelationEngine:
{'correlated_device': ap.identifier} {'correlated_device': ap.identifier}
) )
# Correlation 3: Same vendor BLE + WiFi # Correlation 3: Same vendor BLE + WiFi
for bt in bt_devices: for bt in bt_devices:
if bt.manufacturer: if bt.manufacturer:
for wifi in wifi_devices: for wifi in wifi_devices:
if wifi.manufacturer and bt.manufacturer.lower() in wifi.manufacturer.lower(): if wifi.manufacturer and bt.manufacturer.lower() in wifi.manufacturer.lower():
correlation = { correlation = {
'type': 'same_vendor_bt_wifi', 'type': 'same_vendor_bt_wifi',
'description': f'Same vendor ({bt.manufacturer}) on BLE and WiFi', 'description': f'Same vendor ({bt.manufacturer}) on BLE and WiFi',
'devices': [bt.identifier, wifi.identifier], 'devices': [bt.identifier, wifi.identifier],
'protocols': ['bluetooth', 'wifi'], 'protocols': ['bluetooth', 'wifi'],
'score_boost': 2, 'score_boost': 2,
'significance': 'medium', 'significance': 'medium',
} }
correlations.append(correlation) correlations.append(correlation)
return correlations # 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]: def get_high_interest_devices(self) -> list[DeviceProfile]:
"""Get all devices classified as high interest.""" """Get all devices classified as high interest."""