Improve TSCM detection and include WiFi clients

This commit is contained in:
Smittix
2026-02-07 17:31:17 +00:00
parent 32b373bf2c
commit 4bbc00b765
10 changed files with 854 additions and 305 deletions
+82 -35
View File
@@ -838,14 +838,15 @@ class ModeManager:
data['data'] = list(getattr(self, 'ais_vessels', {}).values())
elif mode == 'aprs':
data['data'] = list(getattr(self, 'aprs_stations', {}).values())
elif mode == 'tscm':
data['data'] = {
'anomalies': getattr(self, 'tscm_anomalies', []),
'baseline': getattr(self, 'tscm_baseline', {}),
'wifi_devices': list(self.wifi_networks.values()),
'bt_devices': list(self.bluetooth_devices.values()),
'rf_signals': getattr(self, 'tscm_rf_signals', []),
}
elif mode == 'tscm':
data['data'] = {
'anomalies': getattr(self, 'tscm_anomalies', []),
'baseline': getattr(self, 'tscm_baseline', {}),
'wifi_devices': list(self.wifi_networks.values()),
'wifi_clients': list(getattr(self, 'tscm_wifi_clients', {}).values()),
'bt_devices': list(self.bluetooth_devices.values()),
'rf_signals': getattr(self, 'tscm_rf_signals', []),
}
elif mode == 'listening_post':
data['data'] = {
'activity': getattr(self, 'listening_post_activity', []),
@@ -1104,23 +1105,24 @@ class ModeManager:
self.wifi_clients.clear()
elif mode == 'bluetooth':
self.bluetooth_devices.clear()
elif mode == 'tscm':
# Clean up TSCM sub-threads
for sub_thread_name in ['tscm_wifi', 'tscm_bt', 'tscm_rf']:
if sub_thread_name in self.output_threads:
thread = self.output_threads[sub_thread_name]
if thread and thread.is_alive():
thread.join(timeout=2)
del self.output_threads[sub_thread_name]
# Clear TSCM data
self.tscm_anomalies = []
self.tscm_baseline = {}
self.tscm_rf_signals = []
# Clear reported threat tracking sets
if hasattr(self, '_tscm_reported_wifi'):
self._tscm_reported_wifi.clear()
if hasattr(self, '_tscm_reported_bt'):
self._tscm_reported_bt.clear()
elif mode == 'tscm':
# Clean up TSCM sub-threads
for sub_thread_name in ['tscm_wifi', 'tscm_bt', 'tscm_rf']:
if sub_thread_name in self.output_threads:
thread = self.output_threads[sub_thread_name]
if thread and thread.is_alive():
thread.join(timeout=2)
del self.output_threads[sub_thread_name]
# Clear TSCM data
self.tscm_anomalies = []
self.tscm_baseline = {}
self.tscm_rf_signals = []
self.tscm_wifi_clients = {}
# Clear reported threat tracking sets
if hasattr(self, '_tscm_reported_wifi'):
self._tscm_reported_wifi.clear()
if hasattr(self, '_tscm_reported_bt'):
self._tscm_reported_bt.clear()
elif mode == 'dsc':
# Clear DSC data
if hasattr(self, 'dsc_messages'):
@@ -3111,9 +3113,12 @@ class ModeManager:
self.tscm_baseline = {}
if not hasattr(self, 'tscm_anomalies'):
self.tscm_anomalies = []
if not hasattr(self, 'tscm_rf_signals'):
self.tscm_rf_signals = []
self.tscm_anomalies.clear()
if not hasattr(self, 'tscm_rf_signals'):
self.tscm_rf_signals = []
if not hasattr(self, 'tscm_wifi_clients'):
self.tscm_wifi_clients = {}
self.tscm_anomalies.clear()
self.tscm_wifi_clients.clear()
# Get params for what to scan
scan_wifi = params.get('wifi', True)
@@ -3168,7 +3173,7 @@ class ModeManager:
stop_event = self.stop_events.get(mode)
# Import existing Intercept TSCM functions
from routes.tscm import _scan_wifi_networks, _scan_bluetooth_devices, _scan_rf_signals
from routes.tscm import _scan_wifi_networks, _scan_wifi_clients, _scan_bluetooth_devices, _scan_rf_signals
logger.info("TSCM imports successful")
sweep_ranges = None
@@ -3202,8 +3207,9 @@ class ModeManager:
self._tscm_correlation = None
# Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts)
seen_wifi = {}
seen_bt = {}
seen_wifi = {}
seen_wifi_clients = {}
seen_bt = {}
last_rf_scan = 0
rf_scan_interval = 30
@@ -3261,10 +3267,51 @@ class ModeManager:
for i in profile.indicators
]
enriched['recommended_action'] = profile.recommended_action
self.wifi_networks[bssid] = enriched
except Exception as e:
logger.debug(f"WiFi scan error: {e}")
self.wifi_networks[bssid] = enriched
# WiFi clients (monitor mode only)
try:
wifi_clients = _scan_wifi_clients(wifi_interface or '')
for client in wifi_clients:
mac = (client.get('mac') or '').upper()
if not mac or mac in seen_wifi_clients:
continue
seen_wifi_clients[mac] = client
rssi_val = client.get('rssi_current')
if rssi_val is None:
rssi_val = client.get('rssi_median') or client.get('rssi_ema')
client_device = {
'mac': mac,
'vendor': client.get('vendor'),
'name': client.get('vendor') or 'WiFi Client',
'rssi': rssi_val,
'associated_bssid': client.get('associated_bssid'),
'probed_ssids': client.get('probed_ssids', []),
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
'is_client': True,
}
if self._tscm_correlation:
profile = self._tscm_correlation.analyze_wifi_device(client_device)
client_device['classification'] = profile.risk_level.value
client_device['score'] = profile.total_score
client_device['score_modifier'] = profile.score_modifier
client_device['known_device'] = profile.known_device
client_device['known_device_name'] = profile.known_device_name
client_device['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
client_device['recommended_action'] = profile.recommended_action
self.tscm_wifi_clients[mac] = client_device
except Exception as e:
logger.debug(f"WiFi client scan error: {e}")
except Exception as e:
logger.debug(f"WiFi scan error: {e}")
# Bluetooth scan using Intercept's function (same as local mode)
if scan_bt:
+95 -37
View File
@@ -944,23 +944,34 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
devices = scanner.get_devices()
logger.info(f"TSCM snapshot: get_devices() returned {len(devices)} devices")
# Convert to TSCM format with tracker detection data
tscm_devices = []
for device in devices:
device_data = {
'mac': device.address,
'address_type': device.address_type,
'device_key': device.device_key,
'name': device.name or 'Unknown',
'rssi': device.rssi_current or -100,
'rssi_median': device.rssi_median,
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
'type': _classify_device_type(device),
'manufacturer': device.manufacturer_name,
'manufacturer_id': device.manufacturer_id,
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
'protocol': device.protocol,
'first_seen': device.first_seen.isoformat(),
# Convert to TSCM format with tracker detection data
tscm_devices = []
for device in devices:
manufacturer_name = device.manufacturer_name
if (not manufacturer_name) or str(manufacturer_name).lower().startswith('unknown'):
if device.address and not device.is_randomized_mac:
try:
from data.oui import get_manufacturer
oui_vendor = get_manufacturer(device.address)
if oui_vendor and oui_vendor != 'Unknown':
manufacturer_name = oui_vendor
except Exception:
pass
device_data = {
'mac': device.address,
'address_type': device.address_type,
'device_key': device.device_key,
'name': device.name or 'Unknown',
'rssi': device.rssi_current or -100,
'rssi_median': device.rssi_median,
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
'type': _classify_device_type(device),
'manufacturer': manufacturer_name,
'manufacturer_id': device.manufacturer_id,
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
'protocol': device.protocol,
'first_seen': device.first_seen.isoformat(),
'last_seen': device.last_seen.isoformat(),
'seen_count': device.seen_count,
'range_band': device.range_band,
@@ -1174,14 +1185,38 @@ def get_device_timeseries(device_key: str):
return jsonify(result)
def _classify_device_type(device: BTDeviceAggregate) -> str:
"""Classify device type from available data."""
name_lower = (device.name or '').lower()
manufacturer_lower = (device.manufacturer_name or '').lower()
# Check by name patterns
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
return 'audio'
def _classify_device_type(device: BTDeviceAggregate) -> str:
"""Classify device type from available data."""
name_lower = (device.name or '').lower()
manufacturer_lower = (device.manufacturer_name or '').lower()
service_uuids = device.service_uuids or []
if (not manufacturer_lower) or manufacturer_lower.startswith('unknown'):
if device.address and not device.is_randomized_mac:
try:
from data.oui import get_manufacturer
oui_vendor = get_manufacturer(device.address)
if oui_vendor and oui_vendor != 'Unknown':
manufacturer_lower = oui_vendor.lower()
except Exception:
pass
def normalize_uuid(uuid: str) -> str:
if not uuid:
return ''
value = str(uuid).lower().strip()
if value.startswith('0x'):
value = value[2:]
# Bluetooth Base UUID normalization (16-bit UUIDs)
if value.endswith('-0000-1000-8000-00805f9b34fb') and len(value) >= 8:
return value[4:8]
if len(value) == 4:
return value
return value
# Check by name patterns
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
return 'audio'
if any(x in name_lower for x in ['watch', 'band', 'fitbit', 'garmin']):
return 'wearable'
if any(x in name_lower for x in ['iphone', 'pixel', 'galaxy', 'phone']):
@@ -1190,18 +1225,41 @@ def _classify_device_type(device: BTDeviceAggregate) -> str:
return 'computer'
if any(x in name_lower for x in ['mouse', 'keyboard', 'trackpad']):
return 'peripheral'
if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']):
return 'tracker'
if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']):
return 'speaker'
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
return 'media'
# Check by manufacturer
if 'apple' in manufacturer_lower:
return 'apple_device'
if 'samsung' in manufacturer_lower:
return 'samsung_device'
if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']):
return 'tracker'
if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']):
return 'speaker'
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
return 'media'
# Tracker signals (metadata or Find My service)
if getattr(device, 'is_tracker', False) or getattr(device, 'tracker_type', None):
return 'tracker'
normalized_uuids = {normalize_uuid(u) for u in service_uuids if u}
if 'fd6f' in normalized_uuids:
return 'tracker'
# Service UUIDs (GATT / classic)
audio_uuids = {'110b', '110a', '111e', '111f', '1108', '1203'}
wearable_uuids = {'180d', '1814', '1816'}
hid_uuids = {'1812'}
beacon_uuids = {'feaa', 'feab', 'feb1', 'febe'}
if normalized_uuids & audio_uuids:
return 'audio'
if normalized_uuids & hid_uuids:
return 'peripheral'
if normalized_uuids & wearable_uuids:
return 'wearable'
if normalized_uuids & beacon_uuids:
return 'beacon'
# Check by manufacturer
if 'apple' in manufacturer_lower:
return 'apple_device'
if 'samsung' in manufacturer_lower:
return 'samsung_device'
# Check by class of device
if device.major_class:
+128
View File
@@ -1072,6 +1072,32 @@ def _scan_wifi_networks(interface: str) -> list[dict]:
return []
def _scan_wifi_clients(interface: str) -> list[dict]:
"""
Get WiFi client observations from the unified WiFi scanner.
Clients are only available when monitor-mode scanning is active.
"""
try:
from utils.wifi import get_wifi_scanner
scanner = get_wifi_scanner()
if interface:
try:
if not scanner._is_monitor_mode_interface(interface):
return []
except Exception:
return []
return [client.to_dict() for client in scanner.clients]
except ImportError as e:
logger.error(f"Failed to import wifi scanner: {e}")
return []
except Exception as e:
logger.exception(f"WiFi client scan failed: {e}")
return []
def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]:
"""
Scan for Bluetooth devices with manufacturer data detection.
@@ -1606,6 +1632,7 @@ def _run_sweep(
threats_found = 0
severity_counts = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0}
all_wifi = {} # Use dict for deduplication by BSSID
all_wifi_clients = {} # Use dict for deduplication by client MAC
all_bt = {} # Use dict for deduplication by MAC
all_rf = []
@@ -1702,6 +1729,7 @@ def _run_sweep(
'channel': network.get('channel', ''),
'signal': network.get('power', ''),
'security': network.get('privacy', ''),
'vendor': network.get('vendor'),
'is_threat': is_threat,
'is_new': not classification.get('in_baseline', False),
'classification': profile.risk_level.value,
@@ -1715,6 +1743,77 @@ def _run_sweep(
})
except Exception as e:
logger.error(f"WiFi device processing error for {network.get('bssid', '?')}: {e}")
# WiFi clients (monitor mode only)
try:
wifi_clients = _scan_wifi_clients(wifi_interface)
for client in wifi_clients:
mac = (client.get('mac') or '').upper()
if not mac or mac in all_wifi_clients:
continue
all_wifi_clients[mac] = client
rssi_val = client.get('rssi_current')
if rssi_val is None:
rssi_val = client.get('rssi_median') or client.get('rssi_ema')
client_device = {
'mac': mac,
'vendor': client.get('vendor'),
'name': client.get('vendor') or 'WiFi Client',
'rssi': rssi_val,
'associated_bssid': client.get('associated_bssid'),
'probed_ssids': client.get('probed_ssids', []),
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
'is_client': True,
}
try:
timeline_manager.add_observation(
identifier=mac,
protocol='wifi',
rssi=rssi_val,
name=client_device.get('vendor') or f'WiFi Client {mac[-5:]}',
attributes={'client': True, 'associated_bssid': client_device.get('associated_bssid')}
)
except Exception as e:
logger.debug(f"WiFi client timeline observation error: {e}")
_maybe_store_timeline(
identifier=mac,
protocol='wifi',
rssi=rssi_val,
attributes={'client': True, 'associated_bssid': client_device.get('associated_bssid')}
)
profile = correlation.analyze_wifi_device(client_device)
client_device['classification'] = profile.risk_level.value
client_device['score'] = profile.total_score
client_device['score_modifier'] = profile.score_modifier
client_device['known_device'] = profile.known_device
client_device['known_device_name'] = profile.known_device_name
client_device['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
client_device['recommended_action'] = profile.recommended_action
# Feed to identity engine for MAC-randomization resistant clustering
try:
wifi_obs = {
'timestamp': datetime.now().isoformat(),
'src_mac': mac,
'bssid': client_device.get('associated_bssid'),
'rssi': rssi_val,
'frame_type': 'probe_request',
'probed_ssids': client_device.get('probed_ssids', []),
}
ingest_wifi_dict(wifi_obs)
except Exception as e:
logger.debug(f"Identity engine WiFi client ingest error: {e}")
_emit_event('wifi_client', client_device)
except Exception as e:
logger.debug(f"WiFi client scan error: {e}")
except Exception as e:
last_wifi_scan = current_time
logger.error(f"WiFi scan error: {e}")
@@ -1793,6 +1892,9 @@ def _run_sweep(
'name': device.get('name', 'Unknown'),
'device_type': device.get('type', ''),
'rssi': device.get('rssi', ''),
'manufacturer': device.get('manufacturer'),
'tracker': device.get('tracker'),
'tracker_type': device.get('tracker_type'),
'is_threat': is_threat,
'is_new': not classification.get('in_baseline', False),
'classification': profile.risk_level.value,
@@ -1936,6 +2038,7 @@ def _run_sweep(
if verbose_results:
wifi_payload = list(all_wifi.values())
wifi_client_payload = list(all_wifi_clients.values())
bt_payload = list(all_bt.values())
rf_payload = list(all_rf)
else:
@@ -1951,6 +2054,28 @@ def _run_sweep(
}
for d in all_wifi.values()
]
wifi_client_payload = []
for client in all_wifi_clients.values():
mac = client.get('mac') or client.get('address')
if isinstance(mac, str):
mac = mac.upper()
probed_ssids = client.get('probed_ssids') or []
rssi = client.get('rssi')
if rssi is None:
rssi = client.get('rssi_current')
if rssi is None:
rssi = client.get('rssi_median')
if rssi is None:
rssi = client.get('rssi_ema')
wifi_client_payload.append({
'mac': mac,
'vendor': client.get('vendor'),
'rssi': rssi,
'associated_bssid': client.get('associated_bssid'),
'is_associated': client.get('is_associated'),
'probed_ssids': probed_ssids,
'probe_count': client.get('probe_count', len(probed_ssids)),
})
bt_payload = [
{
'mac': d.get('mac') or d.get('address'),
@@ -1975,9 +2100,11 @@ def _run_sweep(
status='completed',
results={
'wifi_devices': wifi_payload,
'wifi_clients': wifi_client_payload,
'bt_devices': bt_payload,
'rf_signals': rf_payload,
'wifi_count': len(all_wifi),
'wifi_client_count': len(all_wifi_clients),
'bt_count': len(all_bt),
'rf_count': len(all_rf),
'severity_counts': severity_counts,
@@ -2022,6 +2149,7 @@ def _run_sweep(
'sweep_id': _current_sweep_id,
'threats_found': threats_found,
'wifi_count': len(all_wifi),
'wifi_client_count': len(all_wifi_clients),
'bt_count': len(all_bt),
'rf_count': len(all_rf),
'severity_counts': severity_counts,
+22
View File
@@ -196,6 +196,28 @@
margin-left: 6px;
font-size: 10px;
}
.tracker-badge {
margin-left: 6px;
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
background: rgba(255, 51, 102, 0.2);
color: #ff3366;
border: 1px solid rgba(255, 51, 102, 0.4);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.client-badge {
margin-left: 6px;
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
background: rgba(74, 158, 255, 0.2);
color: #4a9eff;
border: 1px solid rgba(74, 158, 255, 0.4);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.known-badge {
margin-left: 6px;
font-size: 9px;
+128 -10
View File
@@ -1565,6 +1565,17 @@
</div>
</div>
<!-- WiFi Clients Panel -->
<div class="tscm-panel" id="tscmWifiClientPanel">
<div class="tscm-panel-header">
WiFi Clients
<span class="badge" id="tscmWifiClientCount">0</span>
</div>
<div class="tscm-panel-content" id="tscmWifiClientList">
<div class="tscm-empty">Start a sweep to scan for WiFi clients</div>
</div>
</div>
<!-- Bluetooth Panel -->
<div class="tscm-panel" id="tscmBtPanel">
<div class="tscm-panel-header">
@@ -9779,6 +9790,7 @@
let tscmEventSource = null;
let tscmThreats = [];
let tscmWifiDevices = [];
let tscmWifiClients = [];
let tscmBtDevices = [];
let tscmBaselineComparison = null;
let tscmIdentityClusters = [];
@@ -10013,6 +10025,7 @@
// Reset displays
tscmThreats = [];
tscmWifiDevices = [];
tscmWifiClients = [];
tscmBtDevices = [];
tscmRfSignals = [];
tscmRfStatusMessage = null;
@@ -10704,6 +10717,14 @@
}
});
}
if (data.wifi_clients) {
data.wifi_clients.forEach(client => {
const clientMac = client.mac || client.address;
if (!tscmWifiClients.find(d => (d.mac || d.address) === clientMac)) {
handleTscmEvent({ type: 'wifi_client', ...client });
}
});
}
// Process Bluetooth devices
if (data.bt_devices) {
@@ -10759,6 +10780,9 @@
case 'wifi_device':
addTscmWifiDevice(data);
break;
case 'wifi_client':
addTscmWifiClient(data);
break;
case 'bt_device':
addTscmBtDevice(data);
break;
@@ -10868,6 +10892,22 @@
}
}
function addTscmWifiClient(client) {
const mac = client.mac || client.address || '';
if (!mac) return;
const exists = tscmWifiClients.some(d => (d.mac || d.address) === mac);
if (!exists) {
if (!client.mac) client.mac = mac;
client.is_client = true;
tscmWifiClients.push(client);
updateTscmDisplays();
updateTscmThreatCounts();
if (client.score >= 3) {
addHighInterestDevice(client, 'wifi');
}
}
}
function addTscmBtDevice(device) {
const mac = device.mac || device.address || '';
// Check if already exists
@@ -11067,10 +11107,12 @@
const showRf = protocol === 'all' || protocol === 'rf';
const wifiPanel = document.getElementById('tscmWifiPanel');
const wifiClientPanel = document.getElementById('tscmWifiClientPanel');
const btPanel = document.getElementById('tscmBtPanel');
const rfPanel = document.getElementById('tscmRfPanel');
if (wifiPanel) wifiPanel.style.display = showWifi ? '' : 'none';
if (wifiClientPanel) wifiClientPanel.style.display = showWifi ? '' : 'none';
if (btPanel) btPanel.style.display = showBt ? '' : 'none';
if (rfPanel) rfPanel.style.display = showRf ? '' : 'none';
}
@@ -11093,13 +11135,15 @@
function getFilteredDevices(options = {}) {
const wifi = tscmWifiDevices.filter(d => matchesTscmFilters(d, 'wifi', options));
const wifi_clients = tscmWifiClients.filter(d => matchesTscmFilters(d, 'wifi', options));
const bt = tscmBtDevices.filter(d => matchesTscmFilters(d, 'bluetooth', options));
const rf = tscmRfSignals.filter(d => matchesTscmFilters(d, 'rf', options));
return {
wifi,
wifi_clients,
bt,
rf,
all: [...wifi, ...bt, ...rf],
all: [...wifi, ...wifi_clients, ...bt, ...rf],
};
}
@@ -11165,6 +11209,18 @@
return indicators.map(i => `<span class="indicator-tag">${escapeHtml(i.desc || i.type)}</span>`).join(' ');
}
function getTrackerLabel(device) {
if (!device) return null;
return (device.tracker && (device.tracker.name || device.tracker.type)) ||
device.tracker_type || device.tracker_name || null;
}
function formatTrackerBadge(device) {
const label = getTrackerLabel(device);
if (!label) return '';
return `<span class="tracker-badge" title="Tracker">${escapeHtml(label)}</span>`;
}
function getScoreBadge(score) {
if (score === undefined || score === null) return '';
let scoreClass = 'score-low';
@@ -11177,6 +11233,7 @@
function getAllTscmDevices() {
const devices = {};
tscmWifiDevices.forEach(d => { devices[`wifi:${d.bssid}`] = { ...d, protocol: 'wifi' }; });
tscmWifiClients.forEach(d => { devices[`wifi:${d.mac}`] = { ...d, protocol: 'wifi' }; });
tscmBtDevices.forEach(d => { devices[`bluetooth:${d.mac}`] = { ...d, protocol: 'bluetooth' }; });
tscmRfSignals.forEach(d => { devices[`rf:${d.frequency}`] = { ...d, protocol: 'rf' }; });
return devices;
@@ -11220,18 +11277,32 @@
// Add device-specific fields
if (protocol === 'wifi') {
html += `
<tr><td>BSSID</td><td>${device.bssid || 'Unknown'}</td></tr>
<tr><td>SSID</td><td>${escapeHtml(device.ssid || '[Hidden]')}</td></tr>
<tr><td>Channel</td><td>${device.channel || 'Unknown'}</td></tr>
<tr><td>Signal</td><td>${device.signal || '--'} dBm</td></tr>
<tr><td>Security</td><td>${device.security || 'Unknown'}</td></tr>
`;
if (device.is_client) {
html += `
<tr><td>Client MAC</td><td>${device.mac || 'Unknown'}</td></tr>
<tr><td>Vendor</td><td>${escapeHtml(device.vendor || 'Unknown')}</td></tr>
<tr><td>RSSI</td><td>${device.rssi || '--'} dBm</td></tr>
<tr><td>Associated BSSID</td><td>${device.associated_bssid || 'Unassociated'}</td></tr>
<tr><td>Probed SSIDs</td><td>${device.probe_count || (device.probed_ssids ? device.probed_ssids.length : 0)}</td></tr>
`;
} else {
html += `
<tr><td>BSSID</td><td>${device.bssid || 'Unknown'}</td></tr>
<tr><td>SSID</td><td>${escapeHtml(device.ssid || '[Hidden]')}</td></tr>
<tr><td>Vendor</td><td>${escapeHtml(device.vendor || 'Unknown')}</td></tr>
<tr><td>Channel</td><td>${device.channel || 'Unknown'}</td></tr>
<tr><td>Signal</td><td>${device.signal || '--'} dBm</td></tr>
<tr><td>Security</td><td>${device.security || 'Unknown'}</td></tr>
`;
}
} else if (protocol === 'bluetooth') {
const trackerLabel = getTrackerLabel(device);
html += `
<tr><td>MAC Address</td><td>${device.mac || 'Unknown'}</td></tr>
<tr><td>Name</td><td>${escapeHtml(device.name || 'Unknown')}</td></tr>
<tr><td>Type</td><td>${device.device_type || 'Unknown'}</td></tr>
<tr><td>Manufacturer</td><td>${escapeHtml(device.manufacturer || 'Unknown')}</td></tr>
<tr><td>Tracker</td><td>${trackerLabel ? escapeHtml(trackerLabel) : 'No'}</td></tr>
<tr><td>RSSI</td><td>${device.rssi || '--'} dBm</td></tr>
<tr><td>Audio Capable</td><td>${device.is_audio_capable ? 'Yes' : 'No'}</td></tr>
`;
@@ -11564,6 +11635,8 @@
<tr><td>Name</td><td>${escapeHtml(profile.name || 'N/A')}</td></tr>
<tr><td>Manufacturer</td><td>${escapeHtml(profile.manufacturer || 'N/A')}</td></tr>
<tr><td>Device Type</td><td>${escapeHtml(profile.device_type || 'N/A')}</td></tr>
${(profile.tracker_name || profile.tracker_type) ? `<tr><td>Tracker</td><td>${escapeHtml(profile.tracker_name || profile.tracker_type)}</td></tr>` : ''}
${profile.tracker_confidence ? `<tr><td>Tracker Confidence</td><td>${escapeHtml(profile.tracker_confidence)}</td></tr>` : ''}
<tr><td>First Seen</td><td>${profile.first_seen ? new Date(profile.first_seen).toLocaleString() : 'N/A'}</td></tr>
<tr><td>Last Seen</td><td>${profile.last_seen ? new Date(profile.last_seen).toLocaleString() : 'N/A'}</td></tr>
<tr><td>Detections</td><td>${profile.detection_count || 0}</td></tr>
@@ -11897,6 +11970,14 @@
const section = document.getElementById('tscmWifiAdvancedSection');
if (!section) return;
if (device && device.is_client) {
section.innerHTML = `
<h4>WiFi Advanced Indicators</h4>
<div class="tscm-empty">Client devices do not have AP indicators.</div>
`;
return;
}
try {
const payload = {
bssid: device.bssid,
@@ -12341,7 +12422,7 @@
<div class="tscm-device-meta">
<span>${d.bssid}</span>
<span>${d.signal || '--'} dBm</span>
<span>${d.security || 'Open'}</span>
<span>${escapeHtml(d.vendor || 'Unknown')} • ${escapeHtml(d.security || 'Open')}</span>
</div>
${d.indicators && d.indicators.length > 0 ? `<div class="tscm-device-indicators">${formatIndicators(d.indicators)}</div>` : ''}
${d.recommended_action && d.recommended_action !== 'monitor' ? `<div class="tscm-action">Action: ${d.recommended_action}</div>` : ''}
@@ -12350,6 +12431,35 @@
}
document.getElementById('tscmWifiCount').textContent = filtered.wifi.length;
// Update WiFi clients list
const wifiClientList = document.getElementById('tscmWifiClientList');
if (filtered.wifi_clients.length === 0) {
wifiClientList.innerHTML = `<div class="tscm-empty">${filtersActive ? 'No WiFi clients match filters' : 'No WiFi clients detected'}</div>`;
} else {
const sortedClients = [...filtered.wifi_clients].sort((a, b) => (b.score || 0) - (a.score || 0));
wifiClientList.innerHTML = sortedClients.map(c => `
<div class="tscm-device-item ${getClassificationClass(c.classification)}" onclick="showDeviceDetails('${c.mac}', 'wifi')">
<div class="tscm-device-header">
<div class="tscm-device-name">
<span class="classification-indicator">${getClassificationIcon(c.classification)}</span>
${escapeHtml(c.vendor || 'WiFi Client')}
<span class="client-badge" title="WiFi client">CLIENT</span>
${c.known_device ? '<span class="known-badge" title="Known device">KNOWN</span>' : ''}
</div>
${getScoreBadge(c.score)}
</div>
<div class="tscm-device-meta">
<span>${c.mac}</span>
<span>${c.rssi || '--'} dBm</span>
<span>${c.associated_bssid ? `Assoc: ${c.associated_bssid}` : `Probes: ${c.probe_count || 0}`}</span>
</div>
${c.indicators && c.indicators.length > 0 ? `<div class="tscm-device-indicators">${formatIndicators(c.indicators)}</div>` : ''}
${c.recommended_action && c.recommended_action !== 'monitor' ? `<div class="tscm-action">Action: ${c.recommended_action}</div>` : ''}
</div>
`).join('');
}
document.getElementById('tscmWifiClientCount').textContent = filtered.wifi_clients.length;
// Update BT list
const btList = document.getElementById('tscmBtList');
if (filtered.bt.length === 0) {
@@ -12364,6 +12474,7 @@
<span class="classification-indicator">${getClassificationIcon(d.classification)}</span>
${escapeHtml(d.name || 'Unknown')}
${d.is_audio_capable ? '<span class="audio-badge" title="Audio-capable device">AUDIO</span>' : ''}
${formatTrackerBadge(d)}
${d.known_device ? '<span class="known-badge" title="Known device">KNOWN</span>' : ''}
</div>
${getScoreBadge(d.score)}
@@ -12371,7 +12482,7 @@
<div class="tscm-device-meta">
<span>${d.mac}</span>
<span>${d.rssi || '--'} dBm</span>
<span>${d.device_type || 'Unknown'}</span>
<span>${escapeHtml([d.device_type, d.manufacturer].filter(Boolean).join(' • ') || 'Unknown')}</span>
</div>
${d.indicators && d.indicators.length > 0 ? `<div class="tscm-device-indicators">${formatIndicators(d.indicators)}</div>` : ''}
${d.recommended_action && d.recommended_action !== 'monitor' ? `<div class="tscm-action">Action: ${d.recommended_action}</div>` : ''}
@@ -12535,6 +12646,10 @@
const identityClusters = data.identity_clusters || (tscmIdentitySummary ? tscmIdentitySummary.total : 0);
const baselineNew = data.baseline_new_devices || 0;
const baselineMissing = data.baseline_missing_devices || 0;
const wifiCount = data.wifi_count ?? tscmWifiDevices.length;
const wifiClientCount = data.wifi_client_count ?? tscmWifiClients.length;
const btCount = data.bt_count ?? tscmBtDevices.length;
const rfCount = data.rf_count ?? tscmRfSignals.length;
let assessment = 'BASELINE ENVIRONMENT';
let assessmentClass = 'informational';
@@ -12574,6 +12689,9 @@
</div>
` : ''}
</div>
<div class="tscm-summary-meta" style="margin-top: 8px; font-size: 10px; color: var(--text-muted);">
Devices: ${wifiCount} WiFi AP • ${wifiClientCount} WiFi Clients • ${btCount} BT • ${rfCount} RF
</div>
<div class="tscm-assessment ${assessmentClass}">
<strong>Assessment:</strong> ${assessment}
</div>
+283 -145
View File
@@ -118,10 +118,15 @@ class DeviceProfile:
identifier: str # MAC, BSSID, or frequency
protocol: str # 'bluetooth', 'wifi', 'rf'
# Device info
name: Optional[str] = None
manufacturer: Optional[str] = None
device_type: Optional[str] = None
# Device info
name: Optional[str] = None
manufacturer: Optional[str] = None
device_type: Optional[str] = None
tracker_type: Optional[str] = None
tracker_name: Optional[str] = None
tracker_confidence: Optional[str] = None
tracker_confidence_score: Optional[float] = None
tracker_evidence: list[str] = field(default_factory=list)
# Bluetooth-specific
services: list[str] = field(default_factory=list)
@@ -231,14 +236,19 @@ class DeviceProfile:
indicator_count = len(self.indicators)
self.confidence = min(1.0, (indicator_count * 0.15) + (self.total_score * 0.05))
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
'identifier': self.identifier,
'protocol': self.protocol,
'name': self.name,
'manufacturer': self.manufacturer,
'device_type': self.device_type,
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
'identifier': self.identifier,
'protocol': self.protocol,
'name': self.name,
'manufacturer': self.manufacturer,
'device_type': self.device_type,
'tracker_type': self.tracker_type,
'tracker_name': self.tracker_name,
'tracker_confidence': self.tracker_confidence,
'tracker_confidence_score': self.tracker_confidence_score,
'tracker_evidence': self.tracker_evidence,
'ssid': self.ssid,
'frequency': self.frequency,
'first_seen': self.first_seen.isoformat() if self.first_seen else None,
@@ -266,14 +276,33 @@ class DeviceProfile:
# Known audio-capable BLE service UUIDs
AUDIO_SERVICE_UUIDS = [
'0000110b-0000-1000-8000-00805f9b34fb', # A2DP Sink
'0000110a-0000-1000-8000-00805f9b34fb', # A2DP Source
'0000111e-0000-1000-8000-00805f9b34fb', # Handsfree
'0000111f-0000-1000-8000-00805f9b34fb', # Handsfree Audio Gateway
'00001108-0000-1000-8000-00805f9b34fb', # Headset
'00001203-0000-1000-8000-00805f9b34fb', # Generic Audio
]
AUDIO_SERVICE_UUIDS = [
'0000110b-0000-1000-8000-00805f9b34fb', # A2DP Sink
'0000110a-0000-1000-8000-00805f9b34fb', # A2DP Source
'0000111e-0000-1000-8000-00805f9b34fb', # Handsfree
'0000111f-0000-1000-8000-00805f9b34fb', # Handsfree Audio Gateway
'00001108-0000-1000-8000-00805f9b34fb', # Headset
'00001203-0000-1000-8000-00805f9b34fb', # Generic Audio
]
_BT_BASE_UUID_SUFFIX = '-0000-1000-8000-00805f9b34fb'
def _normalize_bt_uuid(value: str) -> str:
"""Normalize BLE UUIDs to 16-bit where possible."""
if not value:
return ''
uuid = str(value).lower().strip()
if uuid.startswith('0x'):
uuid = uuid[2:]
if uuid.endswith(_BT_BASE_UUID_SUFFIX) and len(uuid) >= 8:
return uuid[4:8]
if len(uuid) == 4:
return uuid
return uuid
AUDIO_SERVICE_UUIDS_16 = {_normalize_bt_uuid(u) for u in AUDIO_SERVICE_UUIDS}
# Generic chipset vendors (often used in covert devices)
GENERIC_CHIPSET_VENDORS = [
@@ -415,10 +444,24 @@ class CorrelationEngine:
# Update profile data
profile.name = device.get('name') or profile.name
profile.manufacturer = device.get('manufacturer') or profile.manufacturer
profile.device_type = device.get('type') or profile.device_type
profile.services = device.get('services', []) or profile.services
profile.company_id = device.get('company_id') or profile.company_id
profile.advertising_interval = device.get('advertising_interval') or profile.advertising_interval
profile.device_type = device.get('type') or profile.device_type
services = device.get('services')
if not services:
services = device.get('service_uuids')
profile.services = services or profile.services
profile.company_id = device.get('company_id') or profile.company_id
profile.advertising_interval = device.get('advertising_interval') or profile.advertising_interval
tracker_data = device.get('tracker') or {}
if tracker_data:
profile.tracker_type = tracker_data.get('type') or profile.tracker_type
profile.tracker_name = tracker_data.get('name') or profile.tracker_name
profile.tracker_confidence = tracker_data.get('confidence') or profile.tracker_confidence
profile.tracker_confidence_score = tracker_data.get('confidence_score') or profile.tracker_confidence_score
evidence = tracker_data.get('evidence')
if isinstance(evidence, list):
profile.tracker_evidence = evidence
elif evidence:
profile.tracker_evidence = [str(evidence)]
# Add RSSI sample
rssi = device.get('rssi', device.get('signal'))
@@ -431,15 +474,28 @@ class CorrelationEngine:
# Clear previous indicators for fresh analysis
profile.indicators = []
# === Detection Logic ===
# 1. Unknown manufacturer or generic chipset
if not profile.manufacturer:
profile.add_indicator(
IndicatorType.UNKNOWN_DEVICE,
'Unknown manufacturer',
{'manufacturer': None}
)
# === Detection Logic ===
# 1. Unknown manufacturer or generic chipset
if not profile.manufacturer and mac and not device.get('is_randomized_mac'):
try:
first_octet = int(mac.split(':')[0], 16)
except (ValueError, IndexError):
first_octet = None
if first_octet is None or not (first_octet & 0x02):
try:
from data.oui import get_manufacturer
vendor = get_manufacturer(mac)
if vendor and vendor != 'Unknown':
profile.manufacturer = vendor
except Exception:
pass
if not profile.manufacturer:
profile.add_indicator(
IndicatorType.UNKNOWN_DEVICE,
'Unknown manufacturer',
{'manufacturer': None}
)
elif any(v in profile.manufacturer.lower() for v in GENERIC_CHIPSET_VENDORS):
profile.add_indicator(
IndicatorType.UNKNOWN_DEVICE,
@@ -455,16 +511,16 @@ class CorrelationEngine:
{'name': profile.name}
)
# 3. Audio-capable services
if profile.services:
audio_services = [s for s in profile.services
if s.lower() in [u.lower() for u in AUDIO_SERVICE_UUIDS]]
if audio_services:
profile.add_indicator(
IndicatorType.AUDIO_CAPABLE,
'Audio-capable BLE services detected',
{'services': audio_services}
)
# 3. Audio-capable services
if profile.services:
normalized_services = {_normalize_bt_uuid(s) for s in profile.services if s}
audio_services = [s for s in normalized_services if s in AUDIO_SERVICE_UUIDS_16]
if audio_services:
profile.add_indicator(
IndicatorType.AUDIO_CAPABLE,
'Audio-capable BLE services detected',
{'services': audio_services}
)
# Check name for audio keywords
if profile.name:
@@ -518,15 +574,47 @@ class CorrelationEngine:
{'mac': mac}
)
# 9. Known tracker detection (AirTag, Tile, SmartTag, ESP32)
mac_prefix = mac[:8] if len(mac) >= 8 else ''
tracker_detected = False
# Check for tracker flags from BLE scanner (manufacturer ID detection)
if device.get('is_airtag'):
profile.add_indicator(
IndicatorType.AIRTAG_DETECTED,
'Apple AirTag detected via manufacturer data',
# 9. Known tracker detection (AirTag, Tile, SmartTag, ESP32)
mac_prefix = mac[:8] if len(mac) >= 8 else ''
tracker_detected = False
tracker_data = device.get('tracker') or {}
if tracker_data.get('is_tracker'):
tracker_detected = True
tracker_label = tracker_data.get('name') or tracker_data.get('type')
if tracker_label:
label_lower = str(tracker_label).lower()
if 'airtag' in label_lower or 'find my' in label_lower:
profile.add_indicator(
IndicatorType.AIRTAG_DETECTED,
f'Tracker detected: {tracker_label}',
{'mac': mac, 'tracker_type': tracker_label}
)
profile.device_type = 'AirTag'
elif 'tile' in label_lower:
profile.add_indicator(
IndicatorType.TILE_DETECTED,
f'Tracker detected: {tracker_label}',
{'mac': mac, 'tracker_type': tracker_label}
)
profile.device_type = 'Tile Tracker'
elif 'smarttag' in label_lower or 'samsung' in label_lower:
profile.add_indicator(
IndicatorType.SMARTTAG_DETECTED,
f'Tracker detected: {tracker_label}',
{'mac': mac, 'tracker_type': tracker_label}
)
profile.device_type = 'Samsung SmartTag'
else:
profile.device_type = tracker_label
elif not profile.device_type:
profile.device_type = 'Tracker'
# Check for tracker flags from BLE scanner (manufacturer ID detection)
if device.get('is_airtag'):
profile.add_indicator(
IndicatorType.AIRTAG_DETECTED,
'Apple AirTag detected via manufacturer data',
{'mac': mac, 'tracker_type': 'AirTag'}
)
profile.device_type = device.get('tracker_type', 'AirTag')
@@ -662,31 +750,41 @@ class CorrelationEngine:
return profile
def analyze_wifi_device(self, device: dict) -> DeviceProfile:
"""
Analyze a Wi-Fi device/AP for suspicious indicators.
def analyze_wifi_device(self, device: dict) -> DeviceProfile:
"""
Analyze a Wi-Fi device/AP for suspicious indicators.
Args:
device: Dict with bssid, ssid, channel, rssi, encryption, etc.
Returns:
DeviceProfile with risk assessment
"""
bssid = device.get('bssid', device.get('mac', '')).upper()
profile = self.get_or_create_profile(bssid, 'wifi')
# Update profile data
ssid = device.get('ssid', device.get('essid', ''))
profile.ssid = ssid if ssid else profile.ssid
profile.name = ssid or f'Hidden Network ({bssid[-8:]})'
profile.channel = device.get('channel') or profile.channel
profile.encryption = device.get('encryption', device.get('privacy')) or profile.encryption
profile.beacon_interval = device.get('beacon_interval') or profile.beacon_interval
profile.is_hidden = not ssid or ssid in ['', 'Hidden', '[Hidden]']
# Extract manufacturer from OUI
if bssid and len(bssid) >= 8:
profile.manufacturer = device.get('vendor') or profile.manufacturer
Returns:
DeviceProfile with risk assessment
"""
bssid = device.get('bssid', device.get('mac', '')).upper()
profile = self.get_or_create_profile(bssid, 'wifi')
is_client = bool(device.get('is_client') or device.get('role') == 'client')
# Update profile data
ssid = device.get('ssid', device.get('essid', ''))
if is_client:
profile.name = device.get('name') or device.get('vendor') or profile.name or f'Client ({bssid[-8:]})'
profile.device_type = 'client'
profile.ssid = profile.ssid # Clients are not SSIDs
profile.channel = device.get('channel') or profile.channel
profile.encryption = profile.encryption
profile.beacon_interval = profile.beacon_interval
profile.is_hidden = False
else:
profile.ssid = ssid if ssid else profile.ssid
profile.name = ssid or f'Hidden Network ({bssid[-8:]})'
profile.channel = device.get('channel') or profile.channel
profile.encryption = device.get('encryption', device.get('privacy')) or profile.encryption
profile.beacon_interval = device.get('beacon_interval') or profile.beacon_interval
profile.is_hidden = not ssid or ssid in ['', 'Hidden', '[Hidden]']
# Extract manufacturer from OUI
if bssid and len(bssid) >= 8:
profile.manufacturer = device.get('vendor') or profile.manufacturer
# Add RSSI sample
rssi = device.get('rssi', device.get('power', device.get('signal')))
@@ -699,78 +797,118 @@ class CorrelationEngine:
# Clear previous indicators
profile.indicators = []
# === Detection Logic ===
# 1. Hidden or unnamed SSID
if profile.is_hidden:
profile.add_indicator(
IndicatorType.HIDDEN_IDENTITY,
'Hidden or empty SSID',
{'ssid': ssid}
)
# 2. BSSID not in authorized list (would need baseline)
# For now, mark as unknown if no manufacturer
if not profile.manufacturer:
profile.add_indicator(
IndicatorType.UNKNOWN_DEVICE,
'Unknown AP manufacturer',
{'bssid': bssid}
)
# 3. Consumer device OUI in restricted environment
consumer_ouis = ['tp-link', 'netgear', 'd-link', 'linksys', 'asus']
if profile.manufacturer and any(c in profile.manufacturer.lower() for c in consumer_ouis):
profile.add_indicator(
IndicatorType.ROGUE_AP,
f'Consumer-grade AP detected: {profile.manufacturer}',
{'manufacturer': profile.manufacturer}
)
# 4. Camera device patterns
camera_keywords = ['cam', 'camera', 'ipcam', 'dvr', 'nvr', 'wyze',
'ring', 'arlo', 'nest', 'blink', 'eufy', 'yi']
if ssid and any(k in ssid.lower() for k in camera_keywords):
profile.add_indicator(
IndicatorType.AUDIO_CAPABLE, # Cameras often have mics
f'Potential camera device: {ssid}',
{'ssid': ssid}
)
# 5. Persistent presence
if profile.detection_count >= 3:
profile.add_indicator(
IndicatorType.PERSISTENT,
f'Persistent AP ({profile.detection_count} detections)',
{'count': profile.detection_count}
)
# 6. Stable RSSI (fixed placement)
rssi_stability = profile.get_rssi_stability()
if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5:
profile.add_indicator(
IndicatorType.STABLE_RSSI,
f'Stable signal (stability: {rssi_stability:.0%})',
{'stability': rssi_stability}
)
# 7. Meeting correlation
if self.is_during_meeting():
profile.add_indicator(
IndicatorType.MEETING_CORRELATED,
'Detected during sensitive period',
{'during_meeting': True}
)
# 8. Strong hidden AP (very suspicious)
if profile.is_hidden and profile.rssi_samples:
latest_rssi = profile.rssi_samples[-1][1]
if latest_rssi > -50:
profile.add_indicator(
IndicatorType.ROGUE_AP,
f'Strong hidden AP (RSSI: {latest_rssi} dBm)',
{'rssi': latest_rssi}
)
# === Detection Logic ===
if is_client:
if not profile.manufacturer:
profile.add_indicator(
IndicatorType.UNKNOWN_DEVICE,
'Unknown client manufacturer',
{'mac': bssid}
)
if profile.detection_count >= 3:
profile.add_indicator(
IndicatorType.PERSISTENT,
f'Persistent client ({profile.detection_count} detections)',
{'count': profile.detection_count}
)
rssi_stability = profile.get_rssi_stability()
if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5:
profile.add_indicator(
IndicatorType.STABLE_RSSI,
f'Stable client signal (stability: {rssi_stability:.0%})',
{'stability': rssi_stability}
)
if self.is_during_meeting():
profile.add_indicator(
IndicatorType.MEETING_CORRELATED,
'Detected during sensitive period',
{'during_meeting': True}
)
try:
first_octet = int(bssid.split(':')[0], 16)
if first_octet & 0x02:
profile.add_indicator(
IndicatorType.MAC_ROTATION,
'Random/locally administered MAC detected',
{'mac': bssid}
)
except (ValueError, IndexError):
pass
else:
# 1. Hidden or unnamed SSID
if profile.is_hidden:
profile.add_indicator(
IndicatorType.HIDDEN_IDENTITY,
'Hidden or empty SSID',
{'ssid': ssid}
)
# 2. BSSID not in authorized list (would need baseline)
# For now, mark as unknown if no manufacturer
if not profile.manufacturer:
profile.add_indicator(
IndicatorType.UNKNOWN_DEVICE,
'Unknown AP manufacturer',
{'bssid': bssid}
)
# 3. Consumer device OUI in restricted environment
consumer_ouis = ['tp-link', 'netgear', 'd-link', 'linksys', 'asus']
if profile.manufacturer and any(c in profile.manufacturer.lower() for c in consumer_ouis):
profile.add_indicator(
IndicatorType.ROGUE_AP,
f'Consumer-grade AP detected: {profile.manufacturer}',
{'manufacturer': profile.manufacturer}
)
# 4. Camera device patterns
camera_keywords = ['cam', 'camera', 'ipcam', 'dvr', 'nvr', 'wyze',
'ring', 'arlo', 'nest', 'blink', 'eufy', 'yi']
if ssid and any(k in ssid.lower() for k in camera_keywords):
profile.add_indicator(
IndicatorType.AUDIO_CAPABLE, # Cameras often have mics
f'Potential camera device: {ssid}',
{'ssid': ssid}
)
# 5. Persistent presence
if profile.detection_count >= 3:
profile.add_indicator(
IndicatorType.PERSISTENT,
f'Persistent AP ({profile.detection_count} detections)',
{'count': profile.detection_count}
)
# 6. Stable RSSI (fixed placement)
rssi_stability = profile.get_rssi_stability()
if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5:
profile.add_indicator(
IndicatorType.STABLE_RSSI,
f'Stable signal (stability: {rssi_stability:.0%})',
{'stability': rssi_stability}
)
# 7. Meeting correlation
if self.is_during_meeting():
profile.add_indicator(
IndicatorType.MEETING_CORRELATED,
'Detected during sensitive period',
{'during_meeting': True}
)
# 8. Strong hidden AP (very suspicious)
if profile.is_hidden and profile.rssi_samples:
latest_rssi = profile.rssi_samples[-1][1]
if latest_rssi > -50:
profile.add_indicator(
IndicatorType.ROGUE_AP,
f'Strong hidden AP (RSSI: {latest_rssi} dBm)',
{'rssi': latest_rssi}
)
self._apply_known_device_modifier(profile, bssid, 'wifi')
+25 -11
View File
@@ -476,11 +476,12 @@ class ThreatDetector:
mac = device.get('mac', device.get('address', '')).upper()
name = device.get('name', '')
rssi = device.get('rssi', device.get('signal', -100))
manufacturer = device.get('manufacturer', '')
device_type = device.get('type', '')
manufacturer_data = device.get('manufacturer_data')
threats = []
manufacturer = device.get('manufacturer', '')
device_type = device.get('type', '')
manufacturer_data = device.get('manufacturer_data')
tracker_data = device.get('tracker', {}) or {}
threats = []
# Check if new device (not in baseline)
if self.baseline and mac and mac not in self.baseline_bt_macs:
@@ -490,12 +491,25 @@ class ThreatDetector:
'reason': 'Device not present in baseline',
})
# Check for known trackers
tracker_info = is_known_tracker(name, manufacturer_data)
if tracker_info:
threats.append({
'type': 'tracker',
'severity': tracker_info.get('risk', 'high'),
# Check for known trackers (v2 tracker data if available)
if tracker_data.get('is_tracker'):
tracker_label = tracker_data.get('name') or tracker_data.get('type') or 'Tracker'
confidence = str(tracker_data.get('confidence') or '').lower()
severity = 'high' if confidence in ('high', 'medium') else 'medium'
threats.append({
'type': 'tracker',
'severity': severity,
'reason': f"Tracker detected: {tracker_label}",
'tracker_type': tracker_label,
'details': tracker_data.get('evidence', []),
})
# Check for known trackers (legacy patterns)
tracker_info = is_known_tracker(name, manufacturer_data)
if tracker_info:
threats.append({
'type': 'tracker',
'severity': tracker_info.get('risk', 'high'),
'reason': f"Known tracker detected: {tracker_info.get('name', 'Unknown')}",
'tracker_type': tracker_info.get('name'),
})
+59 -49
View File
@@ -102,13 +102,14 @@ class TSCMReport:
# Meeting window summaries
meeting_summaries: list[ReportMeetingSummary] = field(default_factory=list)
# Statistics
total_devices_scanned: int = 0
wifi_devices: int = 0
bluetooth_devices: int = 0
rf_signals: int = 0
new_devices: int = 0
missing_devices: int = 0
# Statistics
total_devices_scanned: int = 0
wifi_devices: int = 0
wifi_clients: int = 0
bluetooth_devices: int = 0
rf_signals: int = 0
new_devices: int = 0
missing_devices: int = 0
# Sweep duration
sweep_start: Optional[datetime] = None
@@ -200,12 +201,13 @@ def generate_executive_summary(report: TSCMReport) -> str:
lines.append("")
# Key statistics
lines.append("SCAN STATISTICS:")
lines.append(f" - Total devices scanned: {report.total_devices_scanned}")
lines.append(f" - WiFi access points: {report.wifi_devices}")
lines.append(f" - Bluetooth devices: {report.bluetooth_devices}")
lines.append(f" - RF signals: {report.rf_signals}")
lines.append("")
lines.append("SCAN STATISTICS:")
lines.append(f" - Total devices scanned: {report.total_devices_scanned}")
lines.append(f" - WiFi access points: {report.wifi_devices}")
lines.append(f" - WiFi clients: {report.wifi_clients}")
lines.append(f" - Bluetooth devices: {report.bluetooth_devices}")
lines.append(f" - RF signals: {report.rf_signals}")
lines.append("")
# Findings summary
lines.append("FINDINGS SUMMARY:")
@@ -427,13 +429,14 @@ def generate_technical_annex_json(report: TSCMReport) -> dict:
'capabilities': report.capabilities,
'limitations': report.limitations,
'statistics': {
'total_devices': report.total_devices_scanned,
'wifi_devices': report.wifi_devices,
'bluetooth_devices': report.bluetooth_devices,
'rf_signals': report.rf_signals,
'new_devices': report.new_devices,
'missing_devices': report.missing_devices,
'statistics': {
'total_devices': report.total_devices_scanned,
'wifi_devices': report.wifi_devices,
'wifi_clients': report.wifi_clients,
'bluetooth_devices': report.bluetooth_devices,
'rf_signals': report.rf_signals,
'new_devices': report.new_devices,
'missing_devices': report.missing_devices,
'high_interest_count': len(report.high_interest_findings),
'needs_review_count': len(report.needs_review_findings),
'informational_count': len(report.informational_findings),
@@ -781,21 +784,23 @@ class TSCMReportBuilder:
self.report.meeting_summaries.append(meeting)
return self
def add_statistics(
self,
wifi: int = 0,
bluetooth: int = 0,
rf: int = 0,
new: int = 0,
missing: int = 0
) -> 'TSCMReportBuilder':
self.report.wifi_devices = wifi
self.report.bluetooth_devices = bluetooth
self.report.rf_signals = rf
self.report.total_devices_scanned = wifi + bluetooth + rf
self.report.new_devices = new
self.report.missing_devices = missing
return self
def add_statistics(
self,
wifi: int = 0,
wifi_clients: int = 0,
bluetooth: int = 0,
rf: int = 0,
new: int = 0,
missing: int = 0
) -> 'TSCMReportBuilder':
self.report.wifi_devices = wifi
self.report.wifi_clients = wifi_clients
self.report.bluetooth_devices = bluetooth
self.report.rf_signals = rf
self.report.total_devices_scanned = wifi + wifi_clients + bluetooth + rf
self.report.new_devices = new
self.report.missing_devices = missing
return self
def add_device_timelines(self, timelines: list[dict]) -> 'TSCMReportBuilder':
self.report.device_timelines = timelines
@@ -890,25 +895,30 @@ def generate_report(
builder.add_findings_from_profiles(device_profiles)
# Statistics
results = sweep_data.get('results', {})
wifi_count = results.get('wifi_count')
if wifi_count is None:
wifi_count = len(results.get('wifi_devices', results.get('wifi', [])))
bt_count = results.get('bt_count')
if bt_count is None:
bt_count = len(results.get('bt_devices', results.get('bluetooth', [])))
results = sweep_data.get('results', {})
wifi_count = results.get('wifi_count')
if wifi_count is None:
wifi_count = len(results.get('wifi_devices', results.get('wifi', [])))
wifi_client_count = results.get('wifi_client_count')
if wifi_client_count is None:
wifi_client_count = len(results.get('wifi_clients', []))
bt_count = results.get('bt_count')
if bt_count is None:
bt_count = len(results.get('bt_devices', results.get('bluetooth', [])))
rf_count = results.get('rf_count')
if rf_count is None:
rf_count = len(results.get('rf_signals', results.get('rf', [])))
builder.add_statistics(
wifi=wifi_count,
bluetooth=bt_count,
rf=rf_count,
new=baseline_diff.get('summary', {}).get('new_devices', 0) if baseline_diff else 0,
missing=baseline_diff.get('summary', {}).get('missing_devices', 0) if baseline_diff else 0,
builder.add_statistics(
wifi=wifi_count,
wifi_clients=wifi_client_count,
bluetooth=bt_count,
rf=rf_count,
new=baseline_diff.get('summary', {}).get('new_devices', 0) if baseline_diff else 0,
missing=baseline_diff.get('summary', {}).get('missing_devices', 0) if baseline_diff else 0,
)
# Technical data
+21 -8
View File
@@ -414,14 +414,27 @@ VENDOR_OUIS = {
}
def get_vendor_from_mac(mac: str) -> str | None:
"""Get vendor name from MAC address OUI."""
if not mac:
return None
# Normalize MAC format
mac_upper = mac.upper().replace('-', ':')
oui = mac_upper[:8]
return VENDOR_OUIS.get(oui)
def get_vendor_from_mac(mac: str) -> str | None:
"""Get vendor name from MAC address OUI."""
if not mac:
return None
# Normalize MAC format
mac_upper = mac.upper().replace('-', ':')
oui = mac_upper[:8]
vendor = VENDOR_OUIS.get(oui)
if vendor:
return vendor
# Fallback to expanded OUI database if available
try:
from data.oui import get_manufacturer
manufacturer = get_manufacturer(mac_upper)
if manufacturer and manufacturer != 'Unknown':
return manufacturer
except Exception:
return None
return None
# =============================================================================
+11 -10
View File
@@ -259,16 +259,17 @@ class WiFiAccessPoint:
'in_baseline': self.in_baseline,
}
def to_legacy_dict(self) -> dict:
"""Convert to legacy format for TSCM compatibility."""
return {
'bssid': self.bssid,
'essid': self.essid or '',
'power': str(self.rssi_current) if self.rssi_current else '-100',
'channel': str(self.channel) if self.channel else '',
'privacy': self.security,
'first_seen': self.first_seen.isoformat() if self.first_seen else '',
'last_seen': self.last_seen.isoformat() if self.last_seen else '',
def to_legacy_dict(self) -> dict:
"""Convert to legacy format for TSCM compatibility."""
return {
'bssid': self.bssid,
'essid': self.essid or '',
'vendor': self.vendor,
'power': str(self.rssi_current) if self.rssi_current else '-100',
'channel': str(self.channel) if self.channel else '',
'privacy': self.security,
'first_seen': self.first_seen.isoformat() if self.first_seen else '',
'last_seen': self.last_seen.isoformat() if self.last_seen else '',
'beacon_count': str(self.beacon_count),
'lan_ip': '', # Not tracked in new system
}