Files
intercept/routes/tscm/analysis.py
T
James Smith 96172ca593 style: apply ruff-format to entire codebase
First-time run of ruff-format via pre-commit hook normalises quote
style, trailing commas, and whitespace across 188 Python files.
No logic changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 14:48:11 +01:00

1044 lines
37 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")
_VALID_CATEGORIES = {"high_interest", "needs_review", "informational"}
def _parse_categories_param(raw: str) -> list[str] | None:
"""Parse a comma-separated `categories` query param into a validated list.
Returns None (meaning "all") when the param is absent or covers all three
categories, so callers can skip filtering in the common case.
"""
if not raw:
return None
cats = [c.strip() for c in raw.split(",") if c.strip() in _VALID_CATEGORIES]
if not cats or set(cats) == _VALID_CATEGORIES:
return None
return cats
# =============================================================================
# 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
categories = _parse_categories_param(request.args.get("categories", ""))
site_name = request.args.get("site_name", "").strip()[:200]
examiner_name = request.args.get("examiner_name", "").strip()[:200]
# 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,
categories=categories,
site_name=site_name,
examiner_name=examiner_name,
)
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
categories = _parse_categories_param(request.args.get("categories", ""))
site_name = request.args.get("site_name", "").strip()[:200]
examiner_name = request.args.get("examiner_name", "").strip()[:200]
# 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,
categories=categories,
site_name=site_name,
examiner_name=examiner_name,
)
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})