From 847750fd137549200ec366268b971a9f8c5204fe Mon Sep 17 00:00:00 2001 From: Jon Ander Oribe Date: Sat, 7 Feb 2026 10:01:57 +0100 Subject: [PATCH 01/25] Add LAN SPY network scanning mode Introduce a new LAN SPY mode for local network device discovery and risk scoring. Adds backend blueprint (routes/lan_spy.py) with endpoints for scanning, device CRUD, risk scoring, OUI updates and an SSE event stream; initializes LAN SPY state in routes/__init__.py. Adds frontend assets (static/js/lan_spy.js, static/css/modes/lan_spy_dashboard.css) and includes the UI partial in templates/index.html. Adds supporting utils (utils/lan_spy/scanner.py, database.py, risk_scoring.py) and declares pyyaml in requirements.txt for OUI handling. Provides real-time scan events, background scan worker, device flag toggles, and a non-blocking loader UI. --- requirements.txt | 3 + routes/__init__.py | 5 + routes/lan_spy.py | 296 +++++++++++ static/css/modes/lan_spy_dashboard.css | 664 +++++++++++++++++++++++++ static/js/lan_spy.js | 528 ++++++++++++++++++++ templates/index.html | 19 +- templates/partials/modes/lan_spy.html | 51 ++ utils/lan_spy/database.py | 349 +++++++++++++ utils/lan_spy/risk_scoring.py | 223 +++++++++ utils/lan_spy/scanner.py | 316 ++++++++++++ 10 files changed, 2453 insertions(+), 1 deletion(-) create mode 100644 routes/lan_spy.py create mode 100644 static/css/modes/lan_spy_dashboard.css create mode 100644 static/js/lan_spy.js create mode 100644 templates/partials/modes/lan_spy.html create mode 100644 utils/lan_spy/database.py create mode 100644 utils/lan_spy/risk_scoring.py create mode 100644 utils/lan_spy/scanner.py diff --git a/requirements.txt b/requirements.txt index 911afdeb..e0638f2a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,9 @@ flask-limiter>=2.5.4 requests>=2.28.0 Werkzeug>=3.1.5 +# LAN SPY (network device discovery and risk scoring) +pyyaml>=6.0 + # ADS-B history (optional - only needed for Postgres persistence) psycopg2-binary>=2.9.9 diff --git a/routes/__init__.py b/routes/__init__.py index b4426bd8..2ef29f87 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -21,6 +21,7 @@ def register_blueprints(app): from .listening_post import listening_post_bp from .meshtastic import meshtastic_bp from .tscm import tscm_bp, init_tscm_state + from .lan_spy import lan_spy_bp, init_lan_spy_state from .spy_stations import spy_stations_bp from .controller import controller_bp from .offline import offline_bp @@ -49,6 +50,7 @@ def register_blueprints(app): app.register_blueprint(listening_post_bp) app.register_blueprint(meshtastic_bp) app.register_blueprint(tscm_bp) + app.register_blueprint(lan_spy_bp) # LAN device discovery app.register_blueprint(spy_stations_bp) app.register_blueprint(controller_bp) # Remote agent controller app.register_blueprint(offline_bp) # Offline mode settings @@ -62,3 +64,6 @@ def register_blueprints(app): import app as app_module if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'): init_tscm_state(app_module.tscm_queue, app_module.tscm_lock) + + # Initialize LAN SPY state + init_lan_spy_state() diff --git a/routes/lan_spy.py b/routes/lan_spy.py new file mode 100644 index 00000000..239b7bd0 --- /dev/null +++ b/routes/lan_spy.py @@ -0,0 +1,296 @@ +""" +LAN SPY Routes +Flask blueprint for network scanning API and SSE event streaming. +""" + +from flask import Blueprint, jsonify, request, Response +import logging +import threading +import queue +from datetime import datetime +import json +import time + +from utils.lan_spy.scanner import NetworkScanner, update_oui_database, get_local_network +from utils.lan_spy.database import LANDatabase +from utils.lan_spy.risk_scoring import RiskScorer + +logger = logging.getLogger('intercept.lan_spy.routes') + +lan_spy_bp = Blueprint('lan_spy', __name__, url_prefix='/lan_spy') + +# Global state +lan_spy_state = { + 'scanner': None, + 'database': None, + 'risk_scorer': None, + 'event_queue': queue.Queue(), + 'scan_running': False, + 'scan_worker_thread': None +} + + +def init_lan_spy_state(): + """Initialize LAN SPY state on app startup.""" + try: + lan_spy_state['database'] = LANDatabase() + lan_spy_state['risk_scorer'] = RiskScorer() + + # Auto-download OUI database if missing + if not lan_spy_state['database']: + logger.info("Updating OUI database...") + result = update_oui_database() + logger.info(f"OUI update: {result}") + + logger.info("LAN SPY state initialized") + except Exception as e: + logger.error(f"Error initializing LAN SPY: {e}") + + +def _emit_event(event_type: str, data: dict = None): + """Queue event for SSE streaming.""" + event = { + 'type': event_type, + 'timestamp': datetime.now().isoformat() + 'Z', + 'data': data or {} + } + lan_spy_state['event_queue'].put(event) + + +def scan_worker(network: str = None): + """Background worker thread for network scanning.""" + try: + if network is None: + network = get_local_network() + + _emit_event('scan_start', {'network': network, 'status': 'Initializing scan...'}) + + # Create scanner + scanner = NetworkScanner(network=network) + lan_spy_state['scanner'] = scanner + + _emit_event('scan_status', {'message': 'Scanning network for active hosts...'}) + + # Perform scan + devices = scanner.scan() + + _emit_event('scan_status', { + 'message': f'Found {len(devices)} devices...' if devices else 'No devices found on this network' + }) + + # Store devices and calculate risk scores + device_count = 0 + for device in devices: + # Add to database + lan_spy_state['database'].add_device(device) + device_count += 1 + + # Calculate risk score + risk_data = lan_spy_state['risk_scorer'].calculate_risk(device) + lan_spy_state['database'].add_risk_score( + device['mac'], + risk_data['hardware'], + risk_data['exposure'], + risk_data['external'], + risk_data['traffic'], + risk_data['total'] + ) + + # Emit device found event + device['risk_index'] = risk_data['total'] + device['risk_badge'] = risk_data['badge'] + _emit_event('device_found', device) + + _emit_event('scan_status', { + 'message': f'Processing device {device_count}/{len(devices)}: {device.get("hostname", device.get("internal_ip"))}' + }) + + # Record scan history + elapsed = time.time() - scanner._start_time if hasattr(scanner, '_start_time') else 0 + lan_spy_state['database'].record_scan(network, len(devices), elapsed) + + # Emit completion + _emit_event('scan_complete', { + 'devices_found': len(devices), + 'network': network + }) + + except Exception as e: + logger.error(f"Scan worker error: {e}") + _emit_event('scan_error', {'message': str(e)}) + finally: + lan_spy_state['scan_running'] = False + + +@lan_spy_bp.route('/health', methods=['GET']) +def health(): + """Health check endpoint.""" + return jsonify({'status': 'ok', 'service': 'lan_spy'}), 200 + + +@lan_spy_bp.route('/devices', methods=['GET']) +def get_devices(): + """Get all discovered devices.""" + try: + devices = lan_spy_state['database'].get_all_devices() + + # Add risk scores + for device in devices: + risk_data = lan_spy_state['database'].get_risk_score(device['mac']) + if risk_data: + device['risk_index'] = risk_data['total'] + device['risk_scores'] = { + 'hardware': risk_data['hardware'], + 'exposure': risk_data['exposure'], + 'external': risk_data['external'], + 'traffic': risk_data['traffic'] + } + else: + device['risk_index'] = 0.0 + device['risk_scores'] = {} + + return jsonify({'devices': devices, 'count': len(devices)}), 200 + except Exception as e: + logger.error(f"Error getting devices: {e}") + return jsonify({'error': str(e)}), 500 + + +@lan_spy_bp.route('/device/', methods=['GET']) +def get_device(mac): + """Get specific device by MAC address.""" + try: + device = lan_spy_state['database'].get_device(mac) + if not device: + return jsonify({'error': 'Device not found'}), 404 + + # Add risk score + risk_data = lan_spy_state['database'].get_risk_score(mac) + if risk_data: + device['risk_index'] = risk_data['total'] + device['risk_scores'] = { + 'hardware': risk_data['hardware'], + 'exposure': risk_data['exposure'], + 'external': risk_data['external'], + 'traffic': risk_data['traffic'] + } + + return jsonify(device), 200 + except Exception as e: + logger.error(f"Error getting device: {e}") + return jsonify({'error': str(e)}), 500 + + +@lan_spy_bp.route('/scan', methods=['POST']) +def start_scan(): + """Start a network scan.""" + try: + if lan_spy_state['scan_running']: + return jsonify({'error': 'Scan already running'}), 409 + + # Get network from request or auto-detect + network = request.json.get('network') if request.json else None + + lan_spy_state['scan_running'] = True + + # Start scan in background thread + thread = threading.Thread(target=scan_worker, args=(network,), daemon=True) + lan_spy_state['scan_worker_thread'] = thread + thread.start() + + return jsonify({'status': 'scan_started', 'network': network or get_local_network()}), 200 + except Exception as e: + logger.error(f"Error starting scan: {e}") + lan_spy_state['scan_running'] = False + return jsonify({'error': str(e)}), 500 + + +@lan_spy_bp.route('/scan/stop', methods=['POST']) +def stop_scan(): + """Stop the current scan.""" + try: + if lan_spy_state['scanner']: + lan_spy_state['scanner'].stop() + _emit_event('scan_stopped', {'message': 'Scan stopped by user'}) + + return jsonify({'status': 'scan_stopped'}), 200 + except Exception as e: + logger.error(f"Error stopping scan: {e}") + return jsonify({'error': str(e)}), 500 + + +@lan_spy_bp.route('/risk-score/', methods=['GET']) +def get_risk_score(mac): + """Calculate risk score for a device.""" + try: + device = lan_spy_state['database'].get_device(mac) + if not device: + return jsonify({'error': 'Device not found'}), 404 + + risk_data = lan_spy_state['risk_scorer'].calculate_risk(device) + return jsonify(risk_data), 200 + except Exception as e: + logger.error(f"Error calculating risk score: {e}") + return jsonify({'error': str(e)}), 500 + + +@lan_spy_bp.route('/device//tracking', methods=['POST']) +def toggle_tracking(mac): + """Toggle tracking device flag.""" + try: + value = request.json.get('value', True) if request.json else True + lan_spy_state['database'].update_device_flag(mac, 'tracking_device', value) + return jsonify({'status': 'updated', 'mac': mac, 'tracking_device': value}), 200 + except Exception as e: + logger.error(f"Error updating tracking flag: {e}") + return jsonify({'error': str(e)}), 500 + + +@lan_spy_bp.route('/device//surveillance', methods=['POST']) +def toggle_surveillance(mac): + """Toggle surveillance device flag.""" + try: + value = request.json.get('value', True) if request.json else True + lan_spy_state['database'].update_device_flag(mac, 'surveillance_device', value) + return jsonify({'status': 'updated', 'mac': mac, 'surveillance_device': value}), 200 + except Exception as e: + logger.error(f"Error updating surveillance flag: {e}") + return jsonify({'error': str(e)}), 500 + + +@lan_spy_bp.route('/oui/update', methods=['POST']) +def update_oui(): + """Download and update OUI database.""" + try: + _emit_event('oui_update_start', {'message': 'Downloading OUI database...'}) + result = update_oui_database() + _emit_event('oui_update_complete', result) + return jsonify(result), 200 + except Exception as e: + logger.error(f"Error updating OUI: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@lan_spy_bp.route('/events', methods=['GET']) +def events(): + """SSE event stream for real-time updates.""" + def event_stream(): + while True: + try: + # Get next event with timeout + event = lan_spy_state['event_queue'].get(timeout=30) + yield f"data: {json.dumps(event)}\n\n" + except queue.Empty: + # Keep connection alive + yield ": keepalive\n\n" + except Exception as e: + logger.error(f"Event stream error: {e}") + break + + return Response( + event_stream(), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no' + } + ) diff --git a/static/css/modes/lan_spy_dashboard.css b/static/css/modes/lan_spy_dashboard.css new file mode 100644 index 00000000..b291429c --- /dev/null +++ b/static/css/modes/lan_spy_dashboard.css @@ -0,0 +1,664 @@ +/* LAN SPY Dashboard Styling - Enhanced & Polished */ + +.lan-spy-container { + display: flex; + height: 100%; + width: 100%; + gap: 0; + background: linear-gradient(135deg, #0a0e27 0%, #0d1117 100%); + color: #00ff41; + font-family: 'JetBrains Mono', 'Courier New', monospace; + overflow: hidden; + box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.5); + box-sizing: border-box; +} + +/* Sidebar */ +.lan-spy-sidebar { + width: 30%; + min-width: 30%; + max-width: 30%; + flex-shrink: 0; + border-right: 2px solid #00ff41; + overflow-y: auto; + background: linear-gradient(180deg, #0d1117 0%, #0a0e27 100%); + padding: 0; + display: flex; + flex-direction: column; + box-shadow: 2px 0 12px rgba(0, 255, 65, 0.1); +} + +.lan-spy-header { + padding: 18px; + border-bottom: 2px solid #00ff41; + background: linear-gradient(135deg, #161b22 0%, #0d1117 100%); + box-shadow: 0 4px 12px rgba(0, 255, 65, 0.15); + box-sizing: border-box; + flex-shrink: 0; + min-height: 80px; +} + +.lan-spy-header h2 { + margin: 0 0 12px 0; + font-size: 15px; + color: #00ff41; + text-transform: uppercase; + letter-spacing: 2px; + font-weight: 700; + text-shadow: 0 0 10px rgba(0, 255, 65, 0.3); +} + +.lan-spy-controls { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.lan-spy-controls .btn { + flex: 1; + min-width: 80px; + padding: 7px 11px; + font-size: 11px; + border: 1.5px solid #00ff41; + background: linear-gradient(135deg, transparent 0%, rgba(0, 255, 65, 0.05) 100%); + color: #00ff41; + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease; + font-family: 'JetBrains Mono', monospace; + border-radius: 3px; + font-weight: 600; + text-transform: uppercase; +} + +.lan-spy-controls .btn:hover { + background: linear-gradient(135deg, #00ff41 0%, #00dd38 100%); + color: #0d1117; + box-shadow: 0 4px 12px rgba(0, 255, 65, 0.4), inset 0 1px 2px rgba(255, 255, 255, 0.2); +} + +.lan-spy-controls .btn:active { + transform: none; +} + +.lan-spy-controls .btn .icon { + margin-right: 4px; + display: inline-block; +} + +/* Network Input */ +.lan-spy-network-input { + padding: 14px; + border-bottom: 1px solid #1f6feb; + background: rgba(15, 23, 42, 0.8); + flex-shrink: 0; + box-sizing: border-box; +} + +.lan-spy-network-input label { + display: block; + font-size: 10px; + color: #58a6ff; + text-transform: uppercase; + margin-bottom: 7px; + font-weight: 700; + letter-spacing: 1px; +} + +.lan-spy-network-input input { + width: 100%; + padding: 7px 10px; + background: linear-gradient(135deg, #0d1117 0%, #161b22 100%); + border: 1.5px solid #30363d; + color: #00ff41; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + box-sizing: border-box; + border-radius: 3px; + transition: all 0.2s ease; +} + +.lan-spy-network-input input:focus { + outline: none; + border-color: #00ff41; + box-shadow: 0 0 10px rgba(0, 255, 65, 0.4), inset 0 0 5px rgba(0, 255, 65, 0.1); + background: linear-gradient(135deg, #1a2030 0%, #0d1117 100%); +} + +.lan-spy-network-input small { + display: block; + margin-top: 5px; + font-size: 9px; + color: #8b949e; + font-style: italic; +} + +/* Device List */ +.lan-spy-device-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 8px 0; + box-sizing: border-box; + min-height: 0; +} + +.lan-spy-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: #8b949e; + text-align: center; + padding: 20px; +} + +.lan-spy-empty-state p { + margin: 5px 0; + font-size: 14px; +} + +.lan-spy-empty-state p:first-child { + font-size: 32px; + margin-bottom: 10px; + opacity: 0.7; +} + +.lan-spy-empty-state small { + font-size: 10px; + color: #6e7681; +} + +.lan-spy-device-item { + padding: 12px 10px; + border-bottom: 1px solid #21262d; + border-left: 3px solid transparent; + cursor: pointer; + transition: background-color 0.15s ease, border-left-color 0.15s ease; + margin: 0 4px; + border-radius: 0 3px 3px 0; +} + +.lan-spy-device-item:hover { + background: linear-gradient(90deg, rgba(31, 111, 235, 0.15) 0%, rgba(0, 255, 65, 0.08) 100%); + border-left-color: #00ff41; +} + +.lan-spy-device-item.active { + background: linear-gradient(90deg, rgba(31, 111, 235, 0.25) 0%, rgba(0, 255, 65, 0.15) 100%); + border-left-color: #00ff41; + box-shadow: inset 2px 0 8px rgba(0, 255, 65, 0.2), 0 2px 8px rgba(0, 255, 65, 0.2); +} + +.lan-spy-device-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 10px; + margin-bottom: 6px; +} + +.lan-spy-device-primary { + font-size: 12px; + font-weight: 600; + color: #00ff41; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + text-shadow: 0 0 5px rgba(0, 255, 65, 0.2); +} + +.lan-spy-device-secondary { + font-size: 10px; + color: #8b949e; + display: flex; + flex-direction: column; + gap: 2px; +} + +.device-meta-ip { + color: #58a6ff; + font-weight: 500; +} + +.device-meta-mfr { + color: #79c0ff; + opacity: 0.9; +} + +.lan-spy-device-badge { + display: inline-block; + padding: 3px 7px; + font-size: 9px; + border-radius: 3px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + white-space: nowrap; +} + +.lan-spy-device-badge.green { + background: linear-gradient(135deg, #238636 0%, #2ea043 100%); + color: #aaffc1; + border: 1px solid #3fb950; +} + +.lan-spy-device-badge.amber { + background: linear-gradient(135deg, #9e6a03 0%, #bf8700 100%); + color: #ffdf5d; + border: 1px solid #d29922; +} + +.lan-spy-device-badge.red { + background: linear-gradient(135deg, #da3633 0%, #f85149 100%); + color: #ffebe6; + border: 1px solid #ff7b72; +} + +/* Main Content Area */ +.lan-spy-main { + width: 70%; + min-width: 70%; + max-width: 70%; + flex-shrink: 0; + display: flex; + flex-direction: column; + padding: 25px; + overflow-y: auto; + background: linear-gradient(135deg, #0a0e27 0%, #0d1117 100%); + gap: 20px; +} + +.lan-spy-detail-card { + flex: 1; + background: linear-gradient(135deg, #0d1117 0%, #161b22 100%); + border: 2px solid #1f6feb; + border-radius: 4px; + padding: 25px; + overflow-y: auto; + box-shadow: 0 8px 24px rgba(31, 111, 235, 0.15), inset 0 1px 2px rgba(255, 255, 255, 0.05); + box-sizing: border-box; + min-height: 0; +} + +.lan-spy-detail-card:hover { + border-color: #58a6ff; +} + +.lan-spy-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: #8b949e; + text-align: center; + gap: 10px; +} + +.lan-spy-placeholder p { + margin: 0; + font-size: 14px; +} + +.lan-spy-placeholder p:first-child { + font-size: 28px; + opacity: 0.6; +} + +/* Device Details */ +.lan-spy-detail-content { + color: #00ff41; +} + +.lan-spy-detail-section { + margin-bottom: 22px; + padding-bottom: 18px; + border-bottom: 1px solid #21262d; +} + +.lan-spy-detail-section:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.lan-spy-detail-title { + font-size: 11px; + color: #58a6ff; + text-transform: uppercase; + font-weight: 700; + margin-bottom: 12px; + letter-spacing: 1.5px; + padding-bottom: 8px; + border-bottom: 1px solid #1f6feb; +} + +.lan-spy-detail-row { + display: flex; + justify-content: space-between; + padding: 8px 0; + font-size: 12px; + border-bottom: 1px solid #30363d; + transition: all 0.2s ease; +} + +.lan-spy-detail-row:hover { + background: rgba(0, 255, 65, 0.03); + padding-left: 4px; +} + +.lan-spy-detail-row:last-child { + border-bottom: none; +} + +.lan-spy-detail-label { + color: #8b949e; + flex: 0 0 40%; + font-weight: 500; +} + +.lan-spy-detail-value { + color: #00ff41; + flex: 1; + text-align: right; + word-break: break-all; + font-family: 'JetBrains Mono', monospace; + text-shadow: 0 0 5px rgba(0, 255, 65, 0.2); +} + +.lan-spy-risk-index { + font-size: 14px; + font-weight: 700; + padding: 10px 12px; + border-radius: 4px; + display: inline-block; + margin: 12px 0; + letter-spacing: 0.5px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.lan-spy-risk-index.green { + background: linear-gradient(135deg, #238636 0%, #2ea043 100%); + color: #aaffc1; + border: 1px solid #3fb950; +} + +.lan-spy-risk-index.amber { + background: linear-gradient(135deg, #9e6a03 0%, #bf8700 100%); + color: #ffdf5d; + border: 1px solid #d29922; +} + +.lan-spy-risk-index.red { + background: linear-gradient(135deg, #da3633 0%, #f85149 100%); + color: #ffebe6; + border: 1px solid #ff7b72; +} + +/* Action Buttons */ +.lan-spy-action-buttons { + display: flex; + gap: 12px; +} + +.lan-spy-action-buttons .btn { + flex: 1; + padding: 11px 16px; + background: linear-gradient(135deg, transparent 0%, rgba(0, 255, 65, 0.05) 100%); + border: 1.5px solid #00ff41; + color: #00ff41; + cursor: pointer; + border-radius: 4px; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease; + text-transform: uppercase; + font-weight: 700; + letter-spacing: 0.5px; +} + +.lan-spy-action-buttons .btn:hover { + background: linear-gradient(135deg, #00ff41 0%, #00dd38 100%); + color: #0d1117; + box-shadow: 0 6px 16px rgba(0, 255, 65, 0.35), inset 0 1px 2px rgba(255, 255, 255, 0.2); +} + +.lan-spy-action-buttons .btn:active { + transform: translateY(0); +} + +.lan-spy-action-buttons .btn.btn-danger { + border-color: #ff7b72; + color: #ff7b72; + background: linear-gradient(135deg, transparent 0%, rgba(255, 123, 114, 0.05) 100%); +} + +.lan-spy-action-buttons .btn.btn-danger:hover { + background: linear-gradient(135deg, #da3633 0%, #f85149 100%); + color: #fff; + border-color: #ff7b72; + box-shadow: 0 6px 16px rgba(218, 54, 51, 0.4); +} + +.lan-spy-action-buttons .btn.btn-info { + border-color: #58a6ff; + color: #58a6ff; + background: linear-gradient(135deg, transparent 0%, rgba(88, 166, 255, 0.05) 100%); +} + +.lan-spy-action-buttons .btn.btn-info:hover { + background: linear-gradient(135deg, #1f6feb 0%, #388bfd 100%); + color: #fff; + border-color: #58a6ff; + box-shadow: 0 6px 16px rgba(31, 111, 235, 0.4); +} + +.lan-spy-action-buttons .btn .icon { + margin-right: 6px; + display: inline-block; +} + +/* Status Loader (Non-blocking Corner Notification) */ +.lan-spy-loader { + position: fixed; + bottom: 24px; + right: 24px; + min-width: 290px; + max-width: 340px; + background: linear-gradient(135deg, #0d1117 0%, #161b22 100%); + border: 2px solid #00ff41; + border-radius: 4px; + padding: 16px; + z-index: 5000; + display: none; + flex-direction: column; + gap: 12px; + box-shadow: 0 8px 24px rgba(0, 255, 65, 0.25), inset 0 1px 2px rgba(255, 255, 255, 0.05); + animation: slideInRight 0.3s ease-out; +} + +.lan-spy-loader.visible { + display: flex; +} + +.lan-spy-loader.hiding { + animation: slideOutRight 0.3s ease-in forwards; +} + +@keyframes slideInRight { + from { + transform: translateX(420px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes slideOutRight { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(420px); + opacity: 0; + } +} + +.lan-spy-spinner-container { + display: flex; + align-items: center; + gap: 12px; +} + +.lan-spy-spinner { + width: 24px; + height: 24px; + border: 2.5px solid #30363d; + border-top-color: #00ff41; + border-radius: 50%; + animation: spin 1s linear infinite; + flex-shrink: 0; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.lan-spy-loader-text { + font-size: 13px; + color: #00ff41; + font-weight: 700; + font-family: 'JetBrains Mono', monospace; + text-shadow: 0 0 8px rgba(0, 255, 65, 0.3); +} + +.lan-spy-loader-subtext { + font-size: 11px; + color: #58a6ff; + font-family: 'JetBrains Mono', monospace; + margin-top: -6px; +} + +/* Responsive Design */ +@media (max-width: 1024px) { + .lan-spy-container { + flex-direction: column; + } + + .lan-spy-sidebar { + width: 100%; + height: 40%; + border-right: none; + border-bottom: 2px solid #00ff41; + } + + .lan-spy-main { + width: 100%; + height: 60%; + } +} + +@media (max-width: 768px) { + .lan-spy-loader { + bottom: 16px; + right: 16px; + min-width: 270px; + max-width: 300px; + } + + .lan-spy-main { + padding: 15px; + } + + .lan-spy-detail-card { + padding: 15px; + } + + .lan-spy-detail-row { + flex-direction: column; + } + + .lan-spy-detail-value { + text-align: left; + margin-top: 4px; + } + + .lan-spy-controls { + flex-direction: column; + } + + .lan-spy-controls .btn { + width: 100%; + } + + .lan-spy-action-buttons { + flex-direction: column; + } +} + +/* Scrollbar Styling */ +.lan-spy-sidebar::-webkit-scrollbar, +.lan-spy-main::-webkit-scrollbar, +.lan-spy-device-list::-webkit-scrollbar, +.lan-spy-detail-card::-webkit-scrollbar { + width: 8px; +} + +.lan-spy-sidebar::-webkit-scrollbar-track, +.lan-spy-main::-webkit-scrollbar-track, +.lan-spy-device-list::-webkit-scrollbar-track, +.lan-spy-detail-card::-webkit-scrollbar-track { + background: rgba(13, 17, 23, 0.5); +} + +.lan-spy-sidebar::-webkit-scrollbar-thumb, +.lan-spy-main::-webkit-scrollbar-thumb, +.lan-spy-device-list::-webkit-scrollbar-thumb, +.lan-spy-detail-card::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, #00ff41 0%, #00dd38 100%); + border-radius: 4px; + border: 2px solid #0d1117; +} + +.lan-spy-sidebar::-webkit-scrollbar-thumb:hover, +.lan-spy-main::-webkit-scrollbar-thumb:hover, +.lan-spy-device-list::-webkit-scrollbar-thumb:hover, +.lan-spy-detail-card::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, #58a6ff 0%, #79c0ff 100%); +} + +/* Toggle Switches for Flags */ +.lan-spy-toggle { + display: flex; + align-items: center; + gap: 12px; + margin: 12px 0; + padding: 10px; + background: rgba(0, 255, 65, 0.03); + border-radius: 3px; + border-left: 2px solid #00ff41; +} + +.lan-spy-toggle label { + font-size: 12px; + color: #8b949e; + margin: 0; + flex: 1; + font-weight: 500; +} + +.lan-spy-toggle input[type="checkbox"] { + width: 44px; + height: 24px; + cursor: pointer; + accent-color: #00ff41; + flex-shrink: 0; +} + diff --git a/static/js/lan_spy.js b/static/js/lan_spy.js new file mode 100644 index 00000000..4d48d67f --- /dev/null +++ b/static/js/lan_spy.js @@ -0,0 +1,528 @@ +/** + * LAN SPY Frontend + * Network device discovery and analysis + */ + +// Global state +let lanSpyDevices = []; +let lanSpyScanRunning = false; +let lanSpyScanStatus = 'Ready'; +let lanSpyEventSource = null; +let lanSpySelectedMac = null; + +/** + * Initialize LAN SPY mode + */ +function switchToLanSpy() { + console.log('Switching to LAN SPY mode'); + + // Load devices from backend + refreshLanSpyDevices(); + + // Connect to SSE stream + connectToLanSpyEvents(); +} + +/** + * Start network scan + */ +function startLanSpyScan() { + const networkInput = document.getElementById('lanSpyNetworkInput'); + const network = networkInput.value.trim() || null; + + // Validate network if provided + if (network && !isValidCIDR(network)) { + showNotification('Invalid CIDR format. Use: 192.168.1.0/24', 'error'); + return; + } + + console.log('Starting LAN SPY scan on network:', network || 'auto-detect'); + lanSpyScanRunning = true; + + // Show loader + showLanSpyLoader('Initializing scan...', true); + + fetch('/lan_spy/scan', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ network: network }) + }) + .then(r => r.json()) + .then(data => { + console.log('Scan started:', data); + lanSpyScanStatus = 'Scanning...'; + }) + .catch(err => { + console.error('Scan error:', err); + showNotification('Failed to start scan', 'error'); + hideLanSpyLoader(); + lanSpyScanRunning = false; + }); +} + +/** + * Stop network scan + */ +function stopLanSpyScan() { + console.log('Stopping scan'); + + fetch('/lan_spy/scan/stop', { + method: 'POST' + }) + .then(r => r.json()) + .then(data => { + console.log('Scan stopped:', data); + lanSpyScanRunning = false; + }) + .catch(err => console.error('Stop error:', err)); +} + +/** + * Refresh device list + */ +function refreshLanSpyDevices() { + fetch('/lan_spy/devices') + .then(r => r.json()) + .then(data => { + lanSpyDevices = data.devices || []; + renderLanSpyDeviceList(); + console.log('Loaded', lanSpyDevices.length, 'devices'); + }) + .catch(err => console.error('Refresh error:', err)); +} + +/** + * Show non-blocking corner loader notification + */ +function showLanSpyLoader(message, show = true) { + let loader = document.getElementById('lanSpyLoader'); + + if (!loader) { + // Create loader element + loader = document.createElement('div'); + loader.id = 'lanSpyLoader'; + loader.className = 'lan-spy-loader'; + loader.innerHTML = ` +
+
+
+
Loading...
+
Devices: 0
+
+
+ `; + document.body.appendChild(loader); + } + + if (show) { + document.getElementById('lanSpyLoaderText').textContent = message; + loader.classList.add('visible'); + loader.classList.remove('hiding'); + } else { + hideLanSpyLoader(); + } +} + +/** + * Update loader status in real-time + */ +function updateLanSpyLoader(message, deviceCount = null) { + const loaderText = document.getElementById('lanSpyLoaderText'); + const loaderSubtext = document.getElementById('lanSpyLoaderSubtext'); + + if (loaderText) { + loaderText.textContent = message; + } + + if (loaderSubtext && deviceCount !== null) { + loaderSubtext.textContent = `Devices: ${deviceCount}`; + } +} + +/** + * Hide loader with animation + */ +function hideLanSpyLoader() { + const loader = document.getElementById('lanSpyLoader'); + if (loader) { + loader.classList.add('hiding'); + setTimeout(() => { + loader.classList.remove('visible'); + loader.classList.remove('hiding'); + }, 300); + } +} + +/** + * Connect to SSE event stream + */ +function connectToLanSpyEvents() { + if (lanSpyEventSource) { + lanSpyEventSource.close(); + } + + lanSpyEventSource = new EventSource('/lan_spy/events'); + + lanSpyEventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + switch (data.type) { + case 'scan_start': + console.log('Scan started:', data); + showLanSpyLoader('Initializing scan...', true); + lanSpyScanRunning = true; + break; + + case 'scan_status': + lanSpyScanStatus = data.data.message; + updateLanSpyLoader(data.data.message, lanSpyDevices.length); + console.log('Status:', data.data.message); + break; + + case 'device_found': + lanSpyDevices.push(data.data); + renderLanSpyDeviceList(); + updateLanSpyLoader(lanSpyScanStatus, lanSpyDevices.length); + console.log('Device found:', data.data); + break; + + case 'scan_complete': + lanSpyScanRunning = false; + hideLanSpyLoader(); + console.log('Scan complete:', data.data); + showNotification(`Found ${data.data.devices_found} devices`, 'success'); + break; + + case 'scan_error': + lanSpyScanRunning = false; + hideLanSpyLoader(); + showNotification('Scan error: ' + data.data.message, 'error'); + console.error('Scan error:', data.data); + break; + + case 'oui_update_complete': + showNotification('OUI database updated', 'success'); + console.log('OUI updated:', data.data); + break; + } + } catch (e) { + console.error('Event parsing error:', e); + } + }; + + lanSpyEventSource.onerror = (err) => { + console.error('SSE error:', err); + lanSpyEventSource.close(); + + // Reconnect after delay + setTimeout(() => { + console.log('Reconnecting to SSE stream...'); + connectToLanSpyEvents(); + }, 5000); + }; +} + +/** + * Render device list sidebar (deduplicated by MAC) + */ +function renderLanSpyDeviceList() { + const listContainer = document.getElementById('lanSpyDeviceList'); + + if (!lanSpyDevices || lanSpyDevices.length === 0) { + listContainer.innerHTML = '

🔍

No devices found

Start a scan to discover devices
'; + return; + } + + // Deduplicate devices by MAC address (keep last occurrence) + const uniqueDevices = {}; + lanSpyDevices.forEach(device => { + uniqueDevices[device.mac] = device; + }); + + const deviceList = Object.values(uniqueDevices); + + // Sort by risk index (highest first) + deviceList.sort((a, b) => (b.risk_index || 0) - (a.risk_index || 0)); + + listContainer.innerHTML = deviceList.map(device => { + const riskBadge = getRiskBadge(device.risk_index || 0); + const isActive = lanSpySelectedMac === device.mac ? 'active' : ''; + const riskLevel = (device.risk_index || 0).toFixed(2); + + return ` +
+
+
+ ${device.hostname || device.internal_ip} +
+
+ ${riskLevel} +
+
+
+ ${device.internal_ip} + ${device.mfr} +
+
+ `; + }).join(''); +} + +/** + * Select device to view details + */ +function selectLanSpyDevice(mac) { + lanSpySelectedMac = mac; + + // Update active state + document.querySelectorAll('.lan-spy-device-item').forEach(item => { + item.classList.remove('active'); + }); + event.currentTarget.classList.add('active'); + + // Display device details + displayLanSpyDeviceDetails(mac); +} + +/** + * Display selected device details + */ +function displayLanSpyDeviceDetails(mac) { + const device = lanSpyDevices.find(d => d.mac === mac); + + if (!device) { + console.error('Device not found:', mac); + return; + } + + const riskColor = getRiskColor(device.risk_index || 0); + const riskBadge = getRiskBadge(device.risk_index || 0); + + const html = ` +
+ +
+
Subject Identity
+
+ MAC Address + ${device.mac} +
+
+ Hostname + ${device.hostname || 'N/A'} +
+
+ Manufacturer + ${device.mfr} +
+
+ Device Class + ${device.class} +
+
+ Signal ID + ${device.signal_id || 'N/A'} +
+
+ + +
+
Network Telemetry
+
+ Internal IP + ${device.internal_ip} +
+
+ Bandwidth + ${device.bandwidth_utilization} +
+
+ Total Bytes + ${device.bytes_total.toLocaleString()} +
+
+ Current Flow (kbps) + ${device.current_flow_kbps.toFixed(2)} +
+
+ + +
+
External Intelligence
+
+ Primary Uplink + ${device.primary_uplink} +
+
+ Destination Country + ${device.destination_country} +
+
+ Protocol + ${device.protocol_detected} +
+
+ + +
+
Security Posture
+
+ Risk Index + + ${(device.risk_index || 0).toFixed(2)} (${riskBadge.toUpperCase()}) + +
+
+ Exposed Services + ${device.exposed_services.length > 0 ? device.exposed_services.join(', ') : 'None'} +
+
+ + +
+
+ + +
+
+ + +
+
Activity
+
+ Last Seen + ${new Date(device.last_seen).toLocaleString()} +
+
+
+ `; + + document.getElementById('lanSpyDetailCard').innerHTML = html; +} + +/** + * Toggle tracking or surveillance flag + */ +function toggleLanSpyFlag(mac, flagType, value) { + const endpoint = flagType === 'tracking' ? 'tracking' : 'surveillance'; + + fetch(`/lan_spy/device/${mac}/${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ value: value }) + }) + .then(r => r.json()) + .then(data => { + console.log('Flag updated:', data); + + // Update local device + const device = lanSpyDevices.find(d => d.mac === mac); + if (device) { + if (flagType === 'tracking') { + device.tracking_device = value; + } else { + device.surveillance_device = value; + } + } + + showNotification(`${flagType} device flag updated`, 'success'); + }) + .catch(err => { + console.error('Flag update error:', err); + showNotification('Failed to update flag', 'error'); + }); +} + +/** + * Kill all running processes + */ +function killAllLanSpyProcesses() { + if (!confirm('Are you sure you want to kill all LAN SPY processes?')) { + return; + } + + console.log('Killing all processes'); + stopLanSpyScan(); + showNotification('Processes terminated', 'info'); +} + +/** + * Update OUI database + */ +function updateLanSpyOUI() { + console.log('Updating OUI database'); + showLanSpyLoader('Downloading OUI database...', true); + + fetch('/lan_spy/oui/update', { + method: 'POST' + }) + .then(r => r.json()) + .then(data => { + console.log('OUI update:', data); + hideLanSpyLoader(); + if (data.status === 'success') { + showNotification('OUI database updated successfully', 'success'); + } else { + showNotification('OUI update failed: ' + data.message, 'error'); + } + }) + .catch(err => { + console.error('OUI update error:', err); + hideLanSpyLoader(); + showNotification('Failed to update OUI database', 'error'); + }); +} + +/** + * Get risk badge color (green/amber/red) + */ +function getRiskBadge(riskIndex) { + if (riskIndex < 0.40) return 'green'; + if (riskIndex < 0.70) return 'amber'; + return 'red'; +} + +/** + * Get risk color for styling + */ +function getRiskColor(riskIndex) { + const badge = getRiskBadge(riskIndex); + const colors = { + 'green': '#28a745', + 'amber': '#ffc107', + 'red': '#dc3545' + }; + return colors[badge] || '#6c757d'; +} + +/** + * Validate CIDR notation + */ +function isValidCIDR(cidr) { + const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/; + return cidrRegex.test(cidr); +} + +/** + * Show notification (using app's notification system) + */ +function showNotification(message, type = 'info') { + console.log(`[${type.toUpperCase()}] ${message}`); + + // If app has notification system, use it + if (window.showAppNotification) { + window.showAppNotification(message, type); + } +} + +// Initialize when loaded +document.addEventListener('DOMContentLoaded', () => { + console.log('LAN SPY module loaded'); +}); diff --git a/templates/index.html b/templates/index.html index ed33f2a1..454b3561 100644 --- a/templates/index.html +++ b/templates/index.html @@ -51,6 +51,7 @@ + @@ -220,6 +221,10 @@

TSCM + @@ -527,6 +532,8 @@

SDR Device

{% include 'partials/modes/tscm.html' %} + {% include 'partials/modes/lan_spy.html' %} + {% include 'partials/modes/ais.html' %} {% include 'partials/modes/spy-stations.html' %} @@ -2350,7 +2357,7 @@

Device Intelligence

const validModes = new Set([ 'pager', 'sensor', 'rtlamr', 'aprs', 'listening', 'spystations', 'meshtastic', 'wifi', 'bluetooth', - 'tscm', 'satellite', 'sstv', 'sstv_general', 'dmr', 'websdr' + 'tscm', 'lan_spy', 'satellite', 'sstv', 'sstv_general', 'dmr', 'websdr' ]); function getModeFromQuery() { @@ -2861,6 +2868,7 @@

Device Intelligence

document.getElementById('listeningPostMode')?.classList.toggle('active', mode === 'listening'); document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs'); document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm'); + document.getElementById('lanSpyMode')?.classList.toggle('active', mode === 'lan_spy'); document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais'); document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations'); document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic'); @@ -2898,6 +2906,7 @@

Device Intelligence

'listening': 'LISTENING POST', 'aprs': 'APRS', 'tscm': 'TSCM', + 'lan_spy': 'LAN SPY', 'ais': 'AIS VESSELS', 'spystations': 'SPY STATIONS', 'meshtastic': 'MESHTASTIC', @@ -2912,6 +2921,7 @@

Device Intelligence

const listeningPostVisuals = document.getElementById('listeningPostVisuals'); const aprsVisuals = document.getElementById('aprsVisuals'); const tscmVisuals = document.getElementById('tscmVisuals'); + const lanSpyContent = document.getElementById('lanSpyMode'); const spyStationsVisuals = document.getElementById('spyStationsVisuals'); const meshtasticVisuals = document.getElementById('meshtasticVisuals'); const sstvVisuals = document.getElementById('sstvVisuals'); @@ -2924,6 +2934,12 @@

Device Intelligence

if (listeningPostVisuals) listeningPostVisuals.style.display = mode === 'listening' ? 'grid' : 'none'; if (aprsVisuals) aprsVisuals.style.display = mode === 'aprs' ? 'flex' : 'none'; if (tscmVisuals) tscmVisuals.style.display = mode === 'tscm' ? 'flex' : 'none'; + if (lanSpyContent) { + lanSpyContent.style.display = mode === 'lan_spy' ? 'flex' : 'none'; + if (mode === 'lan_spy' && typeof switchToLanSpy === 'function') { + switchToLanSpy(); + } + } if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? 'flex' : 'none'; if (meshtasticVisuals) meshtasticVisuals.style.display = mode === 'meshtastic' ? 'flex' : 'none'; if (sstvVisuals) sstvVisuals.style.display = mode === 'sstv' ? 'flex' : 'none'; @@ -13959,6 +13975,7 @@

Documentation Required

+
diff --git a/templates/partials/modes/lan_spy.html b/templates/partials/modes/lan_spy.html new file mode 100644 index 00000000..f7beb208 --- /dev/null +++ b/templates/partials/modes/lan_spy.html @@ -0,0 +1,51 @@ + diff --git a/utils/lan_spy/database.py b/utils/lan_spy/database.py new file mode 100644 index 00000000..2376fd02 --- /dev/null +++ b/utils/lan_spy/database.py @@ -0,0 +1,349 @@ +""" +LAN SPY Database Module +SQLite persistence for discovered devices and scan history. +""" + +import sqlite3 +import logging +from datetime import datetime +from typing import Dict, List, Any, Optional +import os + +logger = logging.getLogger('intercept.lan_spy.database') + +DB_PATH = 'instance/lan_devices.db' + + +class LANDatabase: + """SQLite database for LAN SPY device storage.""" + + def __init__(self): + """Initialize database and create schema if needed.""" + os.makedirs('instance', exist_ok=True) + self.db_path = DB_PATH + self._init_schema() + + def _init_schema(self): + """Create database tables if they don't exist.""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + # Devices table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mac TEXT UNIQUE NOT NULL, + ip TEXT, + hostname TEXT, + manufacturer TEXT, + device_class TEXT, + label TEXT, + bandwidth_utilization TEXT, + bytes_total INTEGER DEFAULT 0, + current_flow_kbps REAL DEFAULT 0.0, + primary_uplink TEXT, + destination_country TEXT, + protocol_detected TEXT, + exposed_services TEXT, + tracking_device INTEGER DEFAULT 0, + surveillance_device INTEGER DEFAULT 0, + first_seen TEXT, + last_seen TEXT + ) + ''') + + # Scan history table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS scan_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scan_timestamp TEXT NOT NULL, + network_scanned TEXT, + devices_found INTEGER, + scan_duration_seconds REAL, + scan_status TEXT + ) + ''') + + # Risk scores table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS risk_scores ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mac TEXT UNIQUE NOT NULL, + hardware_score REAL DEFAULT 0.0, + exposure_score REAL DEFAULT 0.0, + external_score REAL DEFAULT 0.0, + traffic_score REAL DEFAULT 0.0, + total_risk_index REAL DEFAULT 0.0, + last_calculated TEXT, + FOREIGN KEY (mac) REFERENCES devices(mac) + ) + ''') + + conn.commit() + conn.close() + logger.info(f"Database initialized at {self.db_path}") + except Exception as e: + logger.error(f"Error initializing database schema: {e}") + + def add_device(self, device: Dict[str, Any]) -> bool: + """Add or update a device in the database.""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + now = datetime.now().isoformat() + 'Z' + + # Check if device exists + cursor.execute('SELECT id FROM devices WHERE mac = ?', (device['mac'],)) + existing = cursor.fetchone() + + if existing: + # Update existing device + cursor.execute(''' + UPDATE devices SET + ip = ?, hostname = ?, manufacturer = ?, + device_class = ?, label = ?, bandwidth_utilization = ?, + bytes_total = ?, current_flow_kbps = ?, + primary_uplink = ?, destination_country = ?, + protocol_detected = ?, exposed_services = ?, + last_seen = ? + WHERE mac = ? + ''', ( + device.get('internal_ip'), device.get('hostname'), + device.get('mfr'), device.get('class'), + device.get('label'), device.get('bandwidth_utilization'), + device.get('bytes_total', 0), device.get('current_flow_kbps', 0.0), + device.get('primary_uplink'), device.get('destination_country'), + device.get('protocol_detected'), str(device.get('exposed_services', [])), + now, device['mac'] + )) + else: + # Insert new device + cursor.execute(''' + INSERT INTO devices ( + mac, ip, hostname, manufacturer, device_class, + label, bandwidth_utilization, bytes_total, + current_flow_kbps, primary_uplink, destination_country, + protocol_detected, exposed_services, first_seen, last_seen + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + device['mac'], device.get('internal_ip'), device.get('hostname'), + device.get('mfr'), device.get('class'), device.get('label'), + device.get('bandwidth_utilization'), device.get('bytes_total', 0), + device.get('current_flow_kbps', 0.0), device.get('primary_uplink'), + device.get('destination_country'), device.get('protocol_detected'), + str(device.get('exposed_services', [])), now, now + )) + + conn.commit() + conn.close() + return True + except Exception as e: + logger.error(f"Error adding device {device.get('mac')}: {e}") + return False + + def get_all_devices(self) -> List[Dict[str, Any]]: + """Retrieve all devices from database.""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute(''' + SELECT mac, ip, hostname, manufacturer, device_class, + label, bandwidth_utilization, bytes_total, + current_flow_kbps, primary_uplink, destination_country, + protocol_detected, exposed_services, tracking_device, + surveillance_device, first_seen, last_seen + FROM devices + ''') + + devices = [] + for row in cursor.fetchall(): + devices.append({ + 'mac': row[0], + 'internal_ip': row[1], + 'hostname': row[2], + 'mfr': row[3], + 'class': row[4], + 'label': row[5], + 'bandwidth_utilization': row[6], + 'bytes_total': row[7], + 'current_flow_kbps': row[8], + 'primary_uplink': row[9], + 'destination_country': row[10], + 'protocol_detected': row[11], + 'exposed_services': row[12], + 'tracking_device': bool(row[13]), + 'surveillance_device': bool(row[14]), + 'first_seen': row[15], + 'last_seen': row[16] + }) + + conn.close() + return devices + except Exception as e: + logger.error(f"Error retrieving devices: {e}") + return [] + + def get_device(self, mac: str) -> Optional[Dict[str, Any]]: + """Get a specific device by MAC address.""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute(''' + SELECT mac, ip, hostname, manufacturer, device_class, + label, bandwidth_utilization, bytes_total, + current_flow_kbps, primary_uplink, destination_country, + protocol_detected, exposed_services, tracking_device, + surveillance_device, first_seen, last_seen + FROM devices WHERE mac = ? + ''', (mac,)) + + row = cursor.fetchone() + conn.close() + + if row: + return { + 'mac': row[0], + 'internal_ip': row[1], + 'hostname': row[2], + 'mfr': row[3], + 'class': row[4], + 'label': row[5], + 'bandwidth_utilization': row[6], + 'bytes_total': row[7], + 'current_flow_kbps': row[8], + 'primary_uplink': row[9], + 'destination_country': row[10], + 'protocol_detected': row[11], + 'exposed_services': row[12], + 'tracking_device': bool(row[13]), + 'surveillance_device': bool(row[14]), + 'first_seen': row[15], + 'last_seen': row[16] + } + return None + except Exception as e: + logger.error(f"Error retrieving device {mac}: {e}") + return None + + def update_device_flag(self, mac: str, flag_name: str, value: bool) -> bool: + """Update tracking or surveillance device flag.""" + try: + if flag_name not in ['tracking_device', 'surveillance_device']: + return False + + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + query = f'UPDATE devices SET {flag_name} = ? WHERE mac = ?' + cursor.execute(query, (int(value), mac)) + + conn.commit() + conn.close() + return True + except Exception as e: + logger.error(f"Error updating device flag: {e}") + return False + + def record_scan(self, network: str, devices_found: int, duration: float) -> bool: + """Record scan history.""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + now = datetime.now().isoformat() + 'Z' + + cursor.execute(''' + INSERT INTO scan_history ( + scan_timestamp, network_scanned, devices_found, + scan_duration_seconds, scan_status + ) VALUES (?, ?, ?, ?, ?) + ''', (now, network, devices_found, duration, 'completed')) + + conn.commit() + conn.close() + return True + except Exception as e: + logger.error(f"Error recording scan: {e}") + return False + + def add_risk_score(self, mac: str, hardware: float, exposure: float, + external: float, traffic: float, total: float) -> bool: + """Add or update risk score for a device.""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + now = datetime.now().isoformat() + 'Z' + + cursor.execute('SELECT id FROM risk_scores WHERE mac = ?', (mac,)) + existing = cursor.fetchone() + + if existing: + cursor.execute(''' + UPDATE risk_scores SET + hardware_score = ?, exposure_score = ?, + external_score = ?, traffic_score = ?, + total_risk_index = ?, last_calculated = ? + WHERE mac = ? + ''', (hardware, exposure, external, traffic, total, now, mac)) + else: + cursor.execute(''' + INSERT INTO risk_scores ( + mac, hardware_score, exposure_score, + external_score, traffic_score, total_risk_index, + last_calculated + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ''', (mac, hardware, exposure, external, traffic, total, now)) + + conn.commit() + conn.close() + return True + except Exception as e: + logger.error(f"Error adding risk score: {e}") + return False + + def get_risk_score(self, mac: str) -> Optional[Dict[str, float]]: + """Get risk score for a device.""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute(''' + SELECT hardware_score, exposure_score, external_score, + traffic_score, total_risk_index, last_calculated + FROM risk_scores WHERE mac = ? + ''', (mac,)) + + row = cursor.fetchone() + conn.close() + + if row: + return { + 'hardware': row[0], + 'exposure': row[1], + 'external': row[2], + 'traffic': row[3], + 'total': row[4], + 'last_calculated': row[5] + } + return None + except Exception as e: + logger.error(f"Error retrieving risk score: {e}") + return None + + def clear_devices(self) -> bool: + """Clear all devices from database.""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute('DELETE FROM devices') + conn.commit() + conn.close() + return True + except Exception as e: + logger.error(f"Error clearing devices: {e}") + return False diff --git a/utils/lan_spy/risk_scoring.py b/utils/lan_spy/risk_scoring.py new file mode 100644 index 00000000..d4ba429b --- /dev/null +++ b/utils/lan_spy/risk_scoring.py @@ -0,0 +1,223 @@ +""" +LAN SPY Risk Scoring Module +4-factor algorithm for device risk assessment. +""" + +import logging +from typing import Dict, Any +import yaml +import os + +logger = logging.getLogger('intercept.lan_spy.risk_scoring') + +# Default risk config (can be overridden by instance/risk_config.yaml) +DEFAULT_CONFIG = { + 'weights': { + 'hardware': 0.35, + 'exposure': 0.25, + 'external': 0.25, + 'traffic': 0.15 + }, + 'banned_manufacturers': [ + 'Hikvision', 'Hangzhou Hikvision Digital Technology', + 'Dahua', 'Zhejiang Dahua Technology', + 'Huawei', 'Huawei Technologies', + 'ZTE', 'Hytera', 'Hytera Communications', + 'DJI', 'Da-Jiang Innovations', + 'Autel Robotics', 'EZVIZ' + ], + 'suspicious_keywords': ['Tuya', 'Xiaomi', 'Anker'], + 'high_risk_countries': { + 'CN': 1.0, # China + 'RU': 0.9, # Russia + 'IR': 0.9, # Iran + 'KP': 1.0, # North Korea + 'SY': 0.8 # Syria + }, + 'critical_ports': { + 23: 1.0, # Telnet + 21: 0.7, # FTP + 3389: 0.8, # RDP + 48101: 0.7, # Backdoor + 7547: 0.7 # TR-069 + } +} + +RISK_CONFIG_PATH = 'instance/risk_config.yaml' + + +class RiskScorer: + """Calculate device risk index (0.0 low to 1.0 critical).""" + + def __init__(self): + """Initialize with configuration.""" + self.config = self._load_config() + self.weights = self.config['weights'] + self.banned_manufacturers = set(self.config['banned_manufacturers']) + self.suspicious_keywords = set(self.config['suspicious_keywords']) + self.high_risk_countries = self.config['high_risk_countries'] + self.critical_ports = self.config['critical_ports'] + + def _load_config(self) -> Dict[str, Any]: + """Load configuration from file or use defaults.""" + try: + if os.path.exists(RISK_CONFIG_PATH): + with open(RISK_CONFIG_PATH, 'r') as f: + config = yaml.safe_load(f) + logger.info(f"Loaded risk config from {RISK_CONFIG_PATH}") + return config + except Exception as e: + logger.warning(f"Error loading risk config: {e}, using defaults") + + # Save default config for future editing + try: + os.makedirs('instance', exist_ok=True) + with open(RISK_CONFIG_PATH, 'w') as f: + yaml.dump(DEFAULT_CONFIG, f, default_flow_style=False) + logger.info(f"Saved default risk config to {RISK_CONFIG_PATH}") + except Exception as e: + logger.warning(f"Could not save default config: {e}") + + return DEFAULT_CONFIG + + def calculate_risk(self, device: Dict[str, Any]) -> Dict[str, Any]: + """Calculate risk index (0.0-1.0) for a device.""" + hardware_score = self._score_hardware(device) + exposure_score = self._score_exposure(device) + external_score = self._score_external(device) + traffic_score = self._score_traffic(device) + + # Weighted total + total_risk = ( + hardware_score * self.weights['hardware'] + + exposure_score * self.weights['exposure'] + + external_score * self.weights['external'] + + traffic_score * self.weights['traffic'] + ) + + # Manual overrides + if device.get('tracking_device'): + total_risk = max(total_risk, 0.7) # Min 0.7 for tracking devices + if device.get('surveillance_device'): + total_risk = max(total_risk, 0.8) # Min 0.8 for surveillance devices + + # Clamp to 0.0-1.0 + total_risk = max(0.0, min(1.0, total_risk)) + + return { + 'hardware': round(hardware_score, 3), + 'exposure': round(exposure_score, 3), + 'external': round(external_score, 3), + 'traffic': round(traffic_score, 3), + 'total': round(total_risk, 3), + 'badge': self._get_badge(total_risk) + } + + def _score_hardware(self, device: Dict[str, Any]) -> float: + """Score based on manufacturer and device class.""" + score = 0.0 + mfr = device.get('mfr', '').lower() + + # Check banned manufacturers + for banned in self.banned_manufacturers: + if banned.lower() in mfr: + return 1.0 # Maximum risk + + # Check suspicious keywords + for keyword in self.suspicious_keywords: + if keyword.lower() in mfr: + score = max(score, 0.6) + + # Unknown/Private OUI + if 'unknown' in mfr or 'private' in mfr: + score = max(score, 0.7) + + # IoT/Embedded class bonus + if 'IoT' in device.get('class', '') or 'Embedded' in device.get('class', ''): + score = max(score, 0.4) + + return min(score, 1.0) + + def _score_exposure(self, device: Dict[str, Any]) -> float: + """Score based on open ports and services.""" + exposed = device.get('exposed_services', []) + score = 0.0 + + if not exposed: + return score + + for service in exposed: + try: + # Parse port from "port/protocol" format + if '/' in str(service): + port = int(str(service).split('/')[0]) + else: + port = int(service) + + # Check against critical ports + if port in self.critical_ports: + score = max(score, self.critical_ports[port]) + except (ValueError, IndexError): + pass + + # Bonus for too many ports + if len(exposed) > 5: + score = max(score, 0.5) + + return min(score, 1.0) + + def _score_external(self, device: Dict[str, Any]) -> float: + """Score based on external communication and ASN.""" + country = device.get('destination_country', '').upper() + uplink = device.get('primary_uplink', '').lower() + + score = 0.0 + + # Check high-risk countries + if country in self.high_risk_countries: + score = self.high_risk_countries[country] + + # Check suspicious ASN/org + for keyword in self.suspicious_keywords: + if keyword.lower() in uplink: + score = max(score, 0.7) + + return min(score, 1.0) + + def _score_traffic(self, device: Dict[str, Any]) -> float: + """Score based on traffic patterns and encryption.""" + bytes_total = device.get('bytes_total', 0) + protocol = device.get('protocol_detected', '').lower() + + score = 0.0 + + # High external bandwidth anomaly + if bytes_total > 10000000: # >10MB + score = max(score, 0.6) + + # Suspicious unencrypted protocols + if 'mqtt' in protocol and 'tls' not in protocol: + score = max(score, 0.7) + + if 'http' in protocol and 'https' not in protocol: + score = max(score, 0.5) + + return min(score, 1.0) + + def _get_badge(self, risk_index: float) -> str: + """Get badge color based on risk level.""" + if risk_index < 0.40: + return 'green' + elif risk_index < 0.70: + return 'amber' + else: + return 'red' + + def get_badge_color_hex(self, badge: str) -> str: + """Get hex color for badge.""" + colors = { + 'green': '#28a745', + 'amber': '#ffc107', + 'red': '#dc3545' + } + return colors.get(badge, '#6c757d') diff --git a/utils/lan_spy/scanner.py b/utils/lan_spy/scanner.py new file mode 100644 index 00000000..637cad84 --- /dev/null +++ b/utils/lan_spy/scanner.py @@ -0,0 +1,316 @@ +""" +Network Scanner for LAN SPY +Parallel network discovery with auto-detection and timeout enforcement. +""" + +import logging +import subprocess +import socket +import os +import requests +from typing import Dict, List, Any, Optional +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed +import time +import ipaddress +from datetime import datetime + +logger = logging.getLogger('intercept.lan_spy.scanner') + +OUI_PATH = 'instance/oui.txt' +OUI_URL = 'http://standards-oui.ieee.org/oui/oui.txt' + +# Banned manufacturers per NDAA §889, FCC Covered List (2026) +BANNED_MANUFACTURERS = { + 'Hikvision', 'Hangzhou Hikvision Digital Technology', + 'Dahua', 'Zhejiang Dahua Technology', + 'Huawei', 'Huawei Technologies', + 'ZTE', 'Hytera', 'Hytera Communications', + 'DJI', 'Da-Jiang Innovations', + 'Autel Robotics', 'EZVIZ' +} + +SUSPICIOUS_KEYWORDS = {'Tuya', 'Xiaomi', 'Anker'} + +CRITICAL_PORTS = { + 23: 1.0, # Telnet + 21: 0.7, # FTP + 3389: 0.8, # RDP + 48101: 0.7, # Backdoor + 7547: 0.7 # TR-069 +} + + +def get_local_network() -> str: + """Auto-detect local network CIDR from host IP (e.g., 192.168.2.0/24).""" + try: + hostname = socket.gethostname() + local_ip = socket.gethostbyname(hostname) + logger.info(f"Detected local IP: {local_ip}") + + # Extract network prefix and assume /24 (common for home/office) + parts = local_ip.split('.') + if len(parts) == 4: + network = f"{parts[0]}.{parts[1]}.{parts[2]}.0/24" + logger.info(f"Auto-detected network: {network}") + return network + except Exception as e: + logger.warning(f"Failed to auto-detect network: {e}") + + # Fallback to common default + return '192.168.1.0/24' + + +def get_oui_database() -> Dict[str, str]: + """Load OUI (Organizationally Unique Identifier) database.""" + oui_dict = {} + + try: + if not os.path.exists(OUI_PATH): + logger.warning(f"OUI database not found at {OUI_PATH}") + return oui_dict + + with open(OUI_PATH, 'r', encoding='utf-8', errors='ignore') as f: + for line in f: + line = line.strip() + if not line or line.startswith('#'): + continue + + parts = line.split('\t') + if len(parts) >= 2: + mac_prefix = parts[0] + manufacturer = parts[1] + oui_dict[mac_prefix] = manufacturer + + logger.info(f"Loaded {len(oui_dict)} OUI entries") + except Exception as e: + logger.error(f"Error loading OUI database: {e}") + + return oui_dict + + +def update_oui_database() -> Dict[str, Any]: + """Download and update OUI database from IEEE.""" + try: + logger.info(f"Downloading OUI database from {OUI_URL}") + + response = requests.get(OUI_URL, timeout=30) + response.raise_for_status() + + os.makedirs('instance', exist_ok=True) + + with open(OUI_PATH, 'w', encoding='utf-8') as f: + f.write(response.text) + + count = len(response.text.split('\n')) + logger.info(f"OUI database updated with {count} entries") + + return { + 'status': 'success', + 'message': f'OUI database updated with {count} entries', + 'entries_count': count + } + except Exception as e: + logger.error(f"Error updating OUI database: {e}") + return { + 'status': 'error', + 'message': f'Failed to update OUI database: {str(e)}' + } + + +def get_manufacturer(mac: str) -> str: + """Get manufacturer from MAC address using OUI lookup.""" + oui_dict = get_oui_database() + + # Normalize MAC + mac_upper = mac.upper().replace(':', '').replace('-', '') + + # Try different prefix lengths (6, 5, 4, 3, 2 characters) + for prefix_len in [6, 5, 4, 3, 2]: + prefix = mac_upper[:prefix_len] + if prefix in oui_dict: + return oui_dict[prefix] + + return 'Unknown/Private' + + +class NetworkScanner: + """LAN network scanner with parallel pinging and auto-detection.""" + + def __init__(self, network: str = None): + """Initialize scanner with optional network specification.""" + # Auto-detect network if not provided + if not network or network == '192.168.1.0/24': + network = get_local_network() + + self.network = network + self.stop_flag = False + self.oui_db = get_oui_database() + self.max_workers = 64 # Parallel ping threads + self.scan_timeout = 300 # 5 minute timeout + + def stop(self): + """Stop the scan gracefully.""" + self.stop_flag = True + + def scan(self) -> List[Dict[str, Any]]: + """Scan network with parallel pinging and 5-minute timeout.""" + devices = [] + start_time = time.time() + + try: + logger.info(f"Starting network scan on {self.network} (timeout: {self.scan_timeout}s)") + + # Parse CIDR notation + try: + network_obj = ipaddress.ip_network(self.network, strict=False) + ips = list(network_obj.hosts()) + except Exception as e: + logger.error(f"Invalid network format: {e}") + return devices + + logger.info(f"Scanning {len(ips)} addresses in {self.network} (parallel, {self.max_workers} workers)") + + # Parallel ping phase + alive_ips = self._parallel_ping(ips, start_time) + + # Check timeout + if time.time() - start_time > self.scan_timeout: + logger.warning(f"Scan timeout exceeded ({self.scan_timeout}s)") + return devices + + logger.info(f"Found {len(alive_ips)} alive hosts, gathering device details...") + + # Gather details for alive hosts + for ip_str in alive_ips: + if self.stop_flag or (time.time() - start_time > self.scan_timeout): + break + + try: + device = { + 'signal_id': f"SIG-{len(devices):04d}-LAN", + 'internal_ip': ip_str, + 'mac': self._get_mac(ip_str), + 'hostname': self._resolve_hostname(ip_str), + 'mfr': self._get_manufacturer(ip_str), + 'class': self._classify_device(ip_str), + 'label': ip_str, + 'bandwidth_utilization': 'Low', + 'bytes_total': 0, + 'current_flow_kbps': 0.0, + 'primary_uplink': 'Unknown', + 'destination_country': 'Unknown', + 'protocol_detected': 'Unknown', + 'risk_index': 0.0, + 'exposed_services': [], + 'tracking_device': False, + 'surveillance_device': False, + 'last_seen': self._get_timestamp() + } + + devices.append(device) + logger.debug(f"Found device: {ip_str} ({device['mfr']})") + + except Exception as e: + logger.error(f"Error processing host {ip_str}: {e}") + + except Exception as e: + logger.error(f"Scan error: {e}") + + elapsed = time.time() - start_time + logger.info(f"Scan complete: found {len(devices)} devices in {elapsed:.1f}s") + return devices + + def _parallel_ping(self, ips: List[Any], start_time) -> List[str]: + """Parallel ping all IPs with timeout enforcement.""" + alive_ips = [] + + with ThreadPoolExecutor(max_workers=self.max_workers) as executor: + # Submit all ping tasks + future_to_ip = { + executor.submit(self._ping_host, str(ip)): str(ip) + for ip in ips + } + + # Collect results with frequent timeout checks (5s intervals) + for future in as_completed(future_to_ip, timeout=5): + elapsed = time.time() - start_time + if self.stop_flag or elapsed > self.scan_timeout: + logger.info(f"Scan timeout/stop at {elapsed:.1f}s") + executor.shutdown(wait=False) + break + + ip = future_to_ip[future] + try: + if future.result(timeout=1): + alive_ips.append(ip) + except Exception as e: + logger.debug(f"Error pinging {ip}: {e}") + + return alive_ips + + def _ping_host(self, ip: str) -> bool: + """Ping host with 300ms timeout (very fast).""" + try: + param = '-n' if os.name == 'nt' else '-c' + timeout_param = '-w' if os.name == 'nt' else '-W' + + if os.name == 'nt': + cmd = ['ping', param, '1', timeout_param, '300', ip] + else: + cmd = ['ping', param, '1', timeout_param, '300', '-n', ip] + + result = subprocess.run( + cmd, + capture_output=True, + timeout=1 # 1 second overall timeout + ) + + return result.returncode == 0 + except (subprocess.TimeoutExpired, Exception): + return False + + def _get_mac(self, ip: str) -> str: + """Get MAC address via ARP with 1s timeout.""" + try: + result = subprocess.run( + ['arp', '-n', ip], + capture_output=True, + text=True, + timeout=1 + ) + + if result.returncode == 0: + for line in result.stdout.split('\n'): + parts = line.split() + for part in parts: + if ':' in part and len(part) == 17: + return part + except (subprocess.TimeoutExpired, Exception): + pass + + return '00:00:00:00:00:00' + + def _get_manufacturer(self, ip: str) -> str: + """Get manufacturer from MAC address.""" + mac = self._get_mac(ip) + return get_manufacturer(mac) + + def _classify_device(self, ip: str) -> str: + """Classify device type.""" + return 'IoT / Embedded' + + def _resolve_hostname(self, ip: str) -> str: + """Resolve hostname from IP with 1s timeout.""" + try: + socket.setdefaulttimeout(1) + hostname = socket.gethostbyaddr(ip)[0] + socket.setdefaulttimeout(None) + return hostname + except (socket.herror, OSError, socket.timeout): + socket.setdefaulttimeout(None) + return ip + + def _get_timestamp(self) -> str: + """Get ISO format timestamp.""" + return datetime.now().isoformat() + 'Z' From e25f13fa7236af7e57cfb33af1195770c548a8a5 Mon Sep 17 00:00:00 2001 From: Jon Ander Oribe Date: Sat, 7 Feb 2026 17:19:55 +0100 Subject: [PATCH 02/25] Documentation & installation method updated --- README.md | 4 ++++ pyproject.toml | 1 + setup.sh | 4 ++++ 3 files changed, 9 insertions(+) diff --git a/README.md b/README.md index e3ad0d6a..793325c7 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,10 @@ cd intercept sudo -E venv/bin/python intercept.py ``` +> Note for Linux users: The LAN SPY network discovery requires `nmap` to be +> installed. On Debian/Ubuntu install with `sudo apt install nmap` before +> running `./setup.sh` or the LAN SPY scan will not work correctly. + ### Docker (Alternative) ```bash diff --git a/pyproject.toml b/pyproject.toml index 1040a650..3b2f90ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "flask-sock", "websocket-client>=1.6.0", "requests>=2.28.0", + "pyyaml>=6.0" ] [project.urls] diff --git a/setup.sh b/setup.sh index 7cdf6f12..3aec3b66 100755 --- a/setup.sh +++ b/setup.sh @@ -224,6 +224,10 @@ check_tools() { check_required "hcxdumptool" "PMKID capture" hcxdumptool check_required "hcxpcapngtool" "PMKID/pcapng conversion" hcxpcapngtool + echo + info "Network:" + check_required "nmap" "Network scanner (required for LAN SPY)" nmap + echo info "Bluetooth:" check_required "bluetoothctl" "Bluetooth controller CLI" bluetoothctl From 575d0e5c599d019fdb957b796d25bba323524fd9 Mon Sep 17 00:00:00 2001 From: Jon Ander Oribe Date: Sat, 7 Feb 2026 17:28:22 +0100 Subject: [PATCH 03/25] navigation update --- static/js/lan_spy.js | 2 +- templates/partials/nav.html | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/static/js/lan_spy.js b/static/js/lan_spy.js index 4d48d67f..0bfbbb25 100644 --- a/static/js/lan_spy.js +++ b/static/js/lan_spy.js @@ -377,7 +377,7 @@ function displayLanSpyDeviceDetails(mac) {
Exposed Services - ${device.exposed_services.length > 0 ? device.exposed_services.join(', ') : 'None'} + ${Array.isArray(device.exposed_services) && device.exposed_services.length > 0 ? device.exposed_services.join(', ') : (device.exposed_services || 'None')}
diff --git a/templates/partials/nav.html b/templates/partials/nav.html index 42c942d1..3e4915ac 100644 --- a/templates/partials/nav.html +++ b/templates/partials/nav.html @@ -100,6 +100,7 @@
{{ mode_item('tscm', 'TSCM', '') }} + {{ mode_item('lan_spy', 'LAN SPY', '') }}
From 1ee64a692219e82b1e147bee74b835d2b0fdafb4 Mon Sep 17 00:00:00 2001 From: Jon Ander Oribe Date: Sat, 7 Feb 2026 17:55:22 +0100 Subject: [PATCH 04/25] Update lan_spy.js --- static/js/lan_spy.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/static/js/lan_spy.js b/static/js/lan_spy.js index 0bfbbb25..4ef83d6c 100644 --- a/static/js/lan_spy.js +++ b/static/js/lan_spy.js @@ -239,8 +239,9 @@ function renderLanSpyDeviceList() { // Deduplicate devices by MAC address (keep last occurrence) const uniqueDevices = {}; lanSpyDevices.forEach(device => { - uniqueDevices[device.mac] = device; - }); + const compositeKey = `${device.mac}-${device.internal_ip}`; + uniqueDevices[compositeKey] = device; +}); const deviceList = Object.values(uniqueDevices); @@ -253,7 +254,7 @@ function renderLanSpyDeviceList() { const riskLevel = (device.risk_index || 0).toFixed(2); return ` -
+
${device.hostname || device.internal_ip} @@ -291,7 +292,7 @@ function selectLanSpyDevice(mac) { * Display selected device details */ function displayLanSpyDeviceDetails(mac) { - const device = lanSpyDevices.find(d => d.mac === mac); + const device = lanSpyDevices.find(d => `${d.mac}-${d.internal_ip}` === mac); if (!device) { console.error('Device not found:', mac); @@ -423,7 +424,7 @@ function toggleLanSpyFlag(mac, flagType, value) { console.log('Flag updated:', data); // Update local device - const device = lanSpyDevices.find(d => d.mac === mac); + const device = lanSpyDevices.find(d => `${d.mac}-${d.internal_ip}` === mac); if (device) { if (flagType === 'tracking') { device.tracking_device = value; From 47ccc19c175360577faa559598fe9c385cb7f436 Mon Sep 17 00:00:00 2001 From: Jon Ander Oribe Date: Sat, 7 Feb 2026 19:41:32 +0100 Subject: [PATCH 05/25] Styles update --- static/css/modes/lan_spy_dashboard.css | 9 +- templates/index.html | 990 +++++++++++++++++-------- templates/partials/modes/lan_spy.html | 20 - 3 files changed, 688 insertions(+), 331 deletions(-) diff --git a/static/css/modes/lan_spy_dashboard.css b/static/css/modes/lan_spy_dashboard.css index b291429c..c379df1b 100644 --- a/static/css/modes/lan_spy_dashboard.css +++ b/static/css/modes/lan_spy_dashboard.css @@ -15,9 +15,7 @@ /* Sidebar */ .lan-spy-sidebar { - width: 30%; - min-width: 30%; - max-width: 30%; + width: 100%; flex-shrink: 0; border-right: 2px solid #00ff41; overflow-y: auto; @@ -259,9 +257,8 @@ /* Main Content Area */ .lan-spy-main { - width: 70%; - min-width: 70%; - max-width: 70%; + width: 100%; + height: 100%; flex-shrink: 0; display: flex; flex-direction: column; diff --git a/templates/index.html b/templates/index.html index 454b3561..f2953a11 100644 --- a/templates/index.html +++ b/templates/index.html @@ -27,7 +27,8 @@ {% if offline_settings.fonts_source == 'local' %} {% else %} - + {% endif %} {% if offline_settings.assets_source == 'local' %} @@ -72,24 +73,27 @@
- + - + - - - - + + + + - + - - + + - - - + + +
@@ -149,50 +153,106 @@

Select Mode

-

SDR / RF

+

+ + + SDR / RF

- + + + Aircraft - + + + + + Vessels
@@ -200,14 +260,28 @@

-

Wireless

+

+ + + + Wireless

@@ -215,14 +289,27 @@

-

Security

+

+ + Security

@@ -230,18 +317,46 @@

-

Space

+

+ + + + + Space

@@ -260,7 +375,12 @@

@@ -741,7 +930,8 @@
Security Overview

@@ -813,7 +1003,8 @@
Device Details
- +

@@ -824,8 +1015,10 @@
Device Details
Tracker Detection
-
-
Monitoring for AirTags, Tiles...
+
+
+ Monitoring for AirTags, Tiles...
@@ -833,12 +1026,14 @@
Signal Distribution
Strong (-50+)
-
+
+
0
Medium (-70)
-
+
+
0
Weak (-90) @@ -852,24 +1047,35 @@
Signal Distribution
Proximity Radar
-
-
- - - - +
+
+ + + +
-
+
- 0 + 0
Immediate
- 0 + 0
Near
- 0 + 0
Far
@@ -949,7 +1155,8 @@

Device Details

GAIN @@ -1009,8 +1216,10 @@

Device Details

style="color: var(--accent-green); text-shadow: 0 0 10px var(--accent-green); margin-bottom: 8px;"> STATION LIST
-
-
+
+
No stations received yet
@@ -1106,7 +1315,8 @@

Device Details

dB
- SNR THRESH + SNR + THRESH Device Details
Tune
-
⌨ arrow keys
@@ -1297,10 +1506,21 @@

Device Details

-
@@ -1402,13 +1622,17 @@

Device Details

- +
+
+
+
+

Select a device to view details

+

MAC • Manufacturer • Risk Score

+
+
+ + +
+ + +
+
+
+
-
@@ -1450,24 +1696,46 @@

Device Details

@@ -1603,7 +1871,8 @@

Device Details

No anomalies detected
-
Start a sweep to scan for signals of interest
+
Start a sweep to scan for signals of interest +
@@ -1611,9 +1880,11 @@

Device Details

-
+
Device Timelines - +
Run a sweep to see device timelines
@@ -1622,26 +1893,32 @@

Device Details

-