From f407a3cb5433af906227010a2616b85108b5d244 Mon Sep 17 00:00:00 2001 From: Smittix Date: Wed, 14 Jan 2026 13:52:28 +0000 Subject: [PATCH] Add TSCM device classification system Classification levels: - Green (Informational): Known devices in baseline, expected infrastructure - Yellow (Needs Review): Unknown BLE devices, new WiFi APs, unidentified RF - Red (High Interest): Persistent transmitters, audio-capable BLE, trackers, devices with repeat detections across scans Features: - Device history tracking for repeat detection (24-hour window) - Audio-capable BLE detection (headphones, mics, speakers) - Classification reasons shown under each device - Color-coded indicators with visual styling - Microphone badge for audio-capable BLE devices --- routes/tscm.py | 19 +++- templates/index.html | 71 ++++++++++-- utils/tscm/detector.py | 240 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 321 insertions(+), 9 deletions(-) diff --git a/routes/tscm.py b/routes/tscm.py index bca41ff..80cf421 100644 --- a/routes/tscm.py +++ b/routes/tscm.py @@ -915,6 +915,8 @@ def _run_sweep( sev = threat.get('severity', 'low').lower() if sev in severity_counts: severity_counts[sev] += 1 + # Classify device + classification = detector.classify_wifi_device(network) # Send device to frontend _emit_event('wifi_device', { 'bssid': bssid, @@ -923,7 +925,9 @@ def _run_sweep( 'signal': network.get('power', ''), 'security': network.get('privacy', ''), 'is_threat': is_threat, - 'is_new': True + 'is_new': not classification.get('in_baseline', False), + 'classification': classification.get('classification', 'review'), + 'reasons': classification.get('reasons', []), }) last_wifi_scan = current_time except Exception as e: @@ -947,6 +951,8 @@ def _run_sweep( sev = threat.get('severity', 'low').lower() if sev in severity_counts: severity_counts[sev] += 1 + # Classify device + classification = detector.classify_bt_device(device) # Send device to frontend _emit_event('bt_device', { 'mac': mac, @@ -954,7 +960,10 @@ def _run_sweep( 'type': device.get('type', ''), 'rssi': device.get('rssi', ''), 'is_threat': is_threat, - 'is_new': True + 'is_new': not classification.get('in_baseline', False), + 'classification': classification.get('classification', 'review'), + 'reasons': classification.get('reasons', []), + 'is_audio_capable': classification.get('is_audio_capable', False), }) last_bt_scan = current_time except Exception as e: @@ -985,6 +994,8 @@ def _run_sweep( sev = threat.get('severity', 'low').lower() if sev in severity_counts: severity_counts[sev] += 1 + # Classify signal + classification = detector.classify_rf_signal(signal) # Send signal to frontend _emit_event('rf_signal', { 'frequency': signal['frequency'], @@ -992,7 +1003,9 @@ def _run_sweep( 'band': signal['band'], 'signal_strength': signal.get('signal_strength', 0), 'is_threat': is_threat, - 'is_new': True + 'is_new': not classification.get('in_baseline', False), + 'classification': classification.get('classification', 'review'), + 'reasons': classification.get('reasons', []), }) last_rf_scan = current_time except Exception as e: diff --git a/templates/index.html b/templates/index.html index 186d1fb..6eef5d8 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2036,6 +2036,33 @@ .tscm-device-item.baseline { border-left-color: #00ff88; } + /* Classification colors */ + .tscm-device-item.classification-green { + border-left-color: #00cc00; + background: rgba(0, 204, 0, 0.1); + } + .tscm-device-item.classification-yellow { + border-left-color: #ffcc00; + background: rgba(255, 204, 0, 0.1); + } + .tscm-device-item.classification-red { + border-left-color: #ff3333; + background: rgba(255, 51, 51, 0.15); + animation: pulse-glow 2s infinite; + } + .classification-indicator { + margin-right: 6px; + } + .tscm-device-reasons { + font-size: 9px; + color: var(--text-muted); + margin-top: 4px; + font-style: italic; + } + .audio-badge { + margin-left: 6px; + font-size: 10px; + } .tscm-device-name { font-weight: 600; font-size: 12px; @@ -9981,6 +10008,25 @@ document.getElementById('tscmThreatCount').textContent = tscmThreats.length; } + function getClassificationClass(classification) { + // Map classification to CSS class + switch (classification) { + case 'high_interest': return 'classification-red'; + case 'review': return 'classification-yellow'; + case 'informational': return 'classification-green'; + default: return 'classification-yellow'; + } + } + + function getClassificationIcon(classification) { + switch (classification) { + case 'high_interest': return '🔴'; + case 'review': return '🟡'; + case 'informational': return '🟢'; + default: return '🟡'; + } + } + function updateTscmDisplays() { // Update WiFi list const wifiList = document.getElementById('tscmWifiList'); @@ -9988,12 +10034,16 @@ wifiList.innerHTML = '
No WiFi networks detected
'; } else { wifiList.innerHTML = tscmWifiDevices.map(d => ` -
-
${escapeHtml(d.ssid || d.bssid || 'Hidden')}
+
+
+ ${getClassificationIcon(d.classification)} + ${escapeHtml(d.ssid || d.bssid || 'Hidden')} +
${d.bssid} ${d.signal || '--'} dBm
+ ${d.reasons && d.reasons.length > 0 ? `
${d.reasons.join(' • ')}
` : ''}
`).join(''); } @@ -10005,12 +10055,17 @@ btList.innerHTML = '
No Bluetooth devices detected
'; } else { btList.innerHTML = tscmBtDevices.map(d => ` -
-
${escapeHtml(d.name || 'Unknown')}
+
+
+ ${getClassificationIcon(d.classification)} + ${escapeHtml(d.name || 'Unknown')} + ${d.is_audio_capable ? '🎤' : ''} +
${d.mac} ${d.rssi || '--'} dBm
+ ${d.reasons && d.reasons.length > 0 ? `
${d.reasons.join(' • ')}
` : ''}
`).join(''); } @@ -10022,12 +10077,16 @@ rfList.innerHTML = '
No RF signals detected
'; } else { rfList.innerHTML = tscmRfSignals.map(s => ` -
-
${s.frequency.toFixed(3)} MHz
+
+
+ ${getClassificationIcon(s.classification)} + ${s.frequency.toFixed(3)} MHz +
${s.band} ${s.power.toFixed(1)} dBm
+ ${s.reasons && s.reasons.length > 0 ? `
${s.reasons.join(' • ')}
` : ''}
`).join(''); } diff --git a/utils/tscm/detector.py b/utils/tscm/detector.py index 8bdda50..b71d741 100644 --- a/utils/tscm/detector.py +++ b/utils/tscm/detector.py @@ -23,6 +23,68 @@ from data.tscm_frequencies import ( logger = logging.getLogger('intercept.tscm.detector') +# Classification levels for TSCM devices +CLASSIFICATION_LEVELS = { + 'informational': { + 'color': '#00cc00', # Green + 'label': 'Informational', + 'description': 'Known device, expected infrastructure, or background noise', + }, + 'review': { + 'color': '#ffcc00', # Yellow + 'label': 'Needs Review', + 'description': 'Unknown device requiring investigation', + }, + 'high_interest': { + 'color': '#ff3333', # Red + 'label': 'High Interest', + 'description': 'Suspicious device requiring immediate attention', + }, +} + +# BLE device types that can transmit audio (potential bugs) +AUDIO_CAPABLE_BLE_NAMES = [ + 'headphone', 'headset', 'earphone', 'earbud', 'speaker', + 'audio', 'mic', 'microphone', 'airpod', 'buds', + 'jabra', 'bose', 'sony wf', 'sony wh', 'beats', + 'jbl', 'soundcore', 'anker', 'skullcandy', +] + +# Device history for tracking repeat detections across scans +_device_history: dict[str, list[datetime]] = {} +_history_window_hours = 24 # Consider detections within 24 hours + + +def _record_device_seen(identifier: str) -> int: + """Record a device sighting and return count of times seen.""" + now = datetime.now() + if identifier not in _device_history: + _device_history[identifier] = [] + + # Clean old entries + cutoff = now.timestamp() - (_history_window_hours * 3600) + _device_history[identifier] = [ + dt for dt in _device_history[identifier] + if dt.timestamp() > cutoff + ] + + _device_history[identifier].append(now) + return len(_device_history[identifier]) + + +def _is_audio_capable_ble(name: str | None, device_type: str | None = None) -> bool: + """Check if a BLE device might be audio-capable.""" + if name: + name_lower = name.lower() + for pattern in AUDIO_CAPABLE_BLE_NAMES: + if pattern in name_lower: + return True + if device_type: + type_lower = device_type.lower() + if any(t in type_lower for t in ['audio', 'headset', 'headphone', 'speaker']): + return True + return False + class ThreatDetector: """ @@ -72,6 +134,184 @@ class ThreatDetector: f"{len(self.baseline_bt_macs)} BT, {len(self.baseline_rf_freqs)} RF" ) + def classify_wifi_device(self, device: dict) -> dict: + """ + Classify a WiFi device into informational/review/high_interest. + + Returns: + Dict with 'classification', 'reasons', and metadata + """ + mac = device.get('bssid', device.get('mac', '')).upper() + ssid = device.get('essid', device.get('ssid', '')) + signal = device.get('power', device.get('signal', -100)) + + reasons = [] + classification = 'informational' + + # Track repeat detections + times_seen = _record_device_seen(f'wifi:{mac}') if mac else 1 + + # Check if in baseline (known device) + in_baseline = mac in self.baseline_wifi_macs if self.baseline else False + + if in_baseline: + reasons.append('Known device in baseline') + classification = 'informational' + else: + # New/unknown device + reasons.append('New WiFi access point') + classification = 'review' + + # Check for suspicious patterns -> high interest + if is_potential_camera(ssid=ssid, mac=mac): + reasons.append('Matches camera device patterns') + classification = 'high_interest' + + if not ssid and signal and int(signal) > -60: + reasons.append('Hidden SSID with strong signal') + classification = 'high_interest' + + # Repeat detections across scans + if times_seen >= 3: + reasons.append(f'Repeat detection ({times_seen} times)') + if classification != 'high_interest': + classification = 'high_interest' + + return { + 'classification': classification, + 'reasons': reasons, + 'in_baseline': in_baseline, + 'times_seen': times_seen, + } + + def classify_bt_device(self, device: dict) -> dict: + """ + Classify a Bluetooth device into informational/review/high_interest. + + Returns: + Dict with 'classification', 'reasons', and metadata + """ + mac = device.get('mac', device.get('address', '')).upper() + name = device.get('name', '') + rssi = device.get('rssi', device.get('signal', -100)) + device_type = device.get('type', '') + manufacturer_data = device.get('manufacturer_data') + + reasons = [] + classification = 'informational' + tracker_info = None + + # Track repeat detections + times_seen = _record_device_seen(f'bt:{mac}') if mac else 1 + + # Check if in baseline (known device) + in_baseline = mac in self.baseline_bt_macs if self.baseline else False + + # Check for trackers (do this early for all devices) + tracker_info = is_known_tracker(name, manufacturer_data) + + if in_baseline: + reasons.append('Known device in baseline') + classification = 'informational' + else: + # New/unknown BLE device + if not name or name == 'Unknown': + reasons.append('Unknown BLE device') + classification = 'review' + else: + reasons.append('New Bluetooth device') + classification = 'review' + + # Check for trackers -> high interest + if tracker_info: + reasons.append(f"Known tracker: {tracker_info.get('name', 'Unknown')}") + classification = 'high_interest' + + # Check for audio-capable devices -> high interest + if _is_audio_capable_ble(name, device_type): + reasons.append('Audio-capable BLE device') + classification = 'high_interest' + + # Strong signal from unknown device + if rssi and int(rssi) > -50 and not name: + reasons.append('Strong signal from unnamed device') + classification = 'high_interest' + + # Repeat detections across scans + if times_seen >= 3: + reasons.append(f'Repeat detection ({times_seen} times)') + if classification != 'high_interest': + classification = 'high_interest' + + return { + 'classification': classification, + 'reasons': reasons, + 'in_baseline': in_baseline, + 'times_seen': times_seen, + 'is_tracker': tracker_info is not None, + 'is_audio_capable': _is_audio_capable_ble(name, device_type), + } + + def classify_rf_signal(self, signal: dict) -> dict: + """ + Classify an RF signal into informational/review/high_interest. + + Returns: + Dict with 'classification', 'reasons', and metadata + """ + frequency = signal.get('frequency', 0) + power = signal.get('power', signal.get('level', -100)) + band = signal.get('band', '') + + reasons = [] + classification = 'informational' + freq_rounded = round(frequency, 1) + + # Track repeat detections + times_seen = _record_device_seen(f'rf:{freq_rounded}') + + # Check if in baseline (known frequency) + in_baseline = freq_rounded in self.baseline_rf_freqs if self.baseline else False + + # Get frequency risk info + risk, band_name = get_frequency_risk(frequency) + + if in_baseline: + reasons.append('Known frequency in baseline') + classification = 'informational' + else: + # New/unidentified RF carrier + reasons.append(f'Unidentified RF carrier in {band_name}') + + if risk == 'low': + reasons.append('Background RF noise band') + classification = 'review' + elif risk == 'medium': + reasons.append('ISM band signal') + classification = 'review' + elif risk in ['high', 'critical']: + reasons.append(f'High-risk surveillance band: {band_name}') + classification = 'high_interest' + + # Strong persistent signal + if power and float(power) > -40: + reasons.append('Strong persistent transmitter') + classification = 'high_interest' + + # Repeat detections (persistent transmitter) + if times_seen >= 2: + reasons.append(f'Persistent transmitter ({times_seen} detections)') + classification = 'high_interest' + + return { + 'classification': classification, + 'reasons': reasons, + 'in_baseline': in_baseline, + 'times_seen': times_seen, + 'risk_level': risk, + 'band_name': band_name, + } + def analyze_wifi_device(self, device: dict) -> dict | None: """ Analyze a WiFi device for threats.