mirror of
https://github.com/smittix/intercept.git
synced 2026-07-07 09:08:12 -07:00
96172ca593
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>
1044 lines
37 KiB
Python
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})
|