mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 22:59:59 -07:00
- Fix SSE fanout thread AttributeError when source queue is None during interpreter shutdown by snapshotting to local variable with null guard - Fix branded "i" logo rendering oversized on first page load (FOUC) by adding inline width/height to SVG elements across 10 templates - Bump version to 2.26.0 in config.py, pyproject.toml, and CHANGELOG.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1077 lines
36 KiB
Python
1077 lines
36 KiB
Python
"""
|
|
TSCM Analysis Routes
|
|
|
|
Handles /threats/*, /report/*, /wifi/*, /bluetooth/*, /playbooks/*,
|
|
/findings/*, /identity/*, /known-devices/*, /device/*/timeline,
|
|
and /timelines endpoints.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
|
|
from flask import Response, jsonify, request
|
|
|
|
from routes.tscm import (
|
|
_generate_assessment,
|
|
tscm_bp,
|
|
)
|
|
from utils.database import (
|
|
acknowledge_tscm_threat,
|
|
get_active_tscm_baseline,
|
|
get_tscm_sweep,
|
|
get_tscm_threat_summary,
|
|
get_tscm_threats,
|
|
)
|
|
from utils.tscm.correlation import get_correlation_engine
|
|
|
|
logger = logging.getLogger('intercept.tscm')
|
|
|
|
|
|
# =============================================================================
|
|
# 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/<int:threat_id>', 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'})
|
|
|
|
|
|
# =============================================================================
|
|
# 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/<identifier>')
|
|
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
|
|
|
|
|
|
# =============================================================================
|
|
# 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
|
|
})
|
|
|
|
|
|
@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 routes.tscm import _current_sweep_id
|
|
from utils.tscm.advanced import detect_sweep_capabilities, get_timeline_manager
|
|
from utils.tscm.reports import generate_report, get_pdf_report
|
|
|
|
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 routes.tscm import _current_sweep_id
|
|
from utils.tscm.advanced import detect_sweep_capabilities, get_timeline_manager
|
|
from utils.tscm.reports import generate_report, get_csv_annex, get_json_annex
|
|
|
|
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/<identifier>/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/<identifier>/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/<playbook_id>')
|
|
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/<identifier>/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
|
|
|
|
|
|
# =============================================================================
|
|
# 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": [<ble_observation>, ...],
|
|
"wifi": [<wifi_observation>, ...]
|
|
}
|
|
"""
|
|
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/<cluster_id>')
|
|
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
|
|
|
|
|
|
# =============================================================================
|
|
# Device Timeline Endpoints
|
|
# =============================================================================
|
|
|
|
@tscm_bp.route('/device/<identifier>/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.database import get_device_timeline
|
|
from utils.tscm.advanced import get_timeline_manager
|
|
|
|
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/<identifier>', 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/<identifier>', 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/<identifier>')
|
|
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
|
|
})
|