mirror of
https://github.com/smittix/intercept.git
synced 2026-07-23 16:38:11 -07:00
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:
+67
-44
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user