mirror of
https://github.com/smittix/intercept.git
synced 2026-07-07 09:08:12 -07:00
style: apply ruff-format to entire codebase
First-time run of ruff-format via pre-commit hook normalises quote style, trailing commas, and whitespace across 188 Python files. No logic changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,4 +8,4 @@ for counter-surveillance operations.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ['detector', 'baseline', 'correlation', 'ble_scanner', 'device_identity']
|
||||
__all__ = ["detector", "baseline", "correlation", "ble_scanner", "device_identity"]
|
||||
|
||||
+716
-735
File diff suppressed because it is too large
Load Diff
+229
-244
@@ -16,7 +16,7 @@ from utils.database import (
|
||||
update_tscm_baseline,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('intercept.tscm.baseline')
|
||||
logger = logging.getLogger("intercept.tscm.baseline")
|
||||
|
||||
|
||||
class BaselineRecorder:
|
||||
@@ -24,20 +24,15 @@ class BaselineRecorder:
|
||||
Records and manages TSCM environment baselines.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.recording = False
|
||||
self.current_baseline_id: int | None = None
|
||||
self.wifi_networks: dict[str, dict] = {} # BSSID -> network info
|
||||
self.wifi_clients: dict[str, dict] = {} # MAC -> client info
|
||||
self.bt_devices: dict[str, dict] = {} # MAC -> device info
|
||||
self.rf_frequencies: dict[float, dict] = {} # Frequency -> signal info
|
||||
def __init__(self):
|
||||
self.recording = False
|
||||
self.current_baseline_id: int | None = None
|
||||
self.wifi_networks: dict[str, dict] = {} # BSSID -> network info
|
||||
self.wifi_clients: dict[str, dict] = {} # MAC -> client info
|
||||
self.bt_devices: dict[str, dict] = {} # MAC -> device info
|
||||
self.rf_frequencies: dict[float, dict] = {} # Frequency -> signal info
|
||||
|
||||
def start_recording(
|
||||
self,
|
||||
name: str,
|
||||
location: str | None = None,
|
||||
description: str | None = None
|
||||
) -> int:
|
||||
def start_recording(self, name: str, location: str | None = None, description: str | None = None) -> int:
|
||||
"""
|
||||
Start recording a new baseline.
|
||||
|
||||
@@ -49,18 +44,14 @@ class BaselineRecorder:
|
||||
Returns:
|
||||
Baseline ID
|
||||
"""
|
||||
self.recording = True
|
||||
self.wifi_networks = {}
|
||||
self.wifi_clients = {}
|
||||
self.bt_devices = {}
|
||||
self.rf_frequencies = {}
|
||||
self.recording = True
|
||||
self.wifi_networks = {}
|
||||
self.wifi_clients = {}
|
||||
self.bt_devices = {}
|
||||
self.rf_frequencies = {}
|
||||
|
||||
# Create baseline in database
|
||||
self.current_baseline_id = create_tscm_baseline(
|
||||
name=name,
|
||||
location=location,
|
||||
description=description
|
||||
)
|
||||
self.current_baseline_id = create_tscm_baseline(name=name, location=location, description=description)
|
||||
|
||||
logger.info(f"Started baseline recording: {name} (ID: {self.current_baseline_id})")
|
||||
return self.current_baseline_id
|
||||
@@ -73,32 +64,32 @@ class BaselineRecorder:
|
||||
Final baseline summary
|
||||
"""
|
||||
if not self.recording or not self.current_baseline_id:
|
||||
return {'error': 'Not recording'}
|
||||
return {"error": "Not recording"}
|
||||
|
||||
self.recording = False
|
||||
|
||||
# Convert to lists for storage
|
||||
wifi_list = list(self.wifi_networks.values())
|
||||
wifi_client_list = list(self.wifi_clients.values())
|
||||
bt_list = list(self.bt_devices.values())
|
||||
rf_list = list(self.rf_frequencies.values())
|
||||
wifi_list = list(self.wifi_networks.values())
|
||||
wifi_client_list = list(self.wifi_clients.values())
|
||||
bt_list = list(self.bt_devices.values())
|
||||
rf_list = list(self.rf_frequencies.values())
|
||||
|
||||
# Update database
|
||||
update_tscm_baseline(
|
||||
self.current_baseline_id,
|
||||
wifi_networks=wifi_list,
|
||||
wifi_clients=wifi_client_list,
|
||||
bt_devices=bt_list,
|
||||
rf_frequencies=rf_list
|
||||
)
|
||||
update_tscm_baseline(
|
||||
self.current_baseline_id,
|
||||
wifi_networks=wifi_list,
|
||||
wifi_clients=wifi_client_list,
|
||||
bt_devices=bt_list,
|
||||
rf_frequencies=rf_list,
|
||||
)
|
||||
|
||||
summary = {
|
||||
'baseline_id': self.current_baseline_id,
|
||||
'wifi_count': len(wifi_list),
|
||||
'wifi_client_count': len(wifi_client_list),
|
||||
'bt_count': len(bt_list),
|
||||
'rf_count': len(rf_list),
|
||||
}
|
||||
summary = {
|
||||
"baseline_id": self.current_baseline_id,
|
||||
"wifi_count": len(wifi_list),
|
||||
"wifi_client_count": len(wifi_client_list),
|
||||
"bt_count": len(bt_list),
|
||||
"rf_count": len(rf_list),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Baseline recording complete: {summary['wifi_count']} WiFi, "
|
||||
@@ -114,87 +105,93 @@ class BaselineRecorder:
|
||||
if not self.recording:
|
||||
return
|
||||
|
||||
mac = device.get('bssid', device.get('mac', '')).upper()
|
||||
mac = device.get("bssid", device.get("mac", "")).upper()
|
||||
if not mac:
|
||||
return
|
||||
|
||||
# Update or add device
|
||||
if mac in self.wifi_networks:
|
||||
# Update with latest info
|
||||
self.wifi_networks[mac].update({
|
||||
'last_seen': datetime.now().isoformat(),
|
||||
'power': device.get('power', self.wifi_networks[mac].get('power')),
|
||||
})
|
||||
self.wifi_networks[mac].update(
|
||||
{
|
||||
"last_seen": datetime.now().isoformat(),
|
||||
"power": device.get("power", self.wifi_networks[mac].get("power")),
|
||||
}
|
||||
)
|
||||
else:
|
||||
self.wifi_networks[mac] = {
|
||||
'bssid': mac,
|
||||
'essid': device.get('essid', device.get('ssid', '')),
|
||||
'channel': device.get('channel'),
|
||||
'power': device.get('power', device.get('signal')),
|
||||
'vendor': device.get('vendor', ''),
|
||||
'encryption': device.get('privacy', device.get('encryption', '')),
|
||||
'first_seen': datetime.now().isoformat(),
|
||||
'last_seen': datetime.now().isoformat(),
|
||||
"bssid": mac,
|
||||
"essid": device.get("essid", device.get("ssid", "")),
|
||||
"channel": device.get("channel"),
|
||||
"power": device.get("power", device.get("signal")),
|
||||
"vendor": device.get("vendor", ""),
|
||||
"encryption": device.get("privacy", device.get("encryption", "")),
|
||||
"first_seen": datetime.now().isoformat(),
|
||||
"last_seen": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
def add_bt_device(self, device: dict) -> None:
|
||||
"""Add a Bluetooth device to the current baseline."""
|
||||
def add_bt_device(self, device: dict) -> None:
|
||||
"""Add a Bluetooth device to the current baseline."""
|
||||
if not self.recording:
|
||||
return
|
||||
|
||||
mac = device.get('mac', device.get('address', '')).upper()
|
||||
mac = device.get("mac", device.get("address", "")).upper()
|
||||
if not mac:
|
||||
return
|
||||
|
||||
if mac in self.bt_devices:
|
||||
self.bt_devices[mac].update({
|
||||
'last_seen': datetime.now().isoformat(),
|
||||
'rssi': device.get('rssi', self.bt_devices[mac].get('rssi')),
|
||||
})
|
||||
self.bt_devices[mac].update(
|
||||
{
|
||||
"last_seen": datetime.now().isoformat(),
|
||||
"rssi": device.get("rssi", self.bt_devices[mac].get("rssi")),
|
||||
}
|
||||
)
|
||||
else:
|
||||
self.bt_devices[mac] = {
|
||||
'mac': mac,
|
||||
'name': device.get('name', ''),
|
||||
'rssi': device.get('rssi', device.get('signal')),
|
||||
'manufacturer': device.get('manufacturer', ''),
|
||||
'type': device.get('type', ''),
|
||||
'first_seen': datetime.now().isoformat(),
|
||||
'last_seen': datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
def add_wifi_client(self, client: dict) -> None:
|
||||
"""Add a WiFi client to the current baseline."""
|
||||
if not self.recording:
|
||||
return
|
||||
|
||||
mac = client.get('mac', client.get('address', '')).upper()
|
||||
if not mac:
|
||||
return
|
||||
|
||||
if mac in self.wifi_clients:
|
||||
self.wifi_clients[mac].update({
|
||||
'last_seen': datetime.now().isoformat(),
|
||||
'rssi': client.get('rssi', self.wifi_clients[mac].get('rssi')),
|
||||
'associated_bssid': client.get('associated_bssid', self.wifi_clients[mac].get('associated_bssid')),
|
||||
})
|
||||
else:
|
||||
self.wifi_clients[mac] = {
|
||||
'mac': mac,
|
||||
'vendor': client.get('vendor', ''),
|
||||
'rssi': client.get('rssi'),
|
||||
'associated_bssid': client.get('associated_bssid'),
|
||||
'probed_ssids': client.get('probed_ssids', []),
|
||||
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
|
||||
'first_seen': datetime.now().isoformat(),
|
||||
'last_seen': datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
def add_rf_signal(self, signal: dict) -> None:
|
||||
"""Add an RF signal to the current baseline."""
|
||||
self.bt_devices[mac] = {
|
||||
"mac": mac,
|
||||
"name": device.get("name", ""),
|
||||
"rssi": device.get("rssi", device.get("signal")),
|
||||
"manufacturer": device.get("manufacturer", ""),
|
||||
"type": device.get("type", ""),
|
||||
"first_seen": datetime.now().isoformat(),
|
||||
"last_seen": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
def add_wifi_client(self, client: dict) -> None:
|
||||
"""Add a WiFi client to the current baseline."""
|
||||
if not self.recording:
|
||||
return
|
||||
|
||||
frequency = signal.get('frequency')
|
||||
mac = client.get("mac", client.get("address", "")).upper()
|
||||
if not mac:
|
||||
return
|
||||
|
||||
if mac in self.wifi_clients:
|
||||
self.wifi_clients[mac].update(
|
||||
{
|
||||
"last_seen": datetime.now().isoformat(),
|
||||
"rssi": client.get("rssi", self.wifi_clients[mac].get("rssi")),
|
||||
"associated_bssid": client.get("associated_bssid", self.wifi_clients[mac].get("associated_bssid")),
|
||||
}
|
||||
)
|
||||
else:
|
||||
self.wifi_clients[mac] = {
|
||||
"mac": mac,
|
||||
"vendor": client.get("vendor", ""),
|
||||
"rssi": client.get("rssi"),
|
||||
"associated_bssid": client.get("associated_bssid"),
|
||||
"probed_ssids": client.get("probed_ssids", []),
|
||||
"probe_count": client.get("probe_count", len(client.get("probed_ssids", []))),
|
||||
"first_seen": datetime.now().isoformat(),
|
||||
"last_seen": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
def add_rf_signal(self, signal: dict) -> None:
|
||||
"""Add an RF signal to the current baseline."""
|
||||
if not self.recording:
|
||||
return
|
||||
|
||||
frequency = signal.get("frequency")
|
||||
if not frequency:
|
||||
return
|
||||
|
||||
@@ -203,33 +200,33 @@ class BaselineRecorder:
|
||||
|
||||
if freq_key in self.rf_frequencies:
|
||||
existing = self.rf_frequencies[freq_key]
|
||||
existing['last_seen'] = datetime.now().isoformat()
|
||||
existing['hit_count'] = existing.get('hit_count', 1) + 1
|
||||
existing["last_seen"] = datetime.now().isoformat()
|
||||
existing["hit_count"] = existing.get("hit_count", 1) + 1
|
||||
# Update max signal level
|
||||
new_level = signal.get('level', signal.get('power', -100))
|
||||
if new_level > existing.get('max_level', -100):
|
||||
existing['max_level'] = new_level
|
||||
new_level = signal.get("level", signal.get("power", -100))
|
||||
if new_level > existing.get("max_level", -100):
|
||||
existing["max_level"] = new_level
|
||||
else:
|
||||
self.rf_frequencies[freq_key] = {
|
||||
'frequency': freq_key,
|
||||
'level': signal.get('level', signal.get('power')),
|
||||
'max_level': signal.get('level', signal.get('power', -100)),
|
||||
'modulation': signal.get('modulation', ''),
|
||||
'first_seen': datetime.now().isoformat(),
|
||||
'last_seen': datetime.now().isoformat(),
|
||||
'hit_count': 1,
|
||||
"frequency": freq_key,
|
||||
"level": signal.get("level", signal.get("power")),
|
||||
"max_level": signal.get("level", signal.get("power", -100)),
|
||||
"modulation": signal.get("modulation", ""),
|
||||
"first_seen": datetime.now().isoformat(),
|
||||
"last_seen": datetime.now().isoformat(),
|
||||
"hit_count": 1,
|
||||
}
|
||||
|
||||
def get_recording_status(self) -> dict:
|
||||
"""Get current recording status and counts."""
|
||||
return {
|
||||
'recording': self.recording,
|
||||
'baseline_id': self.current_baseline_id,
|
||||
'wifi_count': len(self.wifi_networks),
|
||||
'wifi_client_count': len(self.wifi_clients),
|
||||
'bt_count': len(self.bt_devices),
|
||||
'rf_count': len(self.rf_frequencies),
|
||||
}
|
||||
def get_recording_status(self) -> dict:
|
||||
"""Get current recording status and counts."""
|
||||
return {
|
||||
"recording": self.recording,
|
||||
"baseline_id": self.current_baseline_id,
|
||||
"wifi_count": len(self.wifi_networks),
|
||||
"wifi_client_count": len(self.wifi_clients),
|
||||
"bt_count": len(self.bt_devices),
|
||||
"rf_count": len(self.rf_frequencies),
|
||||
}
|
||||
|
||||
|
||||
class BaselineComparator:
|
||||
@@ -246,24 +243,22 @@ class BaselineComparator:
|
||||
"""
|
||||
self.baseline = baseline
|
||||
self.baseline_wifi = {
|
||||
d.get('bssid', d.get('mac', '')).upper(): d
|
||||
for d in baseline.get('wifi_networks', [])
|
||||
if d.get('bssid') or d.get('mac')
|
||||
d.get("bssid", d.get("mac", "")).upper(): d
|
||||
for d in baseline.get("wifi_networks", [])
|
||||
if d.get("bssid") or d.get("mac")
|
||||
}
|
||||
self.baseline_bt = {
|
||||
d.get("mac", d.get("address", "")).upper(): d
|
||||
for d in baseline.get("bt_devices", [])
|
||||
if d.get("mac") or d.get("address")
|
||||
}
|
||||
self.baseline_wifi_clients = {
|
||||
d.get("mac", d.get("address", "")).upper(): d
|
||||
for d in baseline.get("wifi_clients", [])
|
||||
if d.get("mac") or d.get("address")
|
||||
}
|
||||
self.baseline_bt = {
|
||||
d.get('mac', d.get('address', '')).upper(): d
|
||||
for d in baseline.get('bt_devices', [])
|
||||
if d.get('mac') or d.get('address')
|
||||
}
|
||||
self.baseline_wifi_clients = {
|
||||
d.get('mac', d.get('address', '')).upper(): d
|
||||
for d in baseline.get('wifi_clients', [])
|
||||
if d.get('mac') or d.get('address')
|
||||
}
|
||||
self.baseline_rf = {
|
||||
round(d.get('frequency', 0), 1): d
|
||||
for d in baseline.get('rf_frequencies', [])
|
||||
if d.get('frequency')
|
||||
round(d.get("frequency", 0), 1): d for d in baseline.get("rf_frequencies", []) if d.get("frequency")
|
||||
}
|
||||
|
||||
def compare_wifi(self, current_devices: list[dict]) -> dict:
|
||||
@@ -274,9 +269,7 @@ class BaselineComparator:
|
||||
Dict with new, missing, and matching devices
|
||||
"""
|
||||
current_macs = {
|
||||
d.get('bssid', d.get('mac', '')).upper(): d
|
||||
for d in current_devices
|
||||
if d.get('bssid') or d.get('mac')
|
||||
d.get("bssid", d.get("mac", "")).upper(): d for d in current_devices if d.get("bssid") or d.get("mac")
|
||||
}
|
||||
|
||||
new_devices = []
|
||||
@@ -296,20 +289,18 @@ class BaselineComparator:
|
||||
missing_devices.append(device)
|
||||
|
||||
return {
|
||||
'new': new_devices,
|
||||
'missing': missing_devices,
|
||||
'matching': matching_devices,
|
||||
'new_count': len(new_devices),
|
||||
'missing_count': len(missing_devices),
|
||||
'matching_count': len(matching_devices),
|
||||
"new": new_devices,
|
||||
"missing": missing_devices,
|
||||
"matching": matching_devices,
|
||||
"new_count": len(new_devices),
|
||||
"missing_count": len(missing_devices),
|
||||
"matching_count": len(matching_devices),
|
||||
}
|
||||
|
||||
def compare_bluetooth(self, current_devices: list[dict]) -> dict:
|
||||
"""Compare current Bluetooth devices against baseline."""
|
||||
def compare_bluetooth(self, current_devices: list[dict]) -> dict:
|
||||
"""Compare current Bluetooth devices against baseline."""
|
||||
current_macs = {
|
||||
d.get('mac', d.get('address', '')).upper(): d
|
||||
for d in current_devices
|
||||
if d.get('mac') or d.get('address')
|
||||
d.get("mac", d.get("address", "")).upper(): d for d in current_devices if d.get("mac") or d.get("address")
|
||||
}
|
||||
|
||||
new_devices = []
|
||||
@@ -326,53 +317,47 @@ class BaselineComparator:
|
||||
if mac not in current_macs:
|
||||
missing_devices.append(device)
|
||||
|
||||
return {
|
||||
'new': new_devices,
|
||||
'missing': missing_devices,
|
||||
'matching': matching_devices,
|
||||
'new_count': len(new_devices),
|
||||
'missing_count': len(missing_devices),
|
||||
'matching_count': len(matching_devices),
|
||||
}
|
||||
|
||||
def compare_wifi_clients(self, current_devices: list[dict]) -> dict:
|
||||
"""Compare current WiFi clients against baseline."""
|
||||
current_macs = {
|
||||
d.get('mac', d.get('address', '')).upper(): d
|
||||
for d in current_devices
|
||||
if d.get('mac') or d.get('address')
|
||||
}
|
||||
|
||||
new_devices = []
|
||||
missing_devices = []
|
||||
matching_devices = []
|
||||
|
||||
for mac, device in current_macs.items():
|
||||
if mac not in self.baseline_wifi_clients:
|
||||
new_devices.append(device)
|
||||
else:
|
||||
matching_devices.append(device)
|
||||
|
||||
for mac, device in self.baseline_wifi_clients.items():
|
||||
if mac not in current_macs:
|
||||
missing_devices.append(device)
|
||||
|
||||
return {
|
||||
'new': new_devices,
|
||||
'missing': missing_devices,
|
||||
'matching': matching_devices,
|
||||
'new_count': len(new_devices),
|
||||
'missing_count': len(missing_devices),
|
||||
'matching_count': len(matching_devices),
|
||||
}
|
||||
return {
|
||||
"new": new_devices,
|
||||
"missing": missing_devices,
|
||||
"matching": matching_devices,
|
||||
"new_count": len(new_devices),
|
||||
"missing_count": len(missing_devices),
|
||||
"matching_count": len(matching_devices),
|
||||
}
|
||||
|
||||
def compare_wifi_clients(self, current_devices: list[dict]) -> dict:
|
||||
"""Compare current WiFi clients against baseline."""
|
||||
current_macs = {
|
||||
d.get("mac", d.get("address", "")).upper(): d for d in current_devices if d.get("mac") or d.get("address")
|
||||
}
|
||||
|
||||
new_devices = []
|
||||
missing_devices = []
|
||||
matching_devices = []
|
||||
|
||||
for mac, device in current_macs.items():
|
||||
if mac not in self.baseline_wifi_clients:
|
||||
new_devices.append(device)
|
||||
else:
|
||||
matching_devices.append(device)
|
||||
|
||||
for mac, device in self.baseline_wifi_clients.items():
|
||||
if mac not in current_macs:
|
||||
missing_devices.append(device)
|
||||
|
||||
return {
|
||||
"new": new_devices,
|
||||
"missing": missing_devices,
|
||||
"matching": matching_devices,
|
||||
"new_count": len(new_devices),
|
||||
"missing_count": len(missing_devices),
|
||||
"matching_count": len(matching_devices),
|
||||
}
|
||||
|
||||
def compare_rf(self, current_signals: list[dict]) -> dict:
|
||||
"""Compare current RF signals against baseline."""
|
||||
current_freqs = {
|
||||
round(s.get('frequency', 0), 1): s
|
||||
for s in current_signals
|
||||
if s.get('frequency')
|
||||
}
|
||||
current_freqs = {round(s.get("frequency", 0), 1): s for s in current_signals if s.get("frequency")}
|
||||
|
||||
new_signals = []
|
||||
missing_signals = []
|
||||
@@ -389,65 +374,65 @@ class BaselineComparator:
|
||||
missing_signals.append(signal)
|
||||
|
||||
return {
|
||||
'new': new_signals,
|
||||
'missing': missing_signals,
|
||||
'matching': matching_signals,
|
||||
'new_count': len(new_signals),
|
||||
'missing_count': len(missing_signals),
|
||||
'matching_count': len(matching_signals),
|
||||
"new": new_signals,
|
||||
"missing": missing_signals,
|
||||
"matching": matching_signals,
|
||||
"new_count": len(new_signals),
|
||||
"missing_count": len(missing_signals),
|
||||
"matching_count": len(matching_signals),
|
||||
}
|
||||
|
||||
def compare_all(
|
||||
self,
|
||||
wifi_devices: list[dict] | None = None,
|
||||
wifi_clients: list[dict] | None = None,
|
||||
bt_devices: list[dict] | None = None,
|
||||
rf_signals: list[dict] | None = None
|
||||
) -> dict:
|
||||
def compare_all(
|
||||
self,
|
||||
wifi_devices: list[dict] | None = None,
|
||||
wifi_clients: list[dict] | None = None,
|
||||
bt_devices: list[dict] | None = None,
|
||||
rf_signals: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Compare all current data against baseline.
|
||||
|
||||
Returns:
|
||||
Dict with comparison results for each category
|
||||
"""
|
||||
results = {
|
||||
'wifi': None,
|
||||
'wifi_clients': None,
|
||||
'bluetooth': None,
|
||||
'rf': None,
|
||||
'total_new': 0,
|
||||
'total_missing': 0,
|
||||
}
|
||||
results = {
|
||||
"wifi": None,
|
||||
"wifi_clients": None,
|
||||
"bluetooth": None,
|
||||
"rf": None,
|
||||
"total_new": 0,
|
||||
"total_missing": 0,
|
||||
}
|
||||
|
||||
if wifi_devices is not None:
|
||||
results['wifi'] = self.compare_wifi(wifi_devices)
|
||||
results['total_new'] += results['wifi']['new_count']
|
||||
results['total_missing'] += results['wifi']['missing_count']
|
||||
|
||||
if wifi_clients is not None:
|
||||
results['wifi_clients'] = self.compare_wifi_clients(wifi_clients)
|
||||
results['total_new'] += results['wifi_clients']['new_count']
|
||||
results['total_missing'] += results['wifi_clients']['missing_count']
|
||||
|
||||
if bt_devices is not None:
|
||||
results['bluetooth'] = self.compare_bluetooth(bt_devices)
|
||||
results['total_new'] += results['bluetooth']['new_count']
|
||||
results['total_missing'] += results['bluetooth']['missing_count']
|
||||
if wifi_devices is not None:
|
||||
results["wifi"] = self.compare_wifi(wifi_devices)
|
||||
results["total_new"] += results["wifi"]["new_count"]
|
||||
results["total_missing"] += results["wifi"]["missing_count"]
|
||||
|
||||
if wifi_clients is not None:
|
||||
results["wifi_clients"] = self.compare_wifi_clients(wifi_clients)
|
||||
results["total_new"] += results["wifi_clients"]["new_count"]
|
||||
results["total_missing"] += results["wifi_clients"]["missing_count"]
|
||||
|
||||
if bt_devices is not None:
|
||||
results["bluetooth"] = self.compare_bluetooth(bt_devices)
|
||||
results["total_new"] += results["bluetooth"]["new_count"]
|
||||
results["total_missing"] += results["bluetooth"]["missing_count"]
|
||||
|
||||
if rf_signals is not None:
|
||||
results['rf'] = self.compare_rf(rf_signals)
|
||||
results['total_new'] += results['rf']['new_count']
|
||||
results['total_missing'] += results['rf']['missing_count']
|
||||
results["rf"] = self.compare_rf(rf_signals)
|
||||
results["total_new"] += results["rf"]["new_count"]
|
||||
results["total_missing"] += results["rf"]["missing_count"]
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def get_comparison_for_active_baseline(
|
||||
wifi_devices: list[dict] | None = None,
|
||||
wifi_clients: list[dict] | None = None,
|
||||
bt_devices: list[dict] | None = None,
|
||||
rf_signals: list[dict] | None = None
|
||||
) -> dict | None:
|
||||
def get_comparison_for_active_baseline(
|
||||
wifi_devices: list[dict] | None = None,
|
||||
wifi_clients: list[dict] | None = None,
|
||||
bt_devices: list[dict] | None = None,
|
||||
rf_signals: list[dict] | None = None,
|
||||
) -> dict | None:
|
||||
"""
|
||||
Convenience function to compare against the active baseline.
|
||||
|
||||
@@ -459,4 +444,4 @@ def get_comparison_for_active_baseline(
|
||||
return None
|
||||
|
||||
comparator = BaselineComparator(baseline)
|
||||
return comparator.compare_all(wifi_devices, wifi_clients, bt_devices, rf_signals)
|
||||
return comparator.compare_all(wifi_devices, wifi_clients, bt_devices, rf_signals)
|
||||
|
||||
+80
-83
@@ -21,45 +21,45 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger('intercept.tscm.ble')
|
||||
logger = logging.getLogger("intercept.tscm.ble")
|
||||
|
||||
# Manufacturer company IDs (Bluetooth SIG assigned)
|
||||
COMPANY_IDS = {
|
||||
0x004C: 'Apple',
|
||||
0x02E5: 'Espressif',
|
||||
0x0059: 'Nordic Semiconductor',
|
||||
0x000D: 'Texas Instruments',
|
||||
0x0075: 'Samsung',
|
||||
0x00E0: 'Google',
|
||||
0x0006: 'Microsoft',
|
||||
0x01DA: 'Tile',
|
||||
0x004C: "Apple",
|
||||
0x02E5: "Espressif",
|
||||
0x0059: "Nordic Semiconductor",
|
||||
0x000D: "Texas Instruments",
|
||||
0x0075: "Samsung",
|
||||
0x00E0: "Google",
|
||||
0x0006: "Microsoft",
|
||||
0x01DA: "Tile",
|
||||
}
|
||||
|
||||
# Known tracker signatures
|
||||
TRACKER_SIGNATURES = {
|
||||
# Apple AirTag detection patterns
|
||||
'airtag': {
|
||||
'company_id': 0x004C,
|
||||
'data_patterns': [
|
||||
b'\x12\x19', # AirTag/Find My advertisement prefix
|
||||
b'\x07\x19', # Offline Finding
|
||||
"airtag": {
|
||||
"company_id": 0x004C,
|
||||
"data_patterns": [
|
||||
b"\x12\x19", # AirTag/Find My advertisement prefix
|
||||
b"\x07\x19", # Offline Finding
|
||||
],
|
||||
'name_patterns': ['airtag', 'findmy', 'find my'],
|
||||
"name_patterns": ["airtag", "findmy", "find my"],
|
||||
},
|
||||
# Tile tracker
|
||||
'tile': {
|
||||
'company_id': 0x01DA,
|
||||
'name_patterns': ['tile'],
|
||||
"tile": {
|
||||
"company_id": 0x01DA,
|
||||
"name_patterns": ["tile"],
|
||||
},
|
||||
# Samsung SmartTag
|
||||
'smarttag': {
|
||||
'company_id': 0x0075,
|
||||
'name_patterns': ['smarttag', 'smart tag', 'galaxy smart'],
|
||||
"smarttag": {
|
||||
"company_id": 0x0075,
|
||||
"name_patterns": ["smarttag", "smart tag", "galaxy smart"],
|
||||
},
|
||||
# ESP32/ESP8266
|
||||
'espressif': {
|
||||
'company_id': 0x02E5,
|
||||
'name_patterns': ['esp32', 'esp8266', 'espressif'],
|
||||
"espressif": {
|
||||
"company_id": 0x02E5,
|
||||
"name_patterns": ["esp32", "esp8266", "espressif"],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ TRACKER_SIGNATURES = {
|
||||
@dataclass
|
||||
class BLEDevice:
|
||||
"""Represents a detected BLE device with full advertisement data."""
|
||||
|
||||
mac: str
|
||||
name: Optional[str] = None
|
||||
rssi: Optional[int] = None
|
||||
@@ -92,22 +93,22 @@ class BLEDevice:
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'mac': self.mac,
|
||||
'name': self.name or 'Unknown',
|
||||
'rssi': self.rssi,
|
||||
'manufacturer_id': self.manufacturer_id,
|
||||
'manufacturer_name': self.manufacturer_name,
|
||||
'service_uuids': self.service_uuids,
|
||||
'tx_power': self.tx_power,
|
||||
'is_connectable': self.is_connectable,
|
||||
'is_airtag': self.is_airtag,
|
||||
'is_tile': self.is_tile,
|
||||
'is_smarttag': self.is_smarttag,
|
||||
'is_espressif': self.is_espressif,
|
||||
'is_tracker': self.is_tracker,
|
||||
'tracker_type': self.tracker_type,
|
||||
'detection_count': self.detection_count,
|
||||
'type': 'ble',
|
||||
"mac": self.mac,
|
||||
"name": self.name or "Unknown",
|
||||
"rssi": self.rssi,
|
||||
"manufacturer_id": self.manufacturer_id,
|
||||
"manufacturer_name": self.manufacturer_name,
|
||||
"service_uuids": self.service_uuids,
|
||||
"tx_power": self.tx_power,
|
||||
"is_connectable": self.is_connectable,
|
||||
"is_airtag": self.is_airtag,
|
||||
"is_tile": self.is_tile,
|
||||
"is_smarttag": self.is_smarttag,
|
||||
"is_espressif": self.is_espressif,
|
||||
"is_tracker": self.is_tracker,
|
||||
"tracker_type": self.tracker_type,
|
||||
"detection_count": self.detection_count,
|
||||
"type": "ble",
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +129,7 @@ class BLEScanner:
|
||||
"""Check if bleak library is available."""
|
||||
try:
|
||||
import bleak
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
logger.warning("bleak library not available - using fallback scanning")
|
||||
@@ -177,7 +179,7 @@ class BLEScanner:
|
||||
if adv_data.manufacturer_data:
|
||||
for company_id, data in adv_data.manufacturer_data.items():
|
||||
ble_device.manufacturer_id = company_id
|
||||
ble_device.manufacturer_name = COMPANY_IDS.get(company_id, f'Unknown ({hex(company_id)})')
|
||||
ble_device.manufacturer_name = COMPANY_IDS.get(company_id, f"Unknown ({hex(company_id)})")
|
||||
# Handle various data types safely
|
||||
try:
|
||||
if isinstance(data, (bytes, bytearray, list, tuple)):
|
||||
@@ -259,19 +261,19 @@ class BLEScanner:
|
||||
if data[0] == 0x12 and data[1] == 0x19:
|
||||
device.is_airtag = True
|
||||
device.is_tracker = True
|
||||
device.tracker_type = 'AirTag'
|
||||
device.tracker_type = "AirTag"
|
||||
logger.info(f"AirTag detected: {device.mac}")
|
||||
elif data[0] == 0x07: # Offline Finding
|
||||
device.is_airtag = True
|
||||
device.is_tracker = True
|
||||
device.tracker_type = 'AirTag (Offline)'
|
||||
device.tracker_type = "AirTag (Offline)"
|
||||
logger.info(f"AirTag (offline mode) detected: {device.mac}")
|
||||
|
||||
# Tile tracker
|
||||
elif company_id == 0x01DA: # Tile
|
||||
device.is_tile = True
|
||||
device.is_tracker = True
|
||||
device.tracker_type = 'Tile'
|
||||
device.tracker_type = "Tile"
|
||||
logger.info(f"Tile tracker detected: {device.mac}")
|
||||
|
||||
# Samsung SmartTag
|
||||
@@ -279,13 +281,13 @@ class BLEScanner:
|
||||
# Check if it's specifically a SmartTag
|
||||
device.is_smarttag = True
|
||||
device.is_tracker = True
|
||||
device.tracker_type = 'SmartTag'
|
||||
device.tracker_type = "SmartTag"
|
||||
logger.info(f"Samsung SmartTag detected: {device.mac}")
|
||||
|
||||
# Espressif (ESP32/ESP8266)
|
||||
elif company_id == 0x02E5: # Espressif
|
||||
device.is_espressif = True
|
||||
device.tracker_type = 'ESP32/ESP8266'
|
||||
device.tracker_type = "ESP32/ESP8266"
|
||||
logger.info(f"ESP32/ESP8266 device detected: {device.mac}")
|
||||
|
||||
def _check_name_patterns(self, device: BLEDevice):
|
||||
@@ -297,24 +299,24 @@ class BLEScanner:
|
||||
|
||||
# Check each tracker type
|
||||
for tracker_type, sig in TRACKER_SIGNATURES.items():
|
||||
patterns = sig.get('name_patterns', [])
|
||||
patterns = sig.get("name_patterns", [])
|
||||
for pattern in patterns:
|
||||
if pattern in name_lower:
|
||||
if tracker_type == 'airtag':
|
||||
if tracker_type == "airtag":
|
||||
device.is_airtag = True
|
||||
device.is_tracker = True
|
||||
device.tracker_type = 'AirTag'
|
||||
elif tracker_type == 'tile':
|
||||
device.tracker_type = "AirTag"
|
||||
elif tracker_type == "tile":
|
||||
device.is_tile = True
|
||||
device.is_tracker = True
|
||||
device.tracker_type = 'Tile'
|
||||
elif tracker_type == 'smarttag':
|
||||
device.tracker_type = "Tile"
|
||||
elif tracker_type == "smarttag":
|
||||
device.is_smarttag = True
|
||||
device.is_tracker = True
|
||||
device.tracker_type = 'SmartTag'
|
||||
elif tracker_type == 'espressif':
|
||||
device.tracker_type = "SmartTag"
|
||||
elif tracker_type == "espressif":
|
||||
device.is_espressif = True
|
||||
device.tracker_type = 'ESP32/ESP8266'
|
||||
device.tracker_type = "ESP32/ESP8266"
|
||||
|
||||
logger.info(f"Tracker identified by name: {device.name} -> {tracker_type}")
|
||||
return
|
||||
@@ -326,7 +328,7 @@ class BLEScanner:
|
||||
"""
|
||||
system = platform.system()
|
||||
|
||||
if system == 'Darwin':
|
||||
if system == "Darwin":
|
||||
return self._scan_macos(duration)
|
||||
else:
|
||||
return self._scan_linux(duration)
|
||||
@@ -337,20 +339,20 @@ class BLEScanner:
|
||||
|
||||
try:
|
||||
import json
|
||||
|
||||
result = subprocess.run(
|
||||
['system_profiler', 'SPBluetoothDataType', '-json'],
|
||||
capture_output=True, text=True, timeout=15
|
||||
["system_profiler", "SPBluetoothDataType", "-json"], capture_output=True, text=True, timeout=15
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
bt_data = data.get('SPBluetoothDataType', [{}])[0]
|
||||
bt_data = data.get("SPBluetoothDataType", [{}])[0]
|
||||
|
||||
# Get connected/paired devices
|
||||
for section in ['device_connected', 'device_title']:
|
||||
for section in ["device_connected", "device_title"]:
|
||||
section_data = bt_data.get(section, {})
|
||||
if isinstance(section_data, dict):
|
||||
for name, info in section_data.items():
|
||||
if isinstance(info, dict):
|
||||
mac = info.get('device_address', '').upper()
|
||||
mac = info.get("device_address", "").upper()
|
||||
if mac:
|
||||
device = BLEDevice(
|
||||
mac=mac,
|
||||
@@ -374,26 +376,23 @@ class BLEScanner:
|
||||
seen_macs = set()
|
||||
|
||||
# Method 1: Try btmgmt for BLE devices
|
||||
if shutil.which('btmgmt'):
|
||||
if shutil.which("btmgmt"):
|
||||
try:
|
||||
logger.info("Trying btmgmt find...")
|
||||
result = subprocess.run(
|
||||
['btmgmt', 'find'],
|
||||
capture_output=True, text=True, timeout=duration + 5
|
||||
)
|
||||
result = subprocess.run(["btmgmt", "find"], capture_output=True, text=True, timeout=duration + 5)
|
||||
|
||||
for line in result.stdout.split('\n'):
|
||||
if 'dev_found' in line.lower() or ('type' in line.lower() and ':' in line):
|
||||
for line in result.stdout.split("\n"):
|
||||
if "dev_found" in line.lower() or ("type" in line.lower() and ":" in line):
|
||||
mac_match = re.search(
|
||||
r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:'
|
||||
r'[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})',
|
||||
line
|
||||
r"([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:"
|
||||
r"[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})",
|
||||
line,
|
||||
)
|
||||
if mac_match:
|
||||
mac = mac_match.group(1).upper()
|
||||
if mac not in seen_macs:
|
||||
seen_macs.add(mac)
|
||||
name_match = re.search(r'name\s+(.+?)(?:\s|$)', line, re.I)
|
||||
name_match = re.search(r"name\s+(.+?)(?:\s|$)", line, re.I)
|
||||
name = name_match.group(1) if name_match else None
|
||||
|
||||
device = BLEDevice(mac=mac, name=name)
|
||||
@@ -405,28 +404,26 @@ class BLEScanner:
|
||||
logger.warning(f"btmgmt failed: {e}")
|
||||
|
||||
# Method 2: Try hcitool lescan
|
||||
if not devices and shutil.which('hcitool'):
|
||||
if not devices and shutil.which("hcitool"):
|
||||
try:
|
||||
logger.info("Trying hcitool lescan...")
|
||||
# Start lescan in background
|
||||
process = subprocess.Popen(
|
||||
['hcitool', 'lescan', '--duplicates'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
["hcitool", "lescan", "--duplicates"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
||||
)
|
||||
|
||||
import time
|
||||
|
||||
time.sleep(duration)
|
||||
process.terminate()
|
||||
|
||||
stdout, _ = process.communicate(timeout=2)
|
||||
|
||||
for line in stdout.split('\n'):
|
||||
for line in stdout.split("\n"):
|
||||
mac_match = re.search(
|
||||
r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:'
|
||||
r'[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})',
|
||||
line
|
||||
r"([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:"
|
||||
r"[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})",
|
||||
line,
|
||||
)
|
||||
if mac_match:
|
||||
mac = mac_match.group(1).upper()
|
||||
@@ -434,9 +431,9 @@ class BLEScanner:
|
||||
seen_macs.add(mac)
|
||||
# Extract name (comes after MAC)
|
||||
parts = line.strip().split()
|
||||
name = ' '.join(parts[1:]) if len(parts) > 1 else None
|
||||
name = " ".join(parts[1:]) if len(parts) > 1 else None
|
||||
|
||||
device = BLEDevice(mac=mac, name=name if name != '(unknown)' else None)
|
||||
device = BLEDevice(mac=mac, name=name if name != "(unknown)" else None)
|
||||
self._check_name_patterns(device)
|
||||
devices.append(device)
|
||||
|
||||
|
||||
+559
-560
File diff suppressed because it is too large
Load Diff
+289
-263
@@ -20,33 +20,48 @@ from utils.tscm.signal_classification import (
|
||||
get_signal_strength_info,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('intercept.tscm.detector')
|
||||
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',
|
||||
"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',
|
||||
"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',
|
||||
"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',
|
||||
"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
|
||||
@@ -62,10 +77,7 @@ def _record_device_seen(identifier: str) -> int:
|
||||
|
||||
# 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] = [dt for dt in _device_history[identifier] if dt.timestamp() > cutoff]
|
||||
|
||||
_device_history[identifier].append(now)
|
||||
return len(_device_history[identifier])
|
||||
@@ -80,7 +92,7 @@ def _is_audio_capable_ble(name: str | None, device_type: str | None = None) -> b
|
||||
return True
|
||||
if device_type:
|
||||
type_lower = device_type.lower()
|
||||
if any(t in type_lower for t in ['audio', 'headset', 'headphone', 'speaker']):
|
||||
if any(t in type_lower for t in ["audio", "headset", "headphone", "speaker"]):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -107,28 +119,28 @@ class ThreatDetector:
|
||||
|
||||
def _load_baseline(self, baseline: dict) -> None:
|
||||
"""Load baseline device identifiers for comparison."""
|
||||
# WiFi networks and clients
|
||||
for network in baseline.get('wifi_networks', []):
|
||||
if 'bssid' in network:
|
||||
self.baseline_wifi_macs.add(network['bssid'].upper())
|
||||
if 'clients' in network:
|
||||
for client in network['clients']:
|
||||
if 'mac' in client:
|
||||
self.baseline_wifi_macs.add(client['mac'].upper())
|
||||
|
||||
for client in baseline.get('wifi_clients', []):
|
||||
if 'mac' in client:
|
||||
self.baseline_wifi_macs.add(client['mac'].upper())
|
||||
# WiFi networks and clients
|
||||
for network in baseline.get("wifi_networks", []):
|
||||
if "bssid" in network:
|
||||
self.baseline_wifi_macs.add(network["bssid"].upper())
|
||||
if "clients" in network:
|
||||
for client in network["clients"]:
|
||||
if "mac" in client:
|
||||
self.baseline_wifi_macs.add(client["mac"].upper())
|
||||
|
||||
for client in baseline.get("wifi_clients", []):
|
||||
if "mac" in client:
|
||||
self.baseline_wifi_macs.add(client["mac"].upper())
|
||||
|
||||
# Bluetooth devices
|
||||
for device in baseline.get('bt_devices', []):
|
||||
if 'mac' in device:
|
||||
self.baseline_bt_macs.add(device['mac'].upper())
|
||||
for device in baseline.get("bt_devices", []):
|
||||
if "mac" in device:
|
||||
self.baseline_bt_macs.add(device["mac"].upper())
|
||||
|
||||
# RF frequencies (rounded to nearest 0.1 MHz)
|
||||
for freq in baseline.get('rf_frequencies', []):
|
||||
for freq in baseline.get("rf_frequencies", []):
|
||||
if isinstance(freq, dict):
|
||||
self.baseline_rf_freqs.add(round(freq.get('frequency', 0), 1))
|
||||
self.baseline_rf_freqs.add(round(freq.get("frequency", 0), 1))
|
||||
else:
|
||||
self.baseline_rf_freqs.add(round(freq, 1))
|
||||
|
||||
@@ -144,31 +156,31 @@ class ThreatDetector:
|
||||
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))
|
||||
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'
|
||||
classification = "informational"
|
||||
|
||||
# Track repeat detections
|
||||
times_seen = _record_device_seen(f'wifi:{mac}') if mac else 1
|
||||
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'
|
||||
reasons.append("Known device in baseline")
|
||||
classification = "informational"
|
||||
else:
|
||||
# New/unknown device
|
||||
reasons.append('New WiFi access point')
|
||||
classification = 'review'
|
||||
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'
|
||||
reasons.append("Matches camera device patterns")
|
||||
classification = "high_interest"
|
||||
|
||||
try:
|
||||
signal_val = int(signal) if signal else -100
|
||||
@@ -177,27 +189,27 @@ class ThreatDetector:
|
||||
|
||||
# Use standardized signal classification
|
||||
signal_info = get_signal_strength_info(signal_val)
|
||||
if not ssid and signal_info['strength'] in ('strong', 'very_strong'):
|
||||
if not ssid and signal_info["strength"] in ("strong", "very_strong"):
|
||||
reasons.append(f"Hidden SSID with {signal_info['label'].lower()} signal")
|
||||
classification = 'high_interest'
|
||||
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'
|
||||
reasons.append(f"Repeat detection ({times_seen} times)")
|
||||
if classification != "high_interest":
|
||||
classification = "high_interest"
|
||||
|
||||
# Include standardized signal classification
|
||||
signal_info = get_signal_strength_info(signal_val)
|
||||
|
||||
return {
|
||||
'classification': classification,
|
||||
'reasons': reasons,
|
||||
'in_baseline': in_baseline,
|
||||
'times_seen': times_seen,
|
||||
'signal_strength': signal_info['strength'],
|
||||
'signal_label': signal_info['label'],
|
||||
'signal_confidence': signal_info['confidence'],
|
||||
"classification": classification,
|
||||
"reasons": reasons,
|
||||
"in_baseline": in_baseline,
|
||||
"times_seen": times_seen,
|
||||
"signal_strength": signal_info["strength"],
|
||||
"signal_label": signal_info["label"],
|
||||
"signal_confidence": signal_info["confidence"],
|
||||
}
|
||||
|
||||
def classify_bt_device(self, device: dict) -> dict:
|
||||
@@ -209,33 +221,33 @@ class ThreatDetector:
|
||||
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')
|
||||
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'
|
||||
classification = "informational"
|
||||
|
||||
# Track repeat detections
|
||||
times_seen = _record_device_seen(f'bt:{mac}') if mac else 1
|
||||
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
|
||||
|
||||
# Use v2 tracker detection data if available (from get_tscm_bluetooth_snapshot)
|
||||
tracker_data = device.get('tracker', {})
|
||||
is_tracker_v2 = tracker_data.get('is_tracker', False)
|
||||
tracker_type_v2 = tracker_data.get('type')
|
||||
tracker_name_v2 = tracker_data.get('name')
|
||||
tracker_confidence_v2 = tracker_data.get('confidence')
|
||||
tracker_evidence_v2 = tracker_data.get('evidence', [])
|
||||
tracker_data = device.get("tracker", {})
|
||||
is_tracker_v2 = tracker_data.get("is_tracker", False)
|
||||
tracker_type_v2 = tracker_data.get("type")
|
||||
tracker_name_v2 = tracker_data.get("name")
|
||||
tracker_confidence_v2 = tracker_data.get("confidence")
|
||||
tracker_evidence_v2 = tracker_data.get("evidence", [])
|
||||
|
||||
# Use v2 risk analysis if available
|
||||
risk_data = device.get('risk_analysis', {})
|
||||
risk_score = risk_data.get('risk_score', 0)
|
||||
risk_factors = risk_data.get('risk_factors', [])
|
||||
risk_data = device.get("risk_analysis", {})
|
||||
risk_score = risk_data.get("risk_score", 0)
|
||||
risk_factors = risk_data.get("risk_factors", [])
|
||||
|
||||
# Fall back to legacy detection if v2 not available
|
||||
tracker_info_legacy = None
|
||||
@@ -245,23 +257,23 @@ class ThreatDetector:
|
||||
is_tracker = is_tracker_v2 or (tracker_info_legacy is not None)
|
||||
|
||||
if in_baseline:
|
||||
reasons.append('Known device in baseline')
|
||||
classification = 'informational'
|
||||
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'
|
||||
if not name or name == "Unknown":
|
||||
reasons.append("Unknown BLE device")
|
||||
classification = "review"
|
||||
else:
|
||||
reasons.append('New Bluetooth device')
|
||||
classification = 'review'
|
||||
reasons.append("New Bluetooth device")
|
||||
classification = "review"
|
||||
|
||||
# Check for trackers -> high interest
|
||||
if is_tracker_v2:
|
||||
tracker_label = tracker_name_v2 or tracker_type_v2 or 'Unknown tracker'
|
||||
conf_label = f' ({tracker_confidence_v2})' if tracker_confidence_v2 else ''
|
||||
tracker_label = tracker_name_v2 or tracker_type_v2 or "Unknown tracker"
|
||||
conf_label = f" ({tracker_confidence_v2})" if tracker_confidence_v2 else ""
|
||||
reasons.append(f"Tracker detected: {tracker_label}{conf_label}")
|
||||
classification = 'high_interest'
|
||||
classification = "high_interest"
|
||||
|
||||
# Add evidence from v2 detection
|
||||
for evidence_item in tracker_evidence_v2[:2]: # First 2 items
|
||||
@@ -275,12 +287,12 @@ class ThreatDetector:
|
||||
|
||||
elif tracker_info_legacy:
|
||||
reasons.append(f"Known tracker: {tracker_info_legacy.get('name', 'Unknown')}")
|
||||
classification = 'high_interest'
|
||||
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'
|
||||
reasons.append("Audio-capable BLE device")
|
||||
classification = "high_interest"
|
||||
|
||||
# Strong signal from unknown device - use standardized classification
|
||||
try:
|
||||
@@ -289,15 +301,15 @@ class ThreatDetector:
|
||||
rssi_val = -100
|
||||
|
||||
signal_info = get_signal_strength_info(rssi_val)
|
||||
if signal_info['strength'] in ('strong', 'very_strong') and not name:
|
||||
if signal_info["strength"] in ("strong", "very_strong") and not name:
|
||||
reasons.append(f"{signal_info['label']} signal from unnamed device")
|
||||
classification = 'high_interest'
|
||||
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'
|
||||
reasons.append(f"Repeat detection ({times_seen} times)")
|
||||
if classification != "high_interest":
|
||||
classification = "high_interest"
|
||||
|
||||
# Include standardized signal classification
|
||||
try:
|
||||
@@ -307,19 +319,19 @@ class ThreatDetector:
|
||||
signal_info = get_signal_strength_info(rssi_val)
|
||||
|
||||
return {
|
||||
'classification': classification,
|
||||
'reasons': reasons,
|
||||
'in_baseline': in_baseline,
|
||||
'times_seen': times_seen,
|
||||
'is_tracker': is_tracker,
|
||||
'tracker_type': tracker_type_v2,
|
||||
'tracker_name': tracker_name_v2,
|
||||
'tracker_confidence': tracker_confidence_v2,
|
||||
'risk_score': risk_score,
|
||||
'is_audio_capable': _is_audio_capable_ble(name, device_type),
|
||||
'signal_strength': signal_info['strength'],
|
||||
'signal_label': signal_info['label'],
|
||||
'signal_confidence': signal_info['confidence'],
|
||||
"classification": classification,
|
||||
"reasons": reasons,
|
||||
"in_baseline": in_baseline,
|
||||
"times_seen": times_seen,
|
||||
"is_tracker": is_tracker,
|
||||
"tracker_type": tracker_type_v2,
|
||||
"tracker_name": tracker_name_v2,
|
||||
"tracker_confidence": tracker_confidence_v2,
|
||||
"risk_score": risk_score,
|
||||
"is_audio_capable": _is_audio_capable_ble(name, device_type),
|
||||
"signal_strength": signal_info["strength"],
|
||||
"signal_label": signal_info["label"],
|
||||
"signal_confidence": signal_info["confidence"],
|
||||
}
|
||||
|
||||
def classify_rf_signal(self, signal: dict) -> dict:
|
||||
@@ -329,16 +341,16 @@ class ThreatDetector:
|
||||
Returns:
|
||||
Dict with 'classification', 'reasons', and metadata
|
||||
"""
|
||||
frequency = signal.get('frequency', 0)
|
||||
power = signal.get('power', signal.get('level', -100))
|
||||
signal.get('band', '')
|
||||
frequency = signal.get("frequency", 0)
|
||||
power = signal.get("power", signal.get("level", -100))
|
||||
signal.get("band", "")
|
||||
|
||||
reasons = []
|
||||
classification = 'informational'
|
||||
classification = "informational"
|
||||
freq_rounded = round(frequency, 1)
|
||||
|
||||
# Track repeat detections
|
||||
times_seen = _record_device_seen(f'rf:{freq_rounded}')
|
||||
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
|
||||
@@ -347,33 +359,33 @@ class ThreatDetector:
|
||||
risk, band_name = get_frequency_risk(frequency)
|
||||
|
||||
if in_baseline:
|
||||
reasons.append('Known frequency in baseline')
|
||||
classification = 'informational'
|
||||
reasons.append("Known frequency in baseline")
|
||||
classification = "informational"
|
||||
else:
|
||||
# New/unidentified RF carrier
|
||||
reasons.append(f'Unidentified RF carrier in {band_name}')
|
||||
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'
|
||||
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 - use standardized classification
|
||||
if power:
|
||||
power_info = get_signal_strength_info(float(power))
|
||||
if power_info['strength'] in ('strong', 'very_strong'):
|
||||
if power_info["strength"] in ("strong", "very_strong"):
|
||||
reasons.append(f"{power_info['label']} persistent transmitter")
|
||||
classification = 'high_interest'
|
||||
classification = "high_interest"
|
||||
|
||||
# Repeat detections (persistent transmitter)
|
||||
if times_seen >= 2:
|
||||
reasons.append(f'Persistent transmitter ({times_seen} detections)')
|
||||
classification = 'high_interest'
|
||||
reasons.append(f"Persistent transmitter ({times_seen} detections)")
|
||||
classification = "high_interest"
|
||||
|
||||
# Include standardized signal classification
|
||||
try:
|
||||
@@ -383,15 +395,15 @@ class ThreatDetector:
|
||||
signal_info = get_signal_strength_info(power_val)
|
||||
|
||||
return {
|
||||
'classification': classification,
|
||||
'reasons': reasons,
|
||||
'in_baseline': in_baseline,
|
||||
'times_seen': times_seen,
|
||||
'risk_level': risk,
|
||||
'band_name': band_name,
|
||||
'signal_strength': signal_info['strength'],
|
||||
'signal_label': signal_info['label'],
|
||||
'signal_confidence': signal_info['confidence'],
|
||||
"classification": classification,
|
||||
"reasons": reasons,
|
||||
"in_baseline": in_baseline,
|
||||
"times_seen": times_seen,
|
||||
"risk_level": risk,
|
||||
"band_name": band_name,
|
||||
"signal_strength": signal_info["strength"],
|
||||
"signal_label": signal_info["label"],
|
||||
"signal_confidence": signal_info["confidence"],
|
||||
}
|
||||
|
||||
def analyze_wifi_device(self, device: dict) -> dict | None:
|
||||
@@ -404,28 +416,32 @@ class ThreatDetector:
|
||||
Returns:
|
||||
Threat dict if threat detected, None otherwise
|
||||
"""
|
||||
mac = device.get('bssid', device.get('mac', '')).upper()
|
||||
ssid = device.get('essid', device.get('ssid', ''))
|
||||
vendor = device.get('vendor', '')
|
||||
signal = device.get('power', device.get('signal', -100))
|
||||
mac = device.get("bssid", device.get("mac", "")).upper()
|
||||
ssid = device.get("essid", device.get("ssid", ""))
|
||||
vendor = device.get("vendor", "")
|
||||
signal = device.get("power", device.get("signal", -100))
|
||||
|
||||
threats = []
|
||||
|
||||
# Check if new device (not in baseline)
|
||||
if self.baseline and mac and mac not in self.baseline_wifi_macs:
|
||||
threats.append({
|
||||
'type': 'new_device',
|
||||
'severity': get_threat_severity('new_device', {'signal_strength': signal}),
|
||||
'reason': 'Device not present in baseline',
|
||||
})
|
||||
threats.append(
|
||||
{
|
||||
"type": "new_device",
|
||||
"severity": get_threat_severity("new_device", {"signal_strength": signal}),
|
||||
"reason": "Device not present in baseline",
|
||||
}
|
||||
)
|
||||
|
||||
# Check for hidden camera patterns
|
||||
if is_potential_camera(ssid=ssid, mac=mac, vendor=vendor):
|
||||
threats.append({
|
||||
'type': 'hidden_camera',
|
||||
'severity': get_threat_severity('hidden_camera', {'signal_strength': signal}),
|
||||
'reason': 'Device matches WiFi camera patterns',
|
||||
})
|
||||
threats.append(
|
||||
{
|
||||
"type": "hidden_camera",
|
||||
"severity": get_threat_severity("hidden_camera", {"signal_strength": signal}),
|
||||
"reason": "Device matches WiFi camera patterns",
|
||||
}
|
||||
)
|
||||
|
||||
# Check for hidden SSID with strong signal - use standardized classification
|
||||
try:
|
||||
@@ -434,31 +450,33 @@ class ThreatDetector:
|
||||
signal_int = -100
|
||||
|
||||
signal_info = get_signal_strength_info(signal_int)
|
||||
if not ssid and signal_info['strength'] in ('strong', 'very_strong'):
|
||||
threats.append({
|
||||
'type': 'anomaly',
|
||||
'severity': 'medium',
|
||||
'reason': f"Hidden SSID with {signal_info['label'].lower()} signal",
|
||||
})
|
||||
if not ssid and signal_info["strength"] in ("strong", "very_strong"):
|
||||
threats.append(
|
||||
{
|
||||
"type": "anomaly",
|
||||
"severity": "medium",
|
||||
"reason": f"Hidden SSID with {signal_info['label'].lower()} signal",
|
||||
}
|
||||
)
|
||||
|
||||
if not threats:
|
||||
return None
|
||||
|
||||
# Return highest severity threat
|
||||
threats.sort(key=lambda t: ['low', 'medium', 'high', 'critical'].index(t['severity']), reverse=True)
|
||||
threats.sort(key=lambda t: ["low", "medium", "high", "critical"].index(t["severity"]), reverse=True)
|
||||
|
||||
return {
|
||||
'threat_type': threats[0]['type'],
|
||||
'severity': threats[0]['severity'],
|
||||
'source': 'wifi',
|
||||
'identifier': mac,
|
||||
'name': ssid or 'Hidden Network',
|
||||
'signal_strength': signal,
|
||||
'details': {
|
||||
'all_threats': threats,
|
||||
'vendor': vendor,
|
||||
'ssid': ssid,
|
||||
}
|
||||
"threat_type": threats[0]["type"],
|
||||
"severity": threats[0]["severity"],
|
||||
"source": "wifi",
|
||||
"identifier": mac,
|
||||
"name": ssid or "Hidden Network",
|
||||
"signal_strength": signal,
|
||||
"details": {
|
||||
"all_threats": threats,
|
||||
"vendor": vendor,
|
||||
"ssid": ssid,
|
||||
},
|
||||
}
|
||||
|
||||
def analyze_bt_device(self, device: dict) -> dict | None:
|
||||
@@ -471,46 +489,52 @@ class ThreatDetector:
|
||||
Returns:
|
||||
Threat dict if threat detected, None otherwise
|
||||
"""
|
||||
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')
|
||||
tracker_data = device.get('tracker', {}) or {}
|
||||
|
||||
threats = []
|
||||
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")
|
||||
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:
|
||||
threats.append({
|
||||
'type': 'new_device',
|
||||
'severity': get_threat_severity('new_device', {'signal_strength': rssi}),
|
||||
'reason': 'Device not present in baseline',
|
||||
})
|
||||
threats.append(
|
||||
{
|
||||
"type": "new_device",
|
||||
"severity": get_threat_severity("new_device", {"signal_strength": rssi}),
|
||||
"reason": "Device not present in baseline",
|
||||
}
|
||||
)
|
||||
|
||||
# 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'),
|
||||
})
|
||||
# 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"),
|
||||
}
|
||||
)
|
||||
|
||||
# Check for suspicious BLE beacons (unnamed, persistent) - use standardized classification
|
||||
try:
|
||||
@@ -519,31 +543,33 @@ class ThreatDetector:
|
||||
rssi_int = -100
|
||||
|
||||
signal_info = get_signal_strength_info(rssi_int)
|
||||
if not name and signal_info['strength'] in ('moderate', 'strong', 'very_strong'):
|
||||
threats.append({
|
||||
'type': 'anomaly',
|
||||
'severity': 'medium',
|
||||
'reason': f"Unnamed BLE device with {signal_info['label'].lower()} signal",
|
||||
})
|
||||
if not name and signal_info["strength"] in ("moderate", "strong", "very_strong"):
|
||||
threats.append(
|
||||
{
|
||||
"type": "anomaly",
|
||||
"severity": "medium",
|
||||
"reason": f"Unnamed BLE device with {signal_info['label'].lower()} signal",
|
||||
}
|
||||
)
|
||||
|
||||
if not threats:
|
||||
return None
|
||||
|
||||
# Return highest severity threat
|
||||
threats.sort(key=lambda t: ['low', 'medium', 'high', 'critical'].index(t['severity']), reverse=True)
|
||||
threats.sort(key=lambda t: ["low", "medium", "high", "critical"].index(t["severity"]), reverse=True)
|
||||
|
||||
return {
|
||||
'threat_type': threats[0]['type'],
|
||||
'severity': threats[0]['severity'],
|
||||
'source': 'bluetooth',
|
||||
'identifier': mac,
|
||||
'name': name or 'Unknown BLE Device',
|
||||
'signal_strength': rssi,
|
||||
'details': {
|
||||
'all_threats': threats,
|
||||
'manufacturer': manufacturer,
|
||||
'device_type': device_type,
|
||||
}
|
||||
"threat_type": threats[0]["type"],
|
||||
"severity": threats[0]["severity"],
|
||||
"source": "bluetooth",
|
||||
"identifier": mac,
|
||||
"name": name or "Unknown BLE Device",
|
||||
"signal_strength": rssi,
|
||||
"details": {
|
||||
"all_threats": threats,
|
||||
"manufacturer": manufacturer,
|
||||
"device_type": device_type,
|
||||
},
|
||||
}
|
||||
|
||||
def analyze_rf_signal(self, signal: dict) -> dict | None:
|
||||
@@ -556,9 +582,9 @@ class ThreatDetector:
|
||||
Returns:
|
||||
Threat dict if threat detected, None otherwise
|
||||
"""
|
||||
frequency = signal.get('frequency', 0)
|
||||
level = signal.get('level', signal.get('power', -100))
|
||||
modulation = signal.get('modulation', '')
|
||||
frequency = signal.get("frequency", 0)
|
||||
level = signal.get("level", signal.get("power", -100))
|
||||
modulation = signal.get("modulation", "")
|
||||
|
||||
if not frequency:
|
||||
return None
|
||||
@@ -569,47 +595,51 @@ class ThreatDetector:
|
||||
# Check if new frequency (not in baseline)
|
||||
if self.baseline and freq_rounded not in self.baseline_rf_freqs:
|
||||
risk, band_name = get_frequency_risk(frequency)
|
||||
threats.append({
|
||||
'type': 'unknown_signal',
|
||||
'severity': risk,
|
||||
'reason': f'New signal in {band_name}',
|
||||
})
|
||||
threats.append(
|
||||
{
|
||||
"type": "unknown_signal",
|
||||
"severity": risk,
|
||||
"reason": f"New signal in {band_name}",
|
||||
}
|
||||
)
|
||||
|
||||
# Check frequency risk even without baseline
|
||||
risk, band_name = get_frequency_risk(frequency)
|
||||
if risk in ['high', 'critical']:
|
||||
threats.append({
|
||||
'type': 'unknown_signal',
|
||||
'severity': risk,
|
||||
'reason': f'Signal in high-risk band: {band_name}',
|
||||
})
|
||||
if risk in ["high", "critical"]:
|
||||
threats.append(
|
||||
{
|
||||
"type": "unknown_signal",
|
||||
"severity": risk,
|
||||
"reason": f"Signal in high-risk band: {band_name}",
|
||||
}
|
||||
)
|
||||
|
||||
if not threats:
|
||||
return None
|
||||
|
||||
# Return highest severity threat
|
||||
threats.sort(key=lambda t: ['low', 'medium', 'high', 'critical'].index(t['severity']), reverse=True)
|
||||
threats.sort(key=lambda t: ["low", "medium", "high", "critical"].index(t["severity"]), reverse=True)
|
||||
|
||||
return {
|
||||
'threat_type': threats[0]['type'],
|
||||
'severity': threats[0]['severity'],
|
||||
'source': 'rf',
|
||||
'identifier': f'{frequency:.3f} MHz',
|
||||
'name': f'RF Signal @ {frequency:.3f} MHz',
|
||||
'signal_strength': level,
|
||||
'frequency': frequency,
|
||||
'details': {
|
||||
'all_threats': threats,
|
||||
'modulation': modulation,
|
||||
'band_name': band_name,
|
||||
}
|
||||
"threat_type": threats[0]["type"],
|
||||
"severity": threats[0]["severity"],
|
||||
"source": "rf",
|
||||
"identifier": f"{frequency:.3f} MHz",
|
||||
"name": f"RF Signal @ {frequency:.3f} MHz",
|
||||
"signal_strength": level,
|
||||
"frequency": frequency,
|
||||
"details": {
|
||||
"all_threats": threats,
|
||||
"modulation": modulation,
|
||||
"band_name": band_name,
|
||||
},
|
||||
}
|
||||
|
||||
def analyze_all(
|
||||
self,
|
||||
wifi_devices: list[dict] | None = None,
|
||||
bt_devices: list[dict] | None = None,
|
||||
rf_signals: list[dict] | None = None
|
||||
rf_signals: list[dict] | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Analyze all provided devices and signals for threats.
|
||||
@@ -638,17 +668,13 @@ class ThreatDetector:
|
||||
threats.append(threat)
|
||||
|
||||
# Sort by severity (critical first)
|
||||
severity_order = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3}
|
||||
threats.sort(key=lambda t: severity_order.get(t.get('severity', 'low'), 3))
|
||||
severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
||||
threats.sort(key=lambda t: severity_order.get(t.get("severity", "low"), 3))
|
||||
|
||||
return threats
|
||||
|
||||
|
||||
def classify_device_threat(
|
||||
source: str,
|
||||
device: dict,
|
||||
baseline: dict | None = None
|
||||
) -> dict | None:
|
||||
def classify_device_threat(source: str, device: dict, baseline: dict | None = None) -> dict | None:
|
||||
"""
|
||||
Convenience function to classify a single device.
|
||||
|
||||
@@ -662,11 +688,11 @@ def classify_device_threat(
|
||||
"""
|
||||
detector = ThreatDetector(baseline)
|
||||
|
||||
if source == 'wifi':
|
||||
if source == "wifi":
|
||||
return detector.analyze_wifi_device(device)
|
||||
elif source == 'bluetooth':
|
||||
elif source == "bluetooth":
|
||||
return detector.analyze_bt_device(device)
|
||||
elif source == 'rf':
|
||||
elif source == "rf":
|
||||
return detector.analyze_rf_signal(device)
|
||||
|
||||
return None
|
||||
|
||||
+250
-266
@@ -32,7 +32,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
|
||||
logger = logging.getLogger('intercept.tscm.device_identity')
|
||||
logger = logging.getLogger("intercept.tscm.device_identity")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -40,8 +40,8 @@ logger = logging.getLogger('intercept.tscm.device_identity')
|
||||
# =============================================================================
|
||||
|
||||
# Session gap thresholds (seconds)
|
||||
BLE_SESSION_GAP = 60 # New session if no observations for 60s
|
||||
WIFI_SESSION_GAP = 120 # WiFi clients may probe less frequently
|
||||
BLE_SESSION_GAP = 60 # New session if no observations for 60s
|
||||
WIFI_SESSION_GAP = 120 # WiFi clients may probe less frequently
|
||||
|
||||
# Clustering thresholds
|
||||
MIN_CLUSTER_CONFIDENCE = 0.3 # Minimum confidence to consider clustering
|
||||
@@ -56,64 +56,70 @@ TEMPORAL_CORRELATION_WINDOW = timedelta(seconds=5)
|
||||
|
||||
# Fingerprint weights (sum to 1.0 for normalization)
|
||||
FINGERPRINT_WEIGHTS = {
|
||||
'manufacturer_data': 0.25,
|
||||
'service_uuids': 0.20,
|
||||
'capabilities': 0.15,
|
||||
'payload_structure': 0.15,
|
||||
'timing_pattern': 0.10,
|
||||
'rssi_trajectory': 0.10,
|
||||
'name_similarity': 0.05,
|
||||
"manufacturer_data": 0.25,
|
||||
"service_uuids": 0.20,
|
||||
"capabilities": 0.15,
|
||||
"payload_structure": 0.15,
|
||||
"timing_pattern": 0.10,
|
||||
"rssi_trajectory": 0.10,
|
||||
"name_similarity": 0.05,
|
||||
}
|
||||
|
||||
|
||||
class AddressType(Enum):
|
||||
"""BLE address types per Bluetooth spec."""
|
||||
PUBLIC = 'public'
|
||||
RANDOM_STATIC = 'random_static'
|
||||
RPA = 'rpa' # Resolvable Private Address
|
||||
NRPA = 'nrpa' # Non-Resolvable Private Address
|
||||
UNKNOWN = 'unknown'
|
||||
|
||||
PUBLIC = "public"
|
||||
RANDOM_STATIC = "random_static"
|
||||
RPA = "rpa" # Resolvable Private Address
|
||||
NRPA = "nrpa" # Non-Resolvable Private Address
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class AdvType(Enum):
|
||||
"""BLE advertisement types."""
|
||||
ADV_IND = 'ADV_IND'
|
||||
ADV_DIRECT_IND = 'ADV_DIRECT_IND'
|
||||
ADV_NONCONN_IND = 'ADV_NONCONN_IND'
|
||||
ADV_SCAN_IND = 'ADV_SCAN_IND'
|
||||
SCAN_RSP = 'SCAN_RSP'
|
||||
UNKNOWN = 'unknown'
|
||||
|
||||
ADV_IND = "ADV_IND"
|
||||
ADV_DIRECT_IND = "ADV_DIRECT_IND"
|
||||
ADV_NONCONN_IND = "ADV_NONCONN_IND"
|
||||
ADV_SCAN_IND = "ADV_SCAN_IND"
|
||||
SCAN_RSP = "SCAN_RSP"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class WifiFrameType(Enum):
|
||||
"""WiFi frame types of interest."""
|
||||
BEACON = 'beacon'
|
||||
PROBE_REQUEST = 'probe_request'
|
||||
PROBE_RESPONSE = 'probe_response'
|
||||
AUTH = 'auth'
|
||||
ASSOC_REQUEST = 'assoc_request'
|
||||
ASSOC_RESPONSE = 'assoc_response'
|
||||
DEAUTH = 'deauth'
|
||||
DISASSOC = 'disassoc'
|
||||
DATA = 'data'
|
||||
UNKNOWN = 'unknown'
|
||||
|
||||
BEACON = "beacon"
|
||||
PROBE_REQUEST = "probe_request"
|
||||
PROBE_RESPONSE = "probe_response"
|
||||
AUTH = "auth"
|
||||
ASSOC_REQUEST = "assoc_request"
|
||||
ASSOC_RESPONSE = "assoc_response"
|
||||
DEAUTH = "deauth"
|
||||
DISASSOC = "disassoc"
|
||||
DATA = "data"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class RiskLevel(Enum):
|
||||
"""TSCM risk levels for device clusters."""
|
||||
INFORMATIONAL = 'informational'
|
||||
LOW = 'low'
|
||||
MEDIUM = 'medium'
|
||||
HIGH = 'high'
|
||||
|
||||
INFORMATIONAL = "informational"
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Observation Data Classes
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class BLEObservation:
|
||||
"""Single BLE advertisement observation."""
|
||||
|
||||
timestamp: datetime
|
||||
addr: str # MAC-like address
|
||||
addr_type: AddressType = AddressType.UNKNOWN
|
||||
@@ -189,7 +195,7 @@ class BLEObservation:
|
||||
# Check MAC address format for random bit
|
||||
# Bit 1 of first octet set = locally administered (random)
|
||||
try:
|
||||
first_octet = int(self.addr.split(':')[0], 16)
|
||||
first_octet = int(self.addr.split(":")[0], 16)
|
||||
return bool(first_octet & 0x02)
|
||||
except (ValueError, IndexError):
|
||||
return False
|
||||
@@ -198,6 +204,7 @@ class BLEObservation:
|
||||
@dataclass
|
||||
class WifiObservation:
|
||||
"""Single WiFi frame observation."""
|
||||
|
||||
timestamp: datetime
|
||||
src_mac: str
|
||||
dst_mac: str | None = None
|
||||
@@ -245,11 +252,11 @@ class WifiObservation:
|
||||
# Capability fingerprint
|
||||
caps = []
|
||||
if self.ht_capable:
|
||||
caps.append('HT')
|
||||
caps.append("HT")
|
||||
if self.vht_capable:
|
||||
caps.append('VHT')
|
||||
caps.append("VHT")
|
||||
if self.he_capable:
|
||||
caps.append('HE')
|
||||
caps.append("HE")
|
||||
if caps:
|
||||
components.append(f"caps:{'+'.join(caps)}")
|
||||
|
||||
@@ -276,7 +283,7 @@ class WifiObservation:
|
||||
def is_randomized_address(self) -> bool:
|
||||
"""Check if source MAC appears to be randomized."""
|
||||
try:
|
||||
first_octet = int(self.src_mac.split(':')[0], 16)
|
||||
first_octet = int(self.src_mac.split(":")[0], 16)
|
||||
return bool(first_octet & 0x02)
|
||||
except (ValueError, IndexError):
|
||||
return False
|
||||
@@ -286,6 +293,7 @@ class WifiObservation:
|
||||
# Session and Cluster Data Classes
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceSession:
|
||||
"""
|
||||
@@ -294,6 +302,7 @@ class DeviceSession:
|
||||
Multiple observations from the same MAC (or clustered identity) within
|
||||
the session gap threshold belong to the same session.
|
||||
"""
|
||||
|
||||
session_id: str
|
||||
protocol: str # 'ble' or 'wifi'
|
||||
first_seen: datetime
|
||||
@@ -312,11 +321,11 @@ class DeviceSession:
|
||||
self.observations.append(obs)
|
||||
self.last_seen = obs.timestamp
|
||||
|
||||
if hasattr(obs, 'addr'):
|
||||
if hasattr(obs, "addr"):
|
||||
self.observed_macs.add(obs.addr)
|
||||
if self.primary_mac is None:
|
||||
self.primary_mac = obs.addr
|
||||
elif hasattr(obs, 'src_mac'):
|
||||
elif hasattr(obs, "src_mac"):
|
||||
self.observed_macs.add(obs.src_mac)
|
||||
if self.primary_mac is None:
|
||||
self.primary_mac = obs.src_mac
|
||||
@@ -369,24 +378,25 @@ class DeviceSession:
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
'session_id': self.session_id,
|
||||
'protocol': self.protocol,
|
||||
'first_seen': self.first_seen.isoformat(),
|
||||
'last_seen': self.last_seen.isoformat(),
|
||||
'duration_seconds': self.get_duration().total_seconds(),
|
||||
'observation_count': len(self.observations),
|
||||
'primary_mac': self.primary_mac,
|
||||
'observed_macs': list(self.observed_macs),
|
||||
'fingerprint_hashes': list(self.fingerprint_hashes),
|
||||
'mean_rssi': self.get_mean_rssi(),
|
||||
'rssi_stability': self.get_rssi_stability(),
|
||||
'mean_interval': self.get_mean_interval(),
|
||||
"session_id": self.session_id,
|
||||
"protocol": self.protocol,
|
||||
"first_seen": self.first_seen.isoformat(),
|
||||
"last_seen": self.last_seen.isoformat(),
|
||||
"duration_seconds": self.get_duration().total_seconds(),
|
||||
"observation_count": len(self.observations),
|
||||
"primary_mac": self.primary_mac,
|
||||
"observed_macs": list(self.observed_macs),
|
||||
"fingerprint_hashes": list(self.fingerprint_hashes),
|
||||
"mean_rssi": self.get_mean_rssi(),
|
||||
"rssi_stability": self.get_rssi_stability(),
|
||||
"mean_interval": self.get_mean_interval(),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RiskIndicator:
|
||||
"""A TSCM risk indicator for a device cluster."""
|
||||
|
||||
indicator_type: str
|
||||
description: str
|
||||
score: int # 0-10
|
||||
@@ -395,11 +405,11 @@ class RiskIndicator:
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'type': self.indicator_type,
|
||||
'description': self.description,
|
||||
'score': self.score,
|
||||
'evidence': self.evidence,
|
||||
'timestamp': self.timestamp.isoformat(),
|
||||
"type": self.indicator_type,
|
||||
"description": self.description,
|
||||
"score": self.score,
|
||||
"evidence": self.evidence,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@@ -411,6 +421,7 @@ class DeviceCluster:
|
||||
Multiple sessions and MACs may be linked to the same cluster based
|
||||
on fingerprint similarity, temporal correlation, and RSSI patterns.
|
||||
"""
|
||||
|
||||
cluster_id: str
|
||||
protocol: str
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
@@ -441,8 +452,7 @@ class DeviceCluster:
|
||||
last_seen: datetime | None = None
|
||||
presence_ratio: float = 0.0 # % of monitoring period device was present
|
||||
|
||||
def add_session(self, session: DeviceSession, link_reason: str,
|
||||
link_confidence: float) -> None:
|
||||
def add_session(self, session: DeviceSession, link_reason: str, link_confidence: float) -> None:
|
||||
"""Add a session to this cluster with linking evidence."""
|
||||
self.sessions.append(session)
|
||||
self.linked_macs.update(session.observed_macs)
|
||||
@@ -455,18 +465,18 @@ class DeviceCluster:
|
||||
if self.last_seen is None or session.last_seen > self.last_seen:
|
||||
self.last_seen = session.last_seen
|
||||
|
||||
self.link_evidence.append({
|
||||
'session_id': session.session_id,
|
||||
'reason': link_reason,
|
||||
'confidence': link_confidence,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
})
|
||||
self.link_evidence.append(
|
||||
{
|
||||
"session_id": session.session_id,
|
||||
"reason": link_reason,
|
||||
"confidence": link_confidence,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
# Update overall confidence (weighted average)
|
||||
if self.link_evidence:
|
||||
self.confidence = statistics.mean(
|
||||
e['confidence'] for e in self.link_evidence
|
||||
)
|
||||
self.confidence = statistics.mean(e["confidence"] for e in self.link_evidence)
|
||||
|
||||
def add_risk_indicator(self, indicator: RiskIndicator) -> None:
|
||||
"""Add a risk indicator and update risk assessment."""
|
||||
@@ -493,27 +503,27 @@ class DeviceCluster:
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
'cluster_id': self.cluster_id,
|
||||
'protocol': self.protocol,
|
||||
'created_at': self.created_at.isoformat(),
|
||||
'updated_at': self.updated_at.isoformat(),
|
||||
'confidence': round(self.confidence, 3),
|
||||
'session_count': len(self.sessions),
|
||||
'linked_macs': list(self.linked_macs),
|
||||
'fingerprint_hashes': list(self.fingerprint_hashes),
|
||||
'best_name': self.best_name,
|
||||
'manufacturer_id': self.manufacturer_id,
|
||||
'manufacturer_name': self.manufacturer_name,
|
||||
'device_type': self.device_type,
|
||||
'risk_level': self.risk_level.value,
|
||||
'risk_score': self.risk_score,
|
||||
'risk_indicators': [i.to_dict() for i in self.risk_indicators],
|
||||
'total_observations': self.total_observations,
|
||||
'first_seen': self.first_seen.isoformat() if self.first_seen else None,
|
||||
'last_seen': self.last_seen.isoformat() if self.last_seen else None,
|
||||
'presence_ratio': round(self.presence_ratio, 3),
|
||||
'link_evidence': self.link_evidence,
|
||||
'sessions': [s.to_dict() for s in self.sessions],
|
||||
"cluster_id": self.cluster_id,
|
||||
"protocol": self.protocol,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"confidence": round(self.confidence, 3),
|
||||
"session_count": len(self.sessions),
|
||||
"linked_macs": list(self.linked_macs),
|
||||
"fingerprint_hashes": list(self.fingerprint_hashes),
|
||||
"best_name": self.best_name,
|
||||
"manufacturer_id": self.manufacturer_id,
|
||||
"manufacturer_name": self.manufacturer_name,
|
||||
"device_type": self.device_type,
|
||||
"risk_level": self.risk_level.value,
|
||||
"risk_score": self.risk_score,
|
||||
"risk_indicators": [i.to_dict() for i in self.risk_indicators],
|
||||
"total_observations": self.total_observations,
|
||||
"first_seen": self.first_seen.isoformat() if self.first_seen else None,
|
||||
"last_seen": self.last_seen.isoformat() if self.last_seen else None,
|
||||
"presence_ratio": round(self.presence_ratio, 3),
|
||||
"link_evidence": self.link_evidence,
|
||||
"sessions": [s.to_dict() for s in self.sessions],
|
||||
}
|
||||
|
||||
|
||||
@@ -521,6 +531,7 @@ class DeviceCluster:
|
||||
# Fingerprint Similarity Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def jaccard_similarity(set1: set, set2: set) -> float:
|
||||
"""Calculate Jaccard similarity between two sets."""
|
||||
if not set1 and not set2:
|
||||
@@ -530,8 +541,7 @@ def jaccard_similarity(set1: set, set2: set) -> float:
|
||||
return intersection / union if union > 0 else 0.0
|
||||
|
||||
|
||||
def manufacturer_data_similarity(data1: bytes | None,
|
||||
data2: bytes | None) -> float:
|
||||
def manufacturer_data_similarity(data1: bytes | None, data2: bytes | None) -> float:
|
||||
"""
|
||||
Calculate similarity between manufacturer data blobs.
|
||||
|
||||
@@ -546,9 +556,7 @@ def manufacturer_data_similarity(data1: bytes | None,
|
||||
|
||||
# Compare common prefix (often contains device type info)
|
||||
prefix_len = min(8, len(data1), len(data2))
|
||||
prefix_match = sum(
|
||||
1 for i in range(prefix_len) if data1[i] == data2[i]
|
||||
) / prefix_len if prefix_len > 0 else 0.0
|
||||
prefix_match = sum(1 for i in range(prefix_len) if data1[i] == data2[i]) / prefix_len if prefix_len > 0 else 0.0
|
||||
|
||||
# Compare full content via byte-level similarity
|
||||
min_len = min(len(data1), len(data2))
|
||||
@@ -559,9 +567,7 @@ def manufacturer_data_similarity(data1: bytes | None,
|
||||
return 0.5 * prefix_match + 0.3 * content_sim + 0.2 * len_sim
|
||||
|
||||
|
||||
def rssi_trajectory_similarity(samples1: list[int],
|
||||
samples2: list[int],
|
||||
time_window: float = 5.0) -> float:
|
||||
def rssi_trajectory_similarity(samples1: list[int], samples2: list[int], time_window: float = 5.0) -> float:
|
||||
"""
|
||||
Calculate RSSI trajectory similarity.
|
||||
|
||||
@@ -594,8 +600,7 @@ def rssi_trajectory_similarity(samples1: list[int],
|
||||
return 0.6 * mean_sim + 0.4 * var_sim
|
||||
|
||||
|
||||
def timing_pattern_similarity(intervals1: list[float],
|
||||
intervals2: list[float]) -> float:
|
||||
def timing_pattern_similarity(intervals1: list[float], intervals2: list[float]) -> float:
|
||||
"""
|
||||
Calculate advertising/probing interval similarity.
|
||||
|
||||
@@ -650,6 +655,7 @@ def name_similarity(name1: str | None, name2: str | None) -> float:
|
||||
# Device Identity Engine
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class DeviceIdentityEngine:
|
||||
"""
|
||||
Main engine for MAC-randomization resistant device detection.
|
||||
@@ -720,8 +726,8 @@ class DeviceIdentityEngine:
|
||||
def _create_ble_session(self, obs: BLEObservation) -> DeviceSession:
|
||||
"""Create a new BLE session from initial observation."""
|
||||
session = DeviceSession(
|
||||
session_id=self._generate_session_id('ble'),
|
||||
protocol='ble',
|
||||
session_id=self._generate_session_id("ble"),
|
||||
protocol="ble",
|
||||
first_seen=obs.timestamp,
|
||||
last_seen=obs.timestamp,
|
||||
)
|
||||
@@ -762,8 +768,8 @@ class DeviceIdentityEngine:
|
||||
def _create_wifi_session(self, obs: WifiObservation) -> DeviceSession:
|
||||
"""Create a new WiFi session from initial observation."""
|
||||
session = DeviceSession(
|
||||
session_id=self._generate_session_id('wifi'),
|
||||
protocol='wifi',
|
||||
session_id=self._generate_session_id("wifi"),
|
||||
protocol="wifi",
|
||||
first_seen=obs.timestamp,
|
||||
last_seen=obs.timestamp,
|
||||
)
|
||||
@@ -778,11 +784,7 @@ class DeviceIdentityEngine:
|
||||
if cluster:
|
||||
# Add to existing cluster
|
||||
similarity = self._calculate_cluster_similarity(cluster, session)
|
||||
cluster.add_session(
|
||||
session,
|
||||
link_reason="Fingerprint/behavioral match",
|
||||
link_confidence=similarity
|
||||
)
|
||||
cluster.add_session(session, link_reason="Fingerprint/behavioral match", link_confidence=similarity)
|
||||
else:
|
||||
# Create new cluster
|
||||
cluster = self._create_cluster_from_session(session)
|
||||
@@ -811,8 +813,7 @@ class DeviceIdentityEngine:
|
||||
|
||||
return best_match
|
||||
|
||||
def _calculate_cluster_similarity(self, cluster: DeviceCluster,
|
||||
session: DeviceSession) -> float:
|
||||
def _calculate_cluster_similarity(self, cluster: DeviceCluster, session: DeviceSession) -> float:
|
||||
"""
|
||||
Calculate similarity between a cluster and a session.
|
||||
|
||||
@@ -823,48 +824,35 @@ class DeviceIdentityEngine:
|
||||
# 1. Fingerprint hash matching (strongest signal)
|
||||
fp_overlap = cluster.fingerprint_hashes & session.fingerprint_hashes
|
||||
if fp_overlap:
|
||||
fp_score = len(fp_overlap) / max(
|
||||
len(cluster.fingerprint_hashes),
|
||||
len(session.fingerprint_hashes)
|
||||
)
|
||||
scores['fingerprint'] = min(1.0, fp_score * 1.5) # Boost for exact match
|
||||
fp_score = len(fp_overlap) / max(len(cluster.fingerprint_hashes), len(session.fingerprint_hashes))
|
||||
scores["fingerprint"] = min(1.0, fp_score * 1.5) # Boost for exact match
|
||||
|
||||
# 2. Manufacturer data similarity
|
||||
cluster_mfg_data = self._get_cluster_manufacturer_data(cluster)
|
||||
session_mfg_data = self._get_session_manufacturer_data(session)
|
||||
if cluster_mfg_data and session_mfg_data:
|
||||
scores['manufacturer_data'] = manufacturer_data_similarity(
|
||||
cluster_mfg_data, session_mfg_data
|
||||
)
|
||||
scores["manufacturer_data"] = manufacturer_data_similarity(cluster_mfg_data, session_mfg_data)
|
||||
|
||||
# 3. Service UUID overlap
|
||||
cluster_uuids = self._get_cluster_service_uuids(cluster)
|
||||
session_uuids = self._get_session_service_uuids(session)
|
||||
if cluster_uuids or session_uuids:
|
||||
scores['service_uuids'] = jaccard_similarity(
|
||||
cluster_uuids, session_uuids
|
||||
)
|
||||
scores["service_uuids"] = jaccard_similarity(cluster_uuids, session_uuids)
|
||||
|
||||
# 4. RSSI trajectory similarity
|
||||
cluster_rssi = cluster.get_all_rssi_samples()
|
||||
if cluster_rssi and session.rssi_samples:
|
||||
scores['rssi_trajectory'] = rssi_trajectory_similarity(
|
||||
cluster_rssi, session.rssi_samples
|
||||
)
|
||||
scores["rssi_trajectory"] = rssi_trajectory_similarity(cluster_rssi, session.rssi_samples)
|
||||
|
||||
# 5. Timing pattern similarity
|
||||
cluster_intervals = self._get_cluster_intervals(cluster)
|
||||
if cluster_intervals and session.observation_intervals:
|
||||
scores['timing_pattern'] = timing_pattern_similarity(
|
||||
cluster_intervals, session.observation_intervals
|
||||
)
|
||||
scores["timing_pattern"] = timing_pattern_similarity(cluster_intervals, session.observation_intervals)
|
||||
|
||||
# 6. Name similarity
|
||||
session_name = self._get_session_name(session)
|
||||
if cluster.best_name and session_name:
|
||||
scores['name_similarity'] = name_similarity(
|
||||
cluster.best_name, session_name
|
||||
)
|
||||
scores["name_similarity"] = name_similarity(cluster.best_name, session_name)
|
||||
|
||||
if not scores:
|
||||
return 0.0
|
||||
@@ -884,14 +872,14 @@ class DeviceIdentityEngine:
|
||||
"""Get representative manufacturer data from cluster."""
|
||||
for session in cluster.sessions:
|
||||
for obs in session.observations:
|
||||
if hasattr(obs, 'manufacturer_data') and obs.manufacturer_data:
|
||||
if hasattr(obs, "manufacturer_data") and obs.manufacturer_data:
|
||||
return obs.manufacturer_data
|
||||
return None
|
||||
|
||||
def _get_session_manufacturer_data(self, session: DeviceSession) -> bytes | None:
|
||||
"""Get manufacturer data from session."""
|
||||
for obs in session.observations:
|
||||
if hasattr(obs, 'manufacturer_data') and obs.manufacturer_data:
|
||||
if hasattr(obs, "manufacturer_data") and obs.manufacturer_data:
|
||||
return obs.manufacturer_data
|
||||
return None
|
||||
|
||||
@@ -900,7 +888,7 @@ class DeviceIdentityEngine:
|
||||
uuids = set()
|
||||
for session in cluster.sessions:
|
||||
for obs in session.observations:
|
||||
if hasattr(obs, 'service_uuids') and obs.service_uuids:
|
||||
if hasattr(obs, "service_uuids") and obs.service_uuids:
|
||||
uuids.update(obs.service_uuids)
|
||||
return uuids
|
||||
|
||||
@@ -908,7 +896,7 @@ class DeviceIdentityEngine:
|
||||
"""Get service UUIDs from session."""
|
||||
uuids = set()
|
||||
for obs in session.observations:
|
||||
if hasattr(obs, 'service_uuids') and obs.service_uuids:
|
||||
if hasattr(obs, "service_uuids") and obs.service_uuids:
|
||||
uuids.update(obs.service_uuids)
|
||||
return uuids
|
||||
|
||||
@@ -922,7 +910,7 @@ class DeviceIdentityEngine:
|
||||
def _get_session_name(self, session: DeviceSession) -> str | None:
|
||||
"""Get device name from session."""
|
||||
for obs in session.observations:
|
||||
if hasattr(obs, 'local_name') and obs.local_name:
|
||||
if hasattr(obs, "local_name") and obs.local_name:
|
||||
return obs.local_name
|
||||
return None
|
||||
|
||||
@@ -933,17 +921,13 @@ class DeviceIdentityEngine:
|
||||
protocol=session.protocol,
|
||||
)
|
||||
|
||||
cluster.add_session(
|
||||
session,
|
||||
link_reason="Initial session",
|
||||
link_confidence=1.0
|
||||
)
|
||||
cluster.add_session(session, link_reason="Initial session", link_confidence=1.0)
|
||||
|
||||
# Extract identifying information
|
||||
for obs in session.observations:
|
||||
if hasattr(obs, 'local_name') and obs.local_name:
|
||||
if hasattr(obs, "local_name") and obs.local_name:
|
||||
cluster.best_name = obs.local_name
|
||||
if hasattr(obs, 'manufacturer_id') and obs.manufacturer_id:
|
||||
if hasattr(obs, "manufacturer_id") and obs.manufacturer_id:
|
||||
cluster.manufacturer_id = obs.manufacturer_id
|
||||
|
||||
return cluster
|
||||
@@ -969,12 +953,14 @@ class DeviceIdentityEngine:
|
||||
|
||||
# Risk: High presence ratio (device always present)
|
||||
if cluster.presence_ratio > 0.8:
|
||||
cluster.add_risk_indicator(RiskIndicator(
|
||||
indicator_type='high_presence',
|
||||
description='Device present for >80% of monitoring period',
|
||||
score=2,
|
||||
evidence={'presence_ratio': round(cluster.presence_ratio, 2)}
|
||||
))
|
||||
cluster.add_risk_indicator(
|
||||
RiskIndicator(
|
||||
indicator_type="high_presence",
|
||||
description="Device present for >80% of monitoring period",
|
||||
score=2,
|
||||
evidence={"presence_ratio": round(cluster.presence_ratio, 2)},
|
||||
)
|
||||
)
|
||||
|
||||
# Risk: Very stable RSSI (stationary device)
|
||||
rssi_samples = cluster.get_all_rssi_samples()
|
||||
@@ -982,65 +968,69 @@ class DeviceIdentityEngine:
|
||||
try:
|
||||
stdev = statistics.stdev(rssi_samples)
|
||||
if stdev < 3:
|
||||
cluster.add_risk_indicator(RiskIndicator(
|
||||
indicator_type='stable_rssi',
|
||||
description='Very stable signal suggests fixed placement',
|
||||
score=2,
|
||||
evidence={
|
||||
'rssi_stdev': round(stdev, 2),
|
||||
'sample_count': len(rssi_samples)
|
||||
}
|
||||
))
|
||||
cluster.add_risk_indicator(
|
||||
RiskIndicator(
|
||||
indicator_type="stable_rssi",
|
||||
description="Very stable signal suggests fixed placement",
|
||||
score=2,
|
||||
evidence={"rssi_stdev": round(stdev, 2), "sample_count": len(rssi_samples)},
|
||||
)
|
||||
)
|
||||
except statistics.StatisticsError:
|
||||
pass
|
||||
|
||||
# Risk: Multiple MAC addresses observed (MAC rotation)
|
||||
if len(cluster.linked_macs) > 1:
|
||||
cluster.add_risk_indicator(RiskIndicator(
|
||||
indicator_type='mac_rotation',
|
||||
description=f'Multiple MACs ({len(cluster.linked_macs)}) linked to same device',
|
||||
score=1,
|
||||
evidence={'mac_count': len(cluster.linked_macs)}
|
||||
))
|
||||
cluster.add_risk_indicator(
|
||||
RiskIndicator(
|
||||
indicator_type="mac_rotation",
|
||||
description=f"Multiple MACs ({len(cluster.linked_macs)}) linked to same device",
|
||||
score=1,
|
||||
evidence={"mac_count": len(cluster.linked_macs)},
|
||||
)
|
||||
)
|
||||
|
||||
# Risk: Check for suspicious manufacturer IDs
|
||||
if cluster.manufacturer_id:
|
||||
suspicious_mfg = {
|
||||
0x02E5: ('Espressif', 3, 'Programmable ESP32/ESP8266 device'),
|
||||
0x02E5: ("Espressif", 3, "Programmable ESP32/ESP8266 device"),
|
||||
}
|
||||
if cluster.manufacturer_id in suspicious_mfg:
|
||||
name, score, desc = suspicious_mfg[cluster.manufacturer_id]
|
||||
cluster.add_risk_indicator(RiskIndicator(
|
||||
indicator_type='suspicious_chipset',
|
||||
description=desc,
|
||||
score=score,
|
||||
evidence={'manufacturer': name, 'id': hex(cluster.manufacturer_id)}
|
||||
))
|
||||
cluster.add_risk_indicator(
|
||||
RiskIndicator(
|
||||
indicator_type="suspicious_chipset",
|
||||
description=desc,
|
||||
score=score,
|
||||
evidence={"manufacturer": name, "id": hex(cluster.manufacturer_id)},
|
||||
)
|
||||
)
|
||||
|
||||
# Risk: Check for audio-capable services (BLE)
|
||||
audio_service_prefixes = ['0000110', '00001108', '00001203'] # A2DP, Headset, Audio
|
||||
audio_service_prefixes = ["0000110", "00001108", "00001203"] # A2DP, Headset, Audio
|
||||
cluster_uuids = set()
|
||||
for session in cluster.sessions:
|
||||
cluster_uuids.update(self._get_session_service_uuids(session))
|
||||
|
||||
for uuid in cluster_uuids:
|
||||
if any(uuid.lower().startswith(prefix) for prefix in audio_service_prefixes):
|
||||
cluster.add_risk_indicator(RiskIndicator(
|
||||
indicator_type='audio_capable',
|
||||
description='Audio-capable BLE services detected',
|
||||
score=2,
|
||||
evidence={'service_uuid': uuid}
|
||||
))
|
||||
cluster.add_risk_indicator(
|
||||
RiskIndicator(
|
||||
indicator_type="audio_capable",
|
||||
description="Audio-capable BLE services detected",
|
||||
score=2,
|
||||
evidence={"service_uuid": uuid},
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
# Risk: No name advertised (hidden identity)
|
||||
if not cluster.best_name:
|
||||
cluster.add_risk_indicator(RiskIndicator(
|
||||
indicator_type='no_name',
|
||||
description='Device does not advertise a name',
|
||||
score=1,
|
||||
evidence={}
|
||||
))
|
||||
cluster.add_risk_indicator(
|
||||
RiskIndicator(
|
||||
indicator_type="no_name", description="Device does not advertise a name", score=1, evidence={}
|
||||
)
|
||||
)
|
||||
|
||||
# Risk: High observation count relative to duration (aggressive advertising)
|
||||
if cluster.first_seen and cluster.last_seen:
|
||||
@@ -1048,16 +1038,18 @@ class DeviceIdentityEngine:
|
||||
if duration > 60 and cluster.total_observations > 0:
|
||||
obs_rate = cluster.total_observations / duration
|
||||
if obs_rate > 2.0: # More than 2 observations per second
|
||||
cluster.add_risk_indicator(RiskIndicator(
|
||||
indicator_type='high_ad_rate',
|
||||
description='Unusually high advertising rate',
|
||||
score=2,
|
||||
evidence={
|
||||
'rate': round(obs_rate, 2),
|
||||
'observations': cluster.total_observations,
|
||||
'duration': round(duration, 1)
|
||||
}
|
||||
))
|
||||
cluster.add_risk_indicator(
|
||||
RiskIndicator(
|
||||
indicator_type="high_ad_rate",
|
||||
description="Unusually high advertising rate",
|
||||
score=2,
|
||||
evidence={
|
||||
"rate": round(obs_rate, 2),
|
||||
"observations": cluster.total_observations,
|
||||
"duration": round(duration, 1),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def finalize_all_sessions(self) -> None:
|
||||
"""Finalize all active sessions (call at end of monitoring)."""
|
||||
@@ -1068,50 +1060,40 @@ class DeviceIdentityEngine:
|
||||
|
||||
def get_clusters(self, min_confidence: float = 0.0) -> list[DeviceCluster]:
|
||||
"""Get all clusters above minimum confidence."""
|
||||
return [
|
||||
c for c in self.clusters.values()
|
||||
if c.confidence >= min_confidence
|
||||
]
|
||||
return [c for c in self.clusters.values() if c.confidence >= min_confidence]
|
||||
|
||||
def get_high_risk_clusters(self) -> list[DeviceCluster]:
|
||||
"""Get clusters with HIGH risk level."""
|
||||
return [
|
||||
c for c in self.clusters.values()
|
||||
if c.risk_level == RiskLevel.HIGH
|
||||
]
|
||||
return [c for c in self.clusters.values() if c.risk_level == RiskLevel.HIGH]
|
||||
|
||||
def get_summary(self) -> dict:
|
||||
"""Get summary of all clusters and sessions."""
|
||||
clusters_by_risk = {
|
||||
'high': [],
|
||||
'medium': [],
|
||||
'low': [],
|
||||
'informational': []
|
||||
}
|
||||
clusters_by_risk = {"high": [], "medium": [], "low": [], "informational": []}
|
||||
|
||||
for cluster in self.clusters.values():
|
||||
clusters_by_risk[cluster.risk_level.value].append(cluster.to_dict())
|
||||
|
||||
return {
|
||||
'monitoring_period': {
|
||||
'start': self.monitoring_start.isoformat() if self.monitoring_start else None,
|
||||
'end': self.monitoring_end.isoformat() if self.monitoring_end else None,
|
||||
'duration_seconds': (
|
||||
"monitoring_period": {
|
||||
"start": self.monitoring_start.isoformat() if self.monitoring_start else None,
|
||||
"end": self.monitoring_end.isoformat() if self.monitoring_end else None,
|
||||
"duration_seconds": (
|
||||
(self.monitoring_end - self.monitoring_start).total_seconds()
|
||||
if self.monitoring_start and self.monitoring_end else 0
|
||||
)
|
||||
if self.monitoring_start and self.monitoring_end
|
||||
else 0
|
||||
),
|
||||
},
|
||||
'statistics': {
|
||||
'total_clusters': len(self.clusters),
|
||||
'ble_sessions': len(self.ble_sessions),
|
||||
'wifi_sessions': len(self.wifi_sessions),
|
||||
'high_risk_count': len(clusters_by_risk['high']),
|
||||
'medium_risk_count': len(clusters_by_risk['medium']),
|
||||
'low_risk_count': len(clusters_by_risk['low']),
|
||||
'unique_fingerprints': len(self._fingerprint_to_sessions),
|
||||
"statistics": {
|
||||
"total_clusters": len(self.clusters),
|
||||
"ble_sessions": len(self.ble_sessions),
|
||||
"wifi_sessions": len(self.wifi_sessions),
|
||||
"high_risk_count": len(clusters_by_risk["high"]),
|
||||
"medium_risk_count": len(clusters_by_risk["medium"]),
|
||||
"low_risk_count": len(clusters_by_risk["low"]),
|
||||
"unique_fingerprints": len(self._fingerprint_to_sessions),
|
||||
},
|
||||
'clusters_by_risk': clusters_by_risk,
|
||||
'disclaimer': (
|
||||
"clusters_by_risk": clusters_by_risk,
|
||||
"disclaimer": (
|
||||
"Device clustering uses passive fingerprinting and statistical correlation. "
|
||||
"Results indicate probable device identities, NOT confirmed matches. "
|
||||
"Confidence scores reflect similarity measures, not certainty. "
|
||||
@@ -1167,7 +1149,7 @@ def _convert_to_bytes(value) -> bytes | None:
|
||||
return bytes.fromhex(value)
|
||||
except ValueError:
|
||||
# Not a valid hex string, encode as UTF-8
|
||||
return value.encode('utf-8')
|
||||
return value.encode("utf-8")
|
||||
if isinstance(value, (list, tuple)):
|
||||
# Array of integers (like dbus.Array)
|
||||
try:
|
||||
@@ -1184,22 +1166,23 @@ def ingest_ble_dict(data: dict) -> DeviceSession:
|
||||
Convenience function for API integration.
|
||||
"""
|
||||
obs = BLEObservation(
|
||||
timestamp=datetime.fromisoformat(data['timestamp']) if isinstance(data.get('timestamp'), str)
|
||||
else data.get('timestamp', datetime.now()),
|
||||
addr=data.get('addr', data.get('mac', '')).upper(),
|
||||
addr_type=data.get('addr_type', 'unknown'),
|
||||
rssi=data.get('rssi'),
|
||||
tx_power=data.get('tx_power'),
|
||||
adv_type=data.get('adv_type', 'unknown'),
|
||||
adv_flags=data.get('adv_flags'),
|
||||
manufacturer_id=data.get('manufacturer_id'),
|
||||
manufacturer_data=_convert_to_bytes(data.get('manufacturer_data')),
|
||||
service_uuids=data.get('service_uuids', []),
|
||||
service_data=_convert_to_bytes(data.get('service_data')),
|
||||
local_name=data.get('local_name', data.get('name')),
|
||||
appearance=data.get('appearance'),
|
||||
packet_length=data.get('packet_length'),
|
||||
phy=data.get('phy'),
|
||||
timestamp=datetime.fromisoformat(data["timestamp"])
|
||||
if isinstance(data.get("timestamp"), str)
|
||||
else data.get("timestamp", datetime.now()),
|
||||
addr=data.get("addr", data.get("mac", "")).upper(),
|
||||
addr_type=data.get("addr_type", "unknown"),
|
||||
rssi=data.get("rssi"),
|
||||
tx_power=data.get("tx_power"),
|
||||
adv_type=data.get("adv_type", "unknown"),
|
||||
adv_flags=data.get("adv_flags"),
|
||||
manufacturer_id=data.get("manufacturer_id"),
|
||||
manufacturer_data=_convert_to_bytes(data.get("manufacturer_data")),
|
||||
service_uuids=data.get("service_uuids", []),
|
||||
service_data=_convert_to_bytes(data.get("service_data")),
|
||||
local_name=data.get("local_name", data.get("name")),
|
||||
appearance=data.get("appearance"),
|
||||
packet_length=data.get("packet_length"),
|
||||
phy=data.get("phy"),
|
||||
)
|
||||
return get_identity_engine().ingest_ble_observation(obs)
|
||||
|
||||
@@ -1211,29 +1194,30 @@ def ingest_wifi_dict(data: dict) -> DeviceSession:
|
||||
Convenience function for API integration.
|
||||
"""
|
||||
obs = WifiObservation(
|
||||
timestamp=datetime.fromisoformat(data['timestamp']) if isinstance(data.get('timestamp'), str)
|
||||
else data.get('timestamp', datetime.now()),
|
||||
src_mac=data.get('src_mac', data.get('mac', '')).upper(),
|
||||
dst_mac=data.get('dst_mac'),
|
||||
bssid=data.get('bssid'),
|
||||
ssid=data.get('ssid'),
|
||||
frame_type=data.get('frame_type', 'unknown'),
|
||||
rssi=data.get('rssi'),
|
||||
channel=data.get('channel'),
|
||||
bandwidth=data.get('bandwidth'),
|
||||
encryption=data.get('encryption'),
|
||||
beacon_interval=data.get('beacon_interval'),
|
||||
capabilities=data.get('capabilities'),
|
||||
supported_rates=data.get('supported_rates', []),
|
||||
extended_rates=data.get('extended_rates', []),
|
||||
ht_capable=data.get('ht_capable', False),
|
||||
vht_capable=data.get('vht_capable', False),
|
||||
he_capable=data.get('he_capable', False),
|
||||
ht_capabilities=data.get('ht_capabilities'),
|
||||
vht_capabilities=data.get('vht_capabilities'),
|
||||
vendor_ies=data.get('vendor_ies', []),
|
||||
wps_present=data.get('wps_present', False),
|
||||
sequence_number=data.get('sequence_number'),
|
||||
probed_ssids=data.get('probed_ssids', []),
|
||||
timestamp=datetime.fromisoformat(data["timestamp"])
|
||||
if isinstance(data.get("timestamp"), str)
|
||||
else data.get("timestamp", datetime.now()),
|
||||
src_mac=data.get("src_mac", data.get("mac", "")).upper(),
|
||||
dst_mac=data.get("dst_mac"),
|
||||
bssid=data.get("bssid"),
|
||||
ssid=data.get("ssid"),
|
||||
frame_type=data.get("frame_type", "unknown"),
|
||||
rssi=data.get("rssi"),
|
||||
channel=data.get("channel"),
|
||||
bandwidth=data.get("bandwidth"),
|
||||
encryption=data.get("encryption"),
|
||||
beacon_interval=data.get("beacon_interval"),
|
||||
capabilities=data.get("capabilities"),
|
||||
supported_rates=data.get("supported_rates", []),
|
||||
extended_rates=data.get("extended_rates", []),
|
||||
ht_capable=data.get("ht_capable", False),
|
||||
vht_capable=data.get("vht_capable", False),
|
||||
he_capable=data.get("he_capable", False),
|
||||
ht_capabilities=data.get("ht_capabilities"),
|
||||
vht_capabilities=data.get("vht_capabilities"),
|
||||
vendor_ies=data.get("vendor_ies", []),
|
||||
wps_present=data.get("wps_present", False),
|
||||
sequence_number=data.get("sequence_number"),
|
||||
probed_ssids=data.get("probed_ssids", []),
|
||||
)
|
||||
return get_identity_engine().ingest_wifi_observation(obs)
|
||||
|
||||
+251
-286
@@ -23,15 +23,17 @@ from utils.tscm.signal_classification import (
|
||||
generate_hedged_statement,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('intercept.tscm.reports')
|
||||
logger = logging.getLogger("intercept.tscm.reports")
|
||||
|
||||
# =============================================================================
|
||||
# Report Data Structures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReportFinding:
|
||||
"""A single finding for the report."""
|
||||
|
||||
identifier: str
|
||||
protocol: str
|
||||
name: str | None
|
||||
@@ -39,8 +41,8 @@ class ReportFinding:
|
||||
risk_score: int
|
||||
description: str
|
||||
indicators: list[dict] = field(default_factory=list)
|
||||
recommended_action: str = ''
|
||||
playbook_reference: str = ''
|
||||
recommended_action: str = ""
|
||||
playbook_reference: str = ""
|
||||
# Signal classification data
|
||||
signal_strength: str | None = None # minimal, weak, moderate, strong, very_strong
|
||||
signal_confidence: str | None = None # low, medium, high
|
||||
@@ -51,6 +53,7 @@ class ReportFinding:
|
||||
@dataclass
|
||||
class ReportMeetingSummary:
|
||||
"""Meeting window summary for report."""
|
||||
|
||||
name: str | None
|
||||
start_time: str
|
||||
end_time: str | None
|
||||
@@ -67,6 +70,7 @@ class TSCMReport:
|
||||
|
||||
Contains all data needed for both client-safe PDF and technical annex.
|
||||
"""
|
||||
|
||||
# Report metadata
|
||||
report_id: str
|
||||
generated_at: datetime
|
||||
@@ -75,13 +79,13 @@ class TSCMReport:
|
||||
|
||||
# Location and context
|
||||
location: str | None = None
|
||||
examiner_name: str = ''
|
||||
examiner_name: str = ""
|
||||
baseline_id: int | None = None
|
||||
baseline_name: str | None = None
|
||||
|
||||
# Executive summary
|
||||
executive_summary: str = ''
|
||||
overall_risk_assessment: str = 'low' # low, moderate, elevated, high
|
||||
executive_summary: str = ""
|
||||
overall_risk_assessment: str = "low" # low, moderate, elevated, high
|
||||
key_findings_count: int = 0
|
||||
|
||||
# Capabilities used
|
||||
@@ -173,6 +177,7 @@ policies and applicable privacy regulations.
|
||||
# Report Generation Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def generate_executive_summary(report: TSCMReport) -> str:
|
||||
"""Generate executive summary text."""
|
||||
lines = []
|
||||
@@ -185,13 +190,13 @@ def generate_executive_summary(report: TSCMReport) -> str:
|
||||
|
||||
# Overall assessment
|
||||
assessment_text = {
|
||||
'low': 'No significant indicators of surveillance activity were detected.',
|
||||
'moderate': 'Some devices require review but no confirmed surveillance indicators.',
|
||||
'elevated': 'Multiple indicators warrant further investigation.',
|
||||
'high': 'Significant indicators detected requiring immediate attention.',
|
||||
"low": "No significant indicators of surveillance activity were detected.",
|
||||
"moderate": "Some devices require review but no confirmed surveillance indicators.",
|
||||
"elevated": "Multiple indicators warrant further investigation.",
|
||||
"high": "Significant indicators detected requiring immediate attention.",
|
||||
}
|
||||
lines.append(f"OVERALL ASSESSMENT: {report.overall_risk_assessment.upper()}")
|
||||
lines.append(assessment_text.get(report.overall_risk_assessment, ''))
|
||||
lines.append(assessment_text.get(report.overall_risk_assessment, ""))
|
||||
lines.append("")
|
||||
|
||||
# Key statistics
|
||||
@@ -221,9 +226,11 @@ def generate_executive_summary(report: TSCMReport) -> str:
|
||||
if report.meeting_summaries:
|
||||
lines.append("MEETING WINDOW ACTIVITY:")
|
||||
for meeting in report.meeting_summaries:
|
||||
lines.append(f" - {meeting.name or 'Unnamed meeting'}: "
|
||||
f"{meeting.devices_first_seen} new devices, "
|
||||
f"{meeting.high_interest_devices} high interest")
|
||||
lines.append(
|
||||
f" - {meeting.name or 'Unnamed meeting'}: "
|
||||
f"{meeting.devices_first_seen} new devices, "
|
||||
f"{meeting.high_interest_devices} high interest"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Limitations
|
||||
@@ -251,8 +258,8 @@ def generate_findings_section(findings: list[ReportFinding], title: str) -> str:
|
||||
|
||||
# Signal classification with confidence
|
||||
if finding.signal_strength:
|
||||
confidence_label = (finding.signal_confidence or 'low').capitalize()
|
||||
strength_label = finding.signal_strength.replace('_', ' ').title()
|
||||
confidence_label = (finding.signal_confidence or "low").capitalize()
|
||||
strength_label = finding.signal_strength.replace("_", " ").title()
|
||||
lines.append(f" Signal: {strength_label} (Confidence: {confidence_label})")
|
||||
|
||||
lines.append(f" Assessment: {finding.description}")
|
||||
@@ -272,7 +279,7 @@ def generate_findings_section(findings: list[ReportFinding], title: str) -> str:
|
||||
lines.append(f" Reference: {finding.playbook_reference}")
|
||||
|
||||
# Include relevant caveats for high-interest findings
|
||||
if finding.signal_caveats and finding.risk_level == 'high_interest':
|
||||
if finding.signal_caveats and finding.risk_level == "high_interest":
|
||||
lines.append(" Note: " + finding.signal_caveats[0])
|
||||
|
||||
lines.append("")
|
||||
@@ -339,18 +346,12 @@ def generate_pdf_content(report: TSCMReport) -> str:
|
||||
# High Interest Findings
|
||||
if report.high_interest_findings:
|
||||
sections.append("-" * 70)
|
||||
sections.append(generate_findings_section(
|
||||
report.high_interest_findings,
|
||||
"HIGH INTEREST FINDINGS"
|
||||
))
|
||||
sections.append(generate_findings_section(report.high_interest_findings, "HIGH INTEREST FINDINGS"))
|
||||
|
||||
# Needs Review Findings
|
||||
if report.needs_review_findings:
|
||||
sections.append("-" * 70)
|
||||
sections.append(generate_findings_section(
|
||||
report.needs_review_findings,
|
||||
"FINDINGS REQUIRING REVIEW"
|
||||
))
|
||||
sections.append(generate_findings_section(report.needs_review_findings, "FINDINGS REQUIRING REVIEW"))
|
||||
|
||||
# Meeting Window Summary
|
||||
if report.meeting_summaries:
|
||||
@@ -366,11 +367,11 @@ def generate_pdf_content(report: TSCMReport) -> str:
|
||||
if report.capabilities:
|
||||
caps = report.capabilities
|
||||
sections.append("Equipment Used:")
|
||||
if caps.get('wifi', {}).get('mode') != 'unavailable':
|
||||
if caps.get("wifi", {}).get("mode") != "unavailable":
|
||||
sections.append(f" - WiFi: {caps.get('wifi', {}).get('mode', 'unknown')} mode")
|
||||
if caps.get('bluetooth', {}).get('mode') != 'unavailable':
|
||||
if caps.get("bluetooth", {}).get("mode") != "unavailable":
|
||||
sections.append(f" - Bluetooth: {caps.get('bluetooth', {}).get('mode', 'unknown')}")
|
||||
if caps.get('rf', {}).get('available'):
|
||||
if caps.get("rf", {}).get("available"):
|
||||
sections.append(f" - RF/SDR: {caps.get('rf', {}).get('device_type', 'unknown')}")
|
||||
sections.append("")
|
||||
|
||||
@@ -408,93 +409,87 @@ def generate_technical_annex_json(report: TSCMReport) -> dict:
|
||||
for audit and further analysis.
|
||||
"""
|
||||
return {
|
||||
'annex_type': 'tscm_technical_annex',
|
||||
'report_id': report.report_id,
|
||||
'generated_at': report.generated_at.isoformat(),
|
||||
'sweep_id': report.sweep_id,
|
||||
'disclaimer': ANNEX_DISCLAIMER.strip(),
|
||||
|
||||
'sweep_details': {
|
||||
'type': report.sweep_type,
|
||||
'location': report.location,
|
||||
'start_time': report.sweep_start.isoformat() if report.sweep_start else None,
|
||||
'end_time': report.sweep_end.isoformat() if report.sweep_end else None,
|
||||
'duration_minutes': report.duration_minutes,
|
||||
'baseline_id': report.baseline_id,
|
||||
'baseline_name': report.baseline_name,
|
||||
"annex_type": "tscm_technical_annex",
|
||||
"report_id": report.report_id,
|
||||
"generated_at": report.generated_at.isoformat(),
|
||||
"sweep_id": report.sweep_id,
|
||||
"disclaimer": ANNEX_DISCLAIMER.strip(),
|
||||
"sweep_details": {
|
||||
"type": report.sweep_type,
|
||||
"location": report.location,
|
||||
"start_time": report.sweep_start.isoformat() if report.sweep_start else None,
|
||||
"end_time": report.sweep_end.isoformat() if report.sweep_end else None,
|
||||
"duration_minutes": report.duration_minutes,
|
||||
"baseline_id": report.baseline_id,
|
||||
"baseline_name": report.baseline_name,
|
||||
},
|
||||
|
||||
'capabilities': report.capabilities,
|
||||
'limitations': report.limitations,
|
||||
|
||||
'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),
|
||||
"capabilities": report.capabilities,
|
||||
"limitations": report.limitations,
|
||||
"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),
|
||||
},
|
||||
|
||||
'findings': {
|
||||
'high_interest': [
|
||||
"findings": {
|
||||
"high_interest": [
|
||||
{
|
||||
'identifier': f.identifier,
|
||||
'protocol': f.protocol,
|
||||
'name': f.name,
|
||||
'risk_score': f.risk_score,
|
||||
'description': f.description,
|
||||
'indicators': f.indicators,
|
||||
'recommended_action': f.recommended_action,
|
||||
'signal_classification': {
|
||||
'strength': f.signal_strength,
|
||||
'confidence': f.signal_confidence,
|
||||
'interpretation': f.signal_interpretation,
|
||||
'caveats': f.signal_caveats,
|
||||
"identifier": f.identifier,
|
||||
"protocol": f.protocol,
|
||||
"name": f.name,
|
||||
"risk_score": f.risk_score,
|
||||
"description": f.description,
|
||||
"indicators": f.indicators,
|
||||
"recommended_action": f.recommended_action,
|
||||
"signal_classification": {
|
||||
"strength": f.signal_strength,
|
||||
"confidence": f.signal_confidence,
|
||||
"interpretation": f.signal_interpretation,
|
||||
"caveats": f.signal_caveats,
|
||||
},
|
||||
}
|
||||
for f in report.high_interest_findings
|
||||
],
|
||||
'needs_review': [
|
||||
"needs_review": [
|
||||
{
|
||||
'identifier': f.identifier,
|
||||
'protocol': f.protocol,
|
||||
'name': f.name,
|
||||
'risk_score': f.risk_score,
|
||||
'description': f.description,
|
||||
'indicators': f.indicators,
|
||||
'signal_classification': {
|
||||
'strength': f.signal_strength,
|
||||
'confidence': f.signal_confidence,
|
||||
'interpretation': f.signal_interpretation,
|
||||
'caveats': f.signal_caveats,
|
||||
"identifier": f.identifier,
|
||||
"protocol": f.protocol,
|
||||
"name": f.name,
|
||||
"risk_score": f.risk_score,
|
||||
"description": f.description,
|
||||
"indicators": f.indicators,
|
||||
"signal_classification": {
|
||||
"strength": f.signal_strength,
|
||||
"confidence": f.signal_confidence,
|
||||
"interpretation": f.signal_interpretation,
|
||||
"caveats": f.signal_caveats,
|
||||
},
|
||||
}
|
||||
for f in report.needs_review_findings
|
||||
],
|
||||
},
|
||||
|
||||
'meeting_windows': [
|
||||
"meeting_windows": [
|
||||
{
|
||||
'name': m.name,
|
||||
'start_time': m.start_time,
|
||||
'end_time': m.end_time,
|
||||
'duration_minutes': m.duration_minutes,
|
||||
'devices_first_seen': m.devices_first_seen,
|
||||
'behavior_changes': m.behavior_changes,
|
||||
'high_interest_devices': m.high_interest_devices,
|
||||
"name": m.name,
|
||||
"start_time": m.start_time,
|
||||
"end_time": m.end_time,
|
||||
"duration_minutes": m.duration_minutes,
|
||||
"devices_first_seen": m.devices_first_seen,
|
||||
"behavior_changes": m.behavior_changes,
|
||||
"high_interest_devices": m.high_interest_devices,
|
||||
}
|
||||
for m in report.meeting_summaries
|
||||
],
|
||||
|
||||
'device_timelines': report.device_timelines,
|
||||
'all_indicators': report.all_indicators,
|
||||
'baseline_diff': report.baseline_diff,
|
||||
'correlations': report.correlation_data,
|
||||
"device_timelines": report.device_timelines,
|
||||
"all_indicators": report.all_indicators,
|
||||
"baseline_diff": report.baseline_diff,
|
||||
"correlations": report.correlation_data,
|
||||
}
|
||||
|
||||
|
||||
@@ -508,80 +503,88 @@ def generate_technical_annex_csv(report: TSCMReport) -> str:
|
||||
writer = csv.writer(output)
|
||||
|
||||
# Header
|
||||
writer.writerow([
|
||||
'identifier',
|
||||
'protocol',
|
||||
'name',
|
||||
'risk_level',
|
||||
'risk_score',
|
||||
'first_seen',
|
||||
'last_seen',
|
||||
'observation_count',
|
||||
'rssi_min',
|
||||
'rssi_max',
|
||||
'rssi_mean',
|
||||
'rssi_stability',
|
||||
'movement_pattern',
|
||||
'meeting_correlated',
|
||||
'indicators',
|
||||
])
|
||||
writer.writerow(
|
||||
[
|
||||
"identifier",
|
||||
"protocol",
|
||||
"name",
|
||||
"risk_level",
|
||||
"risk_score",
|
||||
"first_seen",
|
||||
"last_seen",
|
||||
"observation_count",
|
||||
"rssi_min",
|
||||
"rssi_max",
|
||||
"rssi_mean",
|
||||
"rssi_stability",
|
||||
"movement_pattern",
|
||||
"meeting_correlated",
|
||||
"indicators",
|
||||
]
|
||||
)
|
||||
|
||||
# Device data from timelines
|
||||
for timeline in report.device_timelines:
|
||||
indicators_str = '; '.join(
|
||||
f"{i.get('type', '')}({i.get('score', 0)})"
|
||||
for i in timeline.get('indicators', [])
|
||||
indicators_str = "; ".join(f"{i.get('type', '')}({i.get('score', 0)})" for i in timeline.get("indicators", []))
|
||||
|
||||
signal = timeline.get("signal", {})
|
||||
metrics = timeline.get("metrics", {})
|
||||
movement = timeline.get("movement", {})
|
||||
meeting = timeline.get("meeting_correlation", {})
|
||||
|
||||
writer.writerow(
|
||||
[
|
||||
timeline.get("identifier", ""),
|
||||
timeline.get("protocol", ""),
|
||||
timeline.get("name", ""),
|
||||
timeline.get("risk_level", "informational"),
|
||||
timeline.get("risk_score", 0),
|
||||
metrics.get("first_seen", ""),
|
||||
metrics.get("last_seen", ""),
|
||||
metrics.get("total_observations", 0),
|
||||
signal.get("rssi_min", ""),
|
||||
signal.get("rssi_max", ""),
|
||||
signal.get("rssi_mean", ""),
|
||||
signal.get("stability", ""),
|
||||
movement.get("pattern", ""),
|
||||
meeting.get("correlated", False),
|
||||
indicators_str,
|
||||
]
|
||||
)
|
||||
|
||||
signal = timeline.get('signal', {})
|
||||
metrics = timeline.get('metrics', {})
|
||||
movement = timeline.get('movement', {})
|
||||
meeting = timeline.get('meeting_correlation', {})
|
||||
|
||||
writer.writerow([
|
||||
timeline.get('identifier', ''),
|
||||
timeline.get('protocol', ''),
|
||||
timeline.get('name', ''),
|
||||
timeline.get('risk_level', 'informational'),
|
||||
timeline.get('risk_score', 0),
|
||||
metrics.get('first_seen', ''),
|
||||
metrics.get('last_seen', ''),
|
||||
metrics.get('total_observations', 0),
|
||||
signal.get('rssi_min', ''),
|
||||
signal.get('rssi_max', ''),
|
||||
signal.get('rssi_mean', ''),
|
||||
signal.get('stability', ''),
|
||||
movement.get('pattern', ''),
|
||||
meeting.get('correlated', False),
|
||||
indicators_str,
|
||||
])
|
||||
|
||||
# Also add findings summary
|
||||
writer.writerow([])
|
||||
writer.writerow(['--- FINDINGS SUMMARY ---'])
|
||||
writer.writerow([
|
||||
'identifier', 'protocol', 'risk_level', 'risk_score',
|
||||
'signal_strength', 'signal_confidence',
|
||||
'description', 'interpretation', 'recommended_action'
|
||||
])
|
||||
|
||||
all_findings = (
|
||||
report.high_interest_findings +
|
||||
report.needs_review_findings
|
||||
writer.writerow(["--- FINDINGS SUMMARY ---"])
|
||||
writer.writerow(
|
||||
[
|
||||
"identifier",
|
||||
"protocol",
|
||||
"risk_level",
|
||||
"risk_score",
|
||||
"signal_strength",
|
||||
"signal_confidence",
|
||||
"description",
|
||||
"interpretation",
|
||||
"recommended_action",
|
||||
]
|
||||
)
|
||||
|
||||
all_findings = report.high_interest_findings + report.needs_review_findings
|
||||
|
||||
for finding in all_findings:
|
||||
writer.writerow([
|
||||
finding.identifier,
|
||||
finding.protocol,
|
||||
finding.risk_level,
|
||||
finding.risk_score,
|
||||
finding.signal_strength or '',
|
||||
finding.signal_confidence or '',
|
||||
finding.description,
|
||||
finding.signal_interpretation or '',
|
||||
finding.recommended_action,
|
||||
])
|
||||
writer.writerow(
|
||||
[
|
||||
finding.identifier,
|
||||
finding.protocol,
|
||||
finding.risk_level,
|
||||
finding.risk_score,
|
||||
finding.signal_strength or "",
|
||||
finding.signal_confidence or "",
|
||||
finding.description,
|
||||
finding.signal_interpretation or "",
|
||||
finding.recommended_action,
|
||||
]
|
||||
)
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
@@ -590,6 +593,7 @@ def generate_technical_annex_csv(report: TSCMReport) -> str:
|
||||
# Report Builder
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TSCMReportBuilder:
|
||||
"""
|
||||
Builder for constructing TSCM reports from sweep data.
|
||||
@@ -608,7 +612,7 @@ class TSCMReportBuilder:
|
||||
report_id=f"TSCM-{sweep_id}-{datetime.now().strftime('%Y%m%d%H%M%S')}",
|
||||
generated_at=datetime.now(),
|
||||
sweep_id=sweep_id,
|
||||
sweep_type='standard',
|
||||
sweep_type="standard",
|
||||
)
|
||||
|
||||
def set_sweep_type(self, sweep_type: str) -> TSCMReportBuilder:
|
||||
@@ -628,27 +632,21 @@ class TSCMReportBuilder:
|
||||
self.report.baseline_name = baseline_name
|
||||
return self
|
||||
|
||||
def set_sweep_times(
|
||||
self,
|
||||
start: datetime,
|
||||
end: datetime | None = None
|
||||
) -> TSCMReportBuilder:
|
||||
def set_sweep_times(self, start: datetime, end: datetime | None = None) -> TSCMReportBuilder:
|
||||
self.report.sweep_start = start
|
||||
self.report.sweep_end = end or datetime.now()
|
||||
self.report.duration_minutes = (
|
||||
(self.report.sweep_end - self.report.sweep_start).total_seconds() / 60
|
||||
)
|
||||
self.report.duration_minutes = (self.report.sweep_end - self.report.sweep_start).total_seconds() / 60
|
||||
return self
|
||||
|
||||
def add_capabilities(self, capabilities: dict) -> TSCMReportBuilder:
|
||||
self.report.capabilities = capabilities
|
||||
self.report.limitations = capabilities.get('all_limitations', [])
|
||||
self.report.limitations = capabilities.get("all_limitations", [])
|
||||
return self
|
||||
|
||||
def add_finding(self, finding: ReportFinding) -> TSCMReportBuilder:
|
||||
if finding.risk_level == 'high_interest':
|
||||
if finding.risk_level == "high_interest":
|
||||
self.report.high_interest_findings.append(finding)
|
||||
elif finding.risk_level in ['review', 'needs_review']:
|
||||
elif finding.risk_level in ["review", "needs_review"]:
|
||||
self.report.needs_review_findings.append(finding)
|
||||
else:
|
||||
self.report.informational_findings.append(finding)
|
||||
@@ -661,19 +659,19 @@ class TSCMReportBuilder:
|
||||
signal_data = self._classify_finding_signal(profile)
|
||||
|
||||
finding = ReportFinding(
|
||||
identifier=profile.get('identifier', ''),
|
||||
protocol=profile.get('protocol', ''),
|
||||
name=profile.get('name'),
|
||||
risk_level=profile.get('risk_level', 'informational'),
|
||||
risk_score=profile.get('total_score', 0),
|
||||
identifier=profile.get("identifier", ""),
|
||||
protocol=profile.get("protocol", ""),
|
||||
name=profile.get("name"),
|
||||
risk_level=profile.get("risk_level", "informational"),
|
||||
risk_score=profile.get("total_score", 0),
|
||||
description=self._generate_finding_description(profile),
|
||||
indicators=profile.get('indicators', []),
|
||||
recommended_action=profile.get('recommended_action', 'monitor'),
|
||||
indicators=profile.get("indicators", []),
|
||||
recommended_action=profile.get("recommended_action", "monitor"),
|
||||
playbook_reference=self._get_playbook_reference(profile),
|
||||
signal_strength=signal_data['signal_strength'],
|
||||
signal_confidence=signal_data['signal_confidence'],
|
||||
signal_interpretation=signal_data['signal_interpretation'],
|
||||
signal_caveats=signal_data['signal_caveats'],
|
||||
signal_strength=signal_data["signal_strength"],
|
||||
signal_confidence=signal_data["signal_confidence"],
|
||||
signal_interpretation=signal_data["signal_interpretation"],
|
||||
signal_caveats=signal_data["signal_caveats"],
|
||||
)
|
||||
self.add_finding(finding)
|
||||
|
||||
@@ -681,13 +679,13 @@ class TSCMReportBuilder:
|
||||
|
||||
def _generate_finding_description(self, profile: dict) -> str:
|
||||
"""Generate description from profile indicators using hedged language."""
|
||||
indicators = profile.get('indicators', [])
|
||||
protocol = profile.get('protocol', 'Unknown').upper()
|
||||
indicators = profile.get("indicators", [])
|
||||
protocol = profile.get("protocol", "Unknown").upper()
|
||||
|
||||
# Get signal data for context
|
||||
rssi = profile.get('rssi_mean') or profile.get('rssi')
|
||||
duration = profile.get('observation_duration_seconds')
|
||||
observation_count = profile.get('observation_count', 1)
|
||||
rssi = profile.get("rssi_mean") or profile.get("rssi")
|
||||
duration = profile.get("observation_duration_seconds")
|
||||
observation_count = profile.get("observation_count", 1)
|
||||
|
||||
# Assess signal to determine confidence
|
||||
assessment = assess_signal(rssi, duration, observation_count)
|
||||
@@ -695,44 +693,24 @@ class TSCMReportBuilder:
|
||||
|
||||
if not indicators:
|
||||
# Use hedged language based on confidence
|
||||
return generate_hedged_statement(
|
||||
f"Observed {protocol} signal",
|
||||
'device_presence',
|
||||
confidence
|
||||
)
|
||||
return generate_hedged_statement(f"Observed {protocol} signal", "device_presence", confidence)
|
||||
|
||||
# Build description with hedged language
|
||||
primary = indicators[0]
|
||||
indicator_type = primary.get('type', 'pattern')
|
||||
indicator_type = primary.get("type", "pattern")
|
||||
|
||||
# Map indicator types to hedged descriptions
|
||||
if indicator_type in ('airtag_detected', 'tile_detected', 'smarttag_detected', 'known_tracker'):
|
||||
desc = generate_hedged_statement(
|
||||
f"{protocol} signal characteristics",
|
||||
'device_presence',
|
||||
confidence
|
||||
)
|
||||
if indicator_type in ("airtag_detected", "tile_detected", "smarttag_detected", "known_tracker"):
|
||||
desc = generate_hedged_statement(f"{protocol} signal characteristics", "device_presence", confidence)
|
||||
desc += f" - pattern consistent with {indicator_type.replace('_', ' ')}"
|
||||
elif indicator_type == 'audio_capable':
|
||||
desc = generate_hedged_statement(
|
||||
"Device characteristics",
|
||||
'surveillance_indicator',
|
||||
confidence
|
||||
)
|
||||
elif indicator_type == "audio_capable":
|
||||
desc = generate_hedged_statement("Device characteristics", "surveillance_indicator", confidence)
|
||||
desc += " - audio-capable device type identified"
|
||||
elif indicator_type in ('hidden_identity', 'hidden_ssid'):
|
||||
desc = generate_hedged_statement(
|
||||
"Network configuration",
|
||||
'surveillance_indicator',
|
||||
confidence
|
||||
)
|
||||
elif indicator_type in ("hidden_identity", "hidden_ssid"):
|
||||
desc = generate_hedged_statement("Network configuration", "surveillance_indicator", confidence)
|
||||
desc += " - concealed identity pattern observed"
|
||||
else:
|
||||
desc = generate_hedged_statement(
|
||||
f"{protocol} signal pattern",
|
||||
'device_presence',
|
||||
confidence
|
||||
)
|
||||
desc = generate_hedged_statement(f"{protocol} signal pattern", "device_presence", confidence)
|
||||
|
||||
if len(indicators) > 1:
|
||||
desc += f" (+{len(indicators) - 1} additional indicators)"
|
||||
@@ -741,58 +719,52 @@ class TSCMReportBuilder:
|
||||
|
||||
def _classify_finding_signal(self, profile: dict) -> dict:
|
||||
"""Extract signal classification data for a finding."""
|
||||
rssi = profile.get('rssi_mean') or profile.get('rssi')
|
||||
duration = profile.get('observation_duration_seconds')
|
||||
observation_count = profile.get('observation_count', 1)
|
||||
rssi = profile.get("rssi_mean") or profile.get("rssi")
|
||||
duration = profile.get("observation_duration_seconds")
|
||||
observation_count = profile.get("observation_count", 1)
|
||||
|
||||
assessment = assess_signal(rssi, duration, observation_count)
|
||||
|
||||
return {
|
||||
'signal_strength': assessment.signal_strength.value,
|
||||
'signal_confidence': assessment.confidence.value,
|
||||
'signal_interpretation': assessment.interpretation,
|
||||
'signal_caveats': assessment.caveats,
|
||||
"signal_strength": assessment.signal_strength.value,
|
||||
"signal_confidence": assessment.confidence.value,
|
||||
"signal_interpretation": assessment.interpretation,
|
||||
"signal_caveats": assessment.caveats,
|
||||
}
|
||||
|
||||
def _get_playbook_reference(self, profile: dict) -> str:
|
||||
"""Get playbook reference based on profile."""
|
||||
risk_level = profile.get('risk_level', 'informational')
|
||||
indicators = profile.get('indicators', [])
|
||||
risk_level = profile.get("risk_level", "informational")
|
||||
indicators = profile.get("indicators", [])
|
||||
|
||||
# Check for tracker
|
||||
tracker_types = ['airtag_detected', 'tile_detected', 'smarttag_detected', 'known_tracker']
|
||||
if any(i.get('type') in tracker_types for i in indicators) and risk_level == 'high_interest':
|
||||
return 'PB-001 (Tracker Detection)'
|
||||
tracker_types = ["airtag_detected", "tile_detected", "smarttag_detected", "known_tracker"]
|
||||
if any(i.get("type") in tracker_types for i in indicators) and risk_level == "high_interest":
|
||||
return "PB-001 (Tracker Detection)"
|
||||
|
||||
if risk_level == 'high_interest':
|
||||
return 'PB-002 (Suspicious Device)'
|
||||
elif risk_level in ['review', 'needs_review']:
|
||||
return 'PB-003 (Unknown Device)'
|
||||
if risk_level == "high_interest":
|
||||
return "PB-002 (Suspicious Device)"
|
||||
elif risk_level in ["review", "needs_review"]:
|
||||
return "PB-003 (Unknown Device)"
|
||||
|
||||
return ''
|
||||
return ""
|
||||
|
||||
def add_meeting_summary(self, summary: dict) -> TSCMReportBuilder:
|
||||
"""Add meeting window summary."""
|
||||
meeting = ReportMeetingSummary(
|
||||
name=summary.get('name'),
|
||||
start_time=summary.get('start_time', ''),
|
||||
end_time=summary.get('end_time'),
|
||||
duration_minutes=summary.get('duration_minutes', 0),
|
||||
devices_first_seen=summary.get('devices_first_seen', 0),
|
||||
behavior_changes=summary.get('behavior_changes', 0),
|
||||
high_interest_devices=summary.get('high_interest_devices', 0),
|
||||
name=summary.get("name"),
|
||||
start_time=summary.get("start_time", ""),
|
||||
end_time=summary.get("end_time"),
|
||||
duration_minutes=summary.get("duration_minutes", 0),
|
||||
devices_first_seen=summary.get("devices_first_seen", 0),
|
||||
behavior_changes=summary.get("behavior_changes", 0),
|
||||
high_interest_devices=summary.get("high_interest_devices", 0),
|
||||
)
|
||||
self.report.meeting_summaries.append(meeting)
|
||||
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
|
||||
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
|
||||
@@ -824,17 +796,16 @@ class TSCMReportBuilder:
|
||||
# Calculate overall risk assessment
|
||||
if self.report.high_interest_findings:
|
||||
if len(self.report.high_interest_findings) >= 3:
|
||||
self.report.overall_risk_assessment = 'high'
|
||||
self.report.overall_risk_assessment = "high"
|
||||
else:
|
||||
self.report.overall_risk_assessment = 'elevated'
|
||||
self.report.overall_risk_assessment = "elevated"
|
||||
elif self.report.needs_review_findings:
|
||||
self.report.overall_risk_assessment = 'moderate'
|
||||
self.report.overall_risk_assessment = "moderate"
|
||||
else:
|
||||
self.report.overall_risk_assessment = 'low'
|
||||
self.report.overall_risk_assessment = "low"
|
||||
|
||||
self.report.key_findings_count = (
|
||||
len(self.report.high_interest_findings) +
|
||||
len(self.report.needs_review_findings)
|
||||
self.report.key_findings_count = len(self.report.high_interest_findings) + len(
|
||||
self.report.needs_review_findings
|
||||
)
|
||||
|
||||
# Generate executive summary
|
||||
@@ -847,6 +818,7 @@ class TSCMReportBuilder:
|
||||
# Report Generation API Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def generate_report(
|
||||
sweep_id: int,
|
||||
sweep_data: dict,
|
||||
@@ -857,8 +829,8 @@ def generate_report(
|
||||
meeting_summaries: list[dict] | None = None,
|
||||
correlations: list[dict] | None = None,
|
||||
categories: list[str] | None = None,
|
||||
site_name: str = '',
|
||||
examiner_name: str = '',
|
||||
site_name: str = "",
|
||||
examiner_name: str = "",
|
||||
) -> TSCMReport:
|
||||
"""
|
||||
Generate a complete TSCM report from sweep data.
|
||||
@@ -879,20 +851,20 @@ def generate_report(
|
||||
builder = TSCMReportBuilder(sweep_id)
|
||||
|
||||
# Basic info
|
||||
builder.set_sweep_type(sweep_data.get('sweep_type', 'standard'))
|
||||
builder.set_sweep_type(sweep_data.get("sweep_type", "standard"))
|
||||
if site_name:
|
||||
builder.set_location(site_name)
|
||||
if examiner_name:
|
||||
builder.set_examiner(examiner_name)
|
||||
|
||||
# Parse times
|
||||
started_at = sweep_data.get('started_at')
|
||||
completed_at = sweep_data.get('completed_at')
|
||||
started_at = sweep_data.get("started_at")
|
||||
completed_at = sweep_data.get("completed_at")
|
||||
if started_at:
|
||||
if isinstance(started_at, str):
|
||||
started_at = datetime.fromisoformat(started_at.replace('Z', '+00:00')).replace(tzinfo=None)
|
||||
started_at = datetime.fromisoformat(started_at.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||
if completed_at and isinstance(completed_at, str):
|
||||
completed_at = datetime.fromisoformat(completed_at.replace('Z', '+00:00')).replace(tzinfo=None)
|
||||
completed_at = datetime.fromisoformat(completed_at.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||
builder.set_sweep_times(started_at, completed_at)
|
||||
|
||||
# Capabilities
|
||||
@@ -900,43 +872,40 @@ def generate_report(
|
||||
|
||||
# Apply category filter before building findings
|
||||
if categories:
|
||||
_cat_map = {'needs_review': {'review', 'needs_review'}}
|
||||
_cat_map = {"needs_review": {"review", "needs_review"}}
|
||||
allowed = set()
|
||||
for c in categories:
|
||||
allowed |= _cat_map.get(c, {c})
|
||||
device_profiles = [
|
||||
p for p in device_profiles
|
||||
if p.get('risk_level', 'informational') in allowed
|
||||
]
|
||||
device_profiles = [p for p in device_profiles if p.get("risk_level", "informational") in allowed]
|
||||
|
||||
# Add findings from profiles
|
||||
builder.add_findings_from_profiles(device_profiles)
|
||||
|
||||
# Statistics
|
||||
results = sweep_data.get('results', {})
|
||||
wifi_count = results.get('wifi_count')
|
||||
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_count = len(results.get("wifi_devices", results.get("wifi", [])))
|
||||
|
||||
wifi_client_count = results.get('wifi_client_count')
|
||||
wifi_client_count = results.get("wifi_client_count")
|
||||
if wifi_client_count is None:
|
||||
wifi_client_count = len(results.get('wifi_clients', []))
|
||||
wifi_client_count = len(results.get("wifi_clients", []))
|
||||
|
||||
bt_count = results.get('bt_count')
|
||||
bt_count = results.get("bt_count")
|
||||
if bt_count is None:
|
||||
bt_count = len(results.get('bt_devices', results.get('bluetooth', [])))
|
||||
bt_count = len(results.get("bt_devices", results.get("bluetooth", [])))
|
||||
|
||||
rf_count = results.get('rf_count')
|
||||
rf_count = results.get("rf_count")
|
||||
if rf_count is None:
|
||||
rf_count = len(results.get('rf_signals', results.get('rf', [])))
|
||||
rf_count = len(results.get("rf_signals", results.get("rf", [])))
|
||||
|
||||
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,
|
||||
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
|
||||
@@ -955,12 +924,8 @@ def generate_report(
|
||||
# Extract all indicators
|
||||
all_indicators = []
|
||||
for profile in device_profiles:
|
||||
for ind in profile.get('indicators', []):
|
||||
all_indicators.append({
|
||||
'device': profile.get('identifier'),
|
||||
'protocol': profile.get('protocol'),
|
||||
**ind
|
||||
})
|
||||
for ind in profile.get("indicators", []):
|
||||
all_indicators.append({"device": profile.get("identifier"), "protocol": profile.get("protocol"), **ind})
|
||||
builder.add_all_indicators(all_indicators)
|
||||
|
||||
return builder.build()
|
||||
|
||||
+128
-132
@@ -16,8 +16,10 @@ from enum import Enum
|
||||
# Signal Strength Classification
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SignalStrength(Enum):
|
||||
"""Qualitative signal strength labels."""
|
||||
|
||||
MINIMAL = "minimal"
|
||||
WEAK = "weak"
|
||||
MODERATE = "moderate"
|
||||
@@ -27,43 +29,43 @@ class SignalStrength(Enum):
|
||||
|
||||
# RSSI thresholds (dBm) - upper bounds for each category
|
||||
RSSI_THRESHOLDS = {
|
||||
SignalStrength.MINIMAL: -85, # -100 to -85
|
||||
SignalStrength.WEAK: -70, # -84 to -70
|
||||
SignalStrength.MODERATE: -55, # -69 to -55
|
||||
SignalStrength.STRONG: -40, # -54 to -40
|
||||
SignalStrength.VERY_STRONG: 0, # > -40
|
||||
SignalStrength.MINIMAL: -85, # -100 to -85
|
||||
SignalStrength.WEAK: -70, # -84 to -70
|
||||
SignalStrength.MODERATE: -55, # -69 to -55
|
||||
SignalStrength.STRONG: -40, # -54 to -40
|
||||
SignalStrength.VERY_STRONG: 0, # > -40
|
||||
}
|
||||
|
||||
SIGNAL_STRENGTH_DESCRIPTIONS = {
|
||||
SignalStrength.MINIMAL: {
|
||||
'label': 'Minimal',
|
||||
'description': 'At detection threshold',
|
||||
'interpretation': 'may be ambient noise or distant source',
|
||||
'confidence': 'low',
|
||||
"label": "Minimal",
|
||||
"description": "At detection threshold",
|
||||
"interpretation": "may be ambient noise or distant source",
|
||||
"confidence": "low",
|
||||
},
|
||||
SignalStrength.WEAK: {
|
||||
'label': 'Weak',
|
||||
'description': 'Detectable signal',
|
||||
'interpretation': 'potentially distant or obstructed',
|
||||
'confidence': 'low',
|
||||
"label": "Weak",
|
||||
"description": "Detectable signal",
|
||||
"interpretation": "potentially distant or obstructed",
|
||||
"confidence": "low",
|
||||
},
|
||||
SignalStrength.MODERATE: {
|
||||
'label': 'Moderate',
|
||||
'description': 'Consistent presence',
|
||||
'interpretation': 'likely in proximity',
|
||||
'confidence': 'medium',
|
||||
"label": "Moderate",
|
||||
"description": "Consistent presence",
|
||||
"interpretation": "likely in proximity",
|
||||
"confidence": "medium",
|
||||
},
|
||||
SignalStrength.STRONG: {
|
||||
'label': 'Strong',
|
||||
'description': 'Clear signal',
|
||||
'interpretation': 'probable close proximity',
|
||||
'confidence': 'medium',
|
||||
"label": "Strong",
|
||||
"description": "Clear signal",
|
||||
"interpretation": "probable close proximity",
|
||||
"confidence": "medium",
|
||||
},
|
||||
SignalStrength.VERY_STRONG: {
|
||||
'label': 'Very Strong',
|
||||
'description': 'High signal level',
|
||||
'interpretation': 'indicates likely nearby source',
|
||||
'confidence': 'high',
|
||||
"label": "Very Strong",
|
||||
"description": "High signal level",
|
||||
"interpretation": "indicates likely nearby source",
|
||||
"confidence": "high",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -106,8 +108,8 @@ def get_signal_strength_info(rssi: float | int | None) -> dict:
|
||||
"""
|
||||
strength = classify_signal_strength(rssi)
|
||||
info = SIGNAL_STRENGTH_DESCRIPTIONS[strength].copy()
|
||||
info['strength'] = strength.value
|
||||
info['rssi'] = rssi
|
||||
info["strength"] = strength.value
|
||||
info["rssi"] = rssi
|
||||
return info
|
||||
|
||||
|
||||
@@ -115,8 +117,10 @@ def get_signal_strength_info(rssi: float | int | None) -> dict:
|
||||
# Detection Duration / Confidence Modifiers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class DetectionDuration(Enum):
|
||||
"""Qualitative duration labels."""
|
||||
|
||||
TRANSIENT = "transient"
|
||||
SHORT = "short"
|
||||
SUSTAINED = "sustained"
|
||||
@@ -125,32 +129,32 @@ class DetectionDuration(Enum):
|
||||
|
||||
# Duration thresholds (seconds)
|
||||
DURATION_THRESHOLDS = {
|
||||
DetectionDuration.TRANSIENT: 5, # < 5 seconds
|
||||
DetectionDuration.SHORT: 30, # 5-30 seconds
|
||||
DetectionDuration.SUSTAINED: 120, # 30s - 2 min
|
||||
DetectionDuration.PERSISTENT: float('inf'), # > 2 min
|
||||
DetectionDuration.TRANSIENT: 5, # < 5 seconds
|
||||
DetectionDuration.SHORT: 30, # 5-30 seconds
|
||||
DetectionDuration.SUSTAINED: 120, # 30s - 2 min
|
||||
DetectionDuration.PERSISTENT: float("inf"), # > 2 min
|
||||
}
|
||||
|
||||
DURATION_DESCRIPTIONS = {
|
||||
DetectionDuration.TRANSIENT: {
|
||||
'label': 'Transient',
|
||||
'modifier': 'briefly observed',
|
||||
'confidence_impact': 'reduces confidence',
|
||||
"label": "Transient",
|
||||
"modifier": "briefly observed",
|
||||
"confidence_impact": "reduces confidence",
|
||||
},
|
||||
DetectionDuration.SHORT: {
|
||||
'label': 'Short-duration',
|
||||
'modifier': 'observed for a short period',
|
||||
'confidence_impact': 'limited confidence',
|
||||
"label": "Short-duration",
|
||||
"modifier": "observed for a short period",
|
||||
"confidence_impact": "limited confidence",
|
||||
},
|
||||
DetectionDuration.SUSTAINED: {
|
||||
'label': 'Sustained',
|
||||
'modifier': 'observed over sustained period',
|
||||
'confidence_impact': 'supports confidence',
|
||||
"label": "Sustained",
|
||||
"modifier": "observed over sustained period",
|
||||
"confidence_impact": "supports confidence",
|
||||
},
|
||||
DetectionDuration.PERSISTENT: {
|
||||
'label': 'Persistent',
|
||||
'modifier': 'continuously observed',
|
||||
'confidence_impact': 'increases confidence',
|
||||
"label": "Persistent",
|
||||
"modifier": "continuously observed",
|
||||
"confidence_impact": "increases confidence",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -187,8 +191,8 @@ def get_duration_info(seconds: float | int | None) -> dict:
|
||||
"""Get full duration classification with metadata."""
|
||||
duration = classify_duration(seconds)
|
||||
info = DURATION_DESCRIPTIONS[duration].copy()
|
||||
info['duration'] = duration.value
|
||||
info['seconds'] = seconds
|
||||
info["duration"] = duration.value
|
||||
info["seconds"] = seconds
|
||||
return info
|
||||
|
||||
|
||||
@@ -196,8 +200,10 @@ def get_duration_info(seconds: float | int | None) -> dict:
|
||||
# Combined Confidence Assessment
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ConfidenceLevel(Enum):
|
||||
"""Overall detection confidence."""
|
||||
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
@@ -206,6 +212,7 @@ class ConfidenceLevel(Enum):
|
||||
@dataclass
|
||||
class SignalAssessment:
|
||||
"""Complete signal assessment with confidence-safe language."""
|
||||
|
||||
rssi: float | None
|
||||
duration_seconds: float | None
|
||||
observation_count: int
|
||||
@@ -244,9 +251,7 @@ def assess_signal(
|
||||
duration = classify_duration(duration_seconds)
|
||||
|
||||
# Calculate confidence based on multiple factors
|
||||
confidence = _calculate_confidence(
|
||||
strength, duration, observation_count, has_corroborating_data
|
||||
)
|
||||
confidence = _calculate_confidence(strength, duration, observation_count, has_corroborating_data)
|
||||
|
||||
strength_info = SIGNAL_STRENGTH_DESCRIPTIONS[strength]
|
||||
duration_info = DURATION_DESCRIPTIONS[duration]
|
||||
@@ -263,8 +268,8 @@ def assess_signal(
|
||||
signal_strength=strength,
|
||||
detection_duration=duration,
|
||||
confidence=confidence,
|
||||
strength_label=strength_info['label'],
|
||||
duration_label=duration_info['label'],
|
||||
strength_label=strength_info["label"],
|
||||
duration_label=duration_info["label"],
|
||||
summary=summary,
|
||||
interpretation=interpretation,
|
||||
caveats=caveats,
|
||||
@@ -326,10 +331,7 @@ def _build_summary(
|
||||
f"with characteristics that suggest device presence in proximity"
|
||||
)
|
||||
elif confidence == ConfidenceLevel.MEDIUM:
|
||||
return (
|
||||
f"{strength_info['label']}, {duration_info['label'].lower()} signal "
|
||||
f"that may indicate device activity"
|
||||
)
|
||||
return f"{strength_info['label']}, {duration_info['label'].lower()} signal that may indicate device activity"
|
||||
else:
|
||||
return (
|
||||
f"{duration_info['modifier'].capitalize()} {strength_info['label'].lower()} signal "
|
||||
@@ -345,7 +347,7 @@ def _build_interpretation(
|
||||
"""Build interpretation text with appropriate hedging."""
|
||||
strength_info = SIGNAL_STRENGTH_DESCRIPTIONS[strength]
|
||||
|
||||
base = strength_info['interpretation']
|
||||
base = strength_info["interpretation"]
|
||||
|
||||
if confidence == ConfidenceLevel.HIGH:
|
||||
return f"Observed signal characteristics suggest {base}"
|
||||
@@ -365,29 +367,22 @@ def _build_caveats(
|
||||
|
||||
# Always include general caveat
|
||||
caveats.append(
|
||||
"Signal strength is affected by environmental factors including walls, "
|
||||
"interference, and device orientation"
|
||||
"Signal strength is affected by environmental factors including walls, interference, and device orientation"
|
||||
)
|
||||
|
||||
# Strength-specific caveats
|
||||
if strength in (SignalStrength.MINIMAL, SignalStrength.WEAK):
|
||||
caveats.append(
|
||||
"Weak signals may represent background noise, distant devices, "
|
||||
"or heavily obstructed sources"
|
||||
)
|
||||
caveats.append("Weak signals may represent background noise, distant devices, or heavily obstructed sources")
|
||||
|
||||
# Duration-specific caveats
|
||||
if duration == DetectionDuration.TRANSIENT:
|
||||
caveats.append(
|
||||
"Brief detection may indicate passing device, intermittent transmission, "
|
||||
"or momentary interference"
|
||||
"Brief detection may indicate passing device, intermittent transmission, or momentary interference"
|
||||
)
|
||||
|
||||
# Confidence-specific caveats
|
||||
if confidence == ConfidenceLevel.LOW:
|
||||
caveats.append(
|
||||
"Insufficient data for reliable assessment; additional observation recommended"
|
||||
)
|
||||
caveats.append("Insufficient data for reliable assessment; additional observation recommended")
|
||||
|
||||
return caveats
|
||||
|
||||
@@ -396,6 +391,7 @@ def _build_caveats(
|
||||
# Client-Facing Language Generators
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def describe_signal_for_report(
|
||||
rssi: float | int | None,
|
||||
duration_seconds: float | int | None = None,
|
||||
@@ -418,24 +414,24 @@ def describe_signal_for_report(
|
||||
range_estimate = _estimate_range(rssi)
|
||||
|
||||
return {
|
||||
'headline': f"{assessment.strength_label} {protocol} signal, {assessment.duration_label.lower()}",
|
||||
'description': assessment.summary,
|
||||
'interpretation': assessment.interpretation,
|
||||
'technical': {
|
||||
'rssi_dbm': rssi,
|
||||
'strength_category': assessment.signal_strength.value,
|
||||
'duration_seconds': duration_seconds,
|
||||
'duration_category': assessment.detection_duration.value,
|
||||
'observations': observation_count,
|
||||
"headline": f"{assessment.strength_label} {protocol} signal, {assessment.duration_label.lower()}",
|
||||
"description": assessment.summary,
|
||||
"interpretation": assessment.interpretation,
|
||||
"technical": {
|
||||
"rssi_dbm": rssi,
|
||||
"strength_category": assessment.signal_strength.value,
|
||||
"duration_seconds": duration_seconds,
|
||||
"duration_category": assessment.detection_duration.value,
|
||||
"observations": observation_count,
|
||||
},
|
||||
'range_estimate': range_estimate,
|
||||
'confidence': assessment.confidence.value,
|
||||
'confidence_factors': {
|
||||
'signal_strength': assessment.strength_label,
|
||||
'detection_duration': assessment.duration_label,
|
||||
'observation_count': observation_count,
|
||||
"range_estimate": range_estimate,
|
||||
"confidence": assessment.confidence.value,
|
||||
"confidence_factors": {
|
||||
"signal_strength": assessment.strength_label,
|
||||
"detection_duration": assessment.duration_label,
|
||||
"observation_count": observation_count,
|
||||
},
|
||||
'caveats': assessment.caveats,
|
||||
"caveats": assessment.caveats,
|
||||
}
|
||||
|
||||
|
||||
@@ -447,16 +443,16 @@ def _estimate_range(rssi: float | int | None) -> dict:
|
||||
"""
|
||||
if rssi is None:
|
||||
return {
|
||||
'estimate': 'Unknown',
|
||||
'disclaimer': 'Insufficient signal data for range estimation',
|
||||
"estimate": "Unknown",
|
||||
"disclaimer": "Insufficient signal data for range estimation",
|
||||
}
|
||||
|
||||
try:
|
||||
rssi_val = float(rssi)
|
||||
except (ValueError, TypeError):
|
||||
return {
|
||||
'estimate': 'Unknown',
|
||||
'disclaimer': 'Invalid signal data',
|
||||
"estimate": "Unknown",
|
||||
"disclaimer": "Invalid signal data",
|
||||
}
|
||||
|
||||
# Very rough estimates based on free-space path loss
|
||||
@@ -478,10 +474,10 @@ def _estimate_range(rssi: float | int | None) -> dict:
|
||||
range_min, range_max = 30, None
|
||||
|
||||
return {
|
||||
'estimate': estimate,
|
||||
'range_min_meters': range_min,
|
||||
'range_max_meters': range_max,
|
||||
'disclaimer': (
|
||||
"estimate": estimate,
|
||||
"range_min_meters": range_min,
|
||||
"range_max_meters": range_max,
|
||||
"disclaimer": (
|
||||
"Range estimates are approximate and significantly affected by "
|
||||
"walls, interference, antenna characteristics, and transmit power"
|
||||
),
|
||||
@@ -505,19 +501,19 @@ def format_signal_for_dashboard(
|
||||
duration = classify_duration(duration_seconds)
|
||||
|
||||
colors = {
|
||||
SignalStrength.MINIMAL: '#888888', # Gray
|
||||
SignalStrength.WEAK: '#6baed6', # Light blue
|
||||
SignalStrength.MODERATE: '#3182bd', # Blue
|
||||
SignalStrength.STRONG: '#fd8d3c', # Orange
|
||||
SignalStrength.VERY_STRONG: '#e6550d', # Red-orange
|
||||
SignalStrength.MINIMAL: "#888888", # Gray
|
||||
SignalStrength.WEAK: "#6baed6", # Light blue
|
||||
SignalStrength.MODERATE: "#3182bd", # Blue
|
||||
SignalStrength.STRONG: "#fd8d3c", # Orange
|
||||
SignalStrength.VERY_STRONG: "#e6550d", # Red-orange
|
||||
}
|
||||
|
||||
icons = {
|
||||
SignalStrength.MINIMAL: 'signal-0',
|
||||
SignalStrength.WEAK: 'signal-1',
|
||||
SignalStrength.MODERATE: 'signal-2',
|
||||
SignalStrength.STRONG: 'signal-3',
|
||||
SignalStrength.VERY_STRONG: 'signal-4',
|
||||
SignalStrength.MINIMAL: "signal-0",
|
||||
SignalStrength.WEAK: "signal-1",
|
||||
SignalStrength.MODERATE: "signal-2",
|
||||
SignalStrength.STRONG: "signal-3",
|
||||
SignalStrength.VERY_STRONG: "signal-4",
|
||||
}
|
||||
|
||||
strength_info = SIGNAL_STRENGTH_DESCRIPTIONS[strength]
|
||||
@@ -528,12 +524,12 @@ def format_signal_for_dashboard(
|
||||
tooltip += f", {duration_info['modifier']}"
|
||||
|
||||
return {
|
||||
'label': strength_info['label'],
|
||||
'color': colors[strength],
|
||||
'icon': icons[strength],
|
||||
'tooltip': tooltip,
|
||||
'strength': strength.value,
|
||||
'duration': duration.value,
|
||||
"label": strength_info["label"],
|
||||
"color": colors[strength],
|
||||
"icon": icons[strength],
|
||||
"tooltip": tooltip,
|
||||
"strength": strength.value,
|
||||
"duration": duration.value,
|
||||
}
|
||||
|
||||
|
||||
@@ -543,38 +539,38 @@ def format_signal_for_dashboard(
|
||||
|
||||
# Vocabulary for generating hedged statements
|
||||
HEDGED_VERBS = {
|
||||
'high_confidence': [
|
||||
'suggests',
|
||||
'indicates',
|
||||
'is consistent with',
|
||||
"high_confidence": [
|
||||
"suggests",
|
||||
"indicates",
|
||||
"is consistent with",
|
||||
],
|
||||
'medium_confidence': [
|
||||
'may indicate',
|
||||
'could suggest',
|
||||
'is potentially consistent with',
|
||||
"medium_confidence": [
|
||||
"may indicate",
|
||||
"could suggest",
|
||||
"is potentially consistent with",
|
||||
],
|
||||
'low_confidence': [
|
||||
'might represent',
|
||||
'could possibly indicate',
|
||||
'may or may not suggest',
|
||||
"low_confidence": [
|
||||
"might represent",
|
||||
"could possibly indicate",
|
||||
"may or may not suggest",
|
||||
],
|
||||
}
|
||||
|
||||
HEDGED_CONCLUSIONS = {
|
||||
'device_presence': {
|
||||
'high': 'likely device presence in proximity',
|
||||
'medium': 'possible device activity',
|
||||
'low': 'potential device presence, though environmental factors cannot be ruled out',
|
||||
"device_presence": {
|
||||
"high": "likely device presence in proximity",
|
||||
"medium": "possible device activity",
|
||||
"low": "potential device presence, though environmental factors cannot be ruled out",
|
||||
},
|
||||
'surveillance_indicator': {
|
||||
'high': 'characteristics warranting further investigation',
|
||||
'medium': 'pattern that may warrant review',
|
||||
'low': 'inconclusive pattern requiring additional data',
|
||||
"surveillance_indicator": {
|
||||
"high": "characteristics warranting further investigation",
|
||||
"medium": "pattern that may warrant review",
|
||||
"low": "inconclusive pattern requiring additional data",
|
||||
},
|
||||
'location': {
|
||||
'high': 'probable location within estimated range',
|
||||
'medium': 'possible location in general vicinity',
|
||||
'low': 'uncertain location; signal could originate from various distances',
|
||||
"location": {
|
||||
"high": "probable location within estimated range",
|
||||
"medium": "possible location in general vicinity",
|
||||
"low": "uncertain location; signal could originate from various distances",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -600,9 +596,9 @@ def generate_hedged_statement(
|
||||
else:
|
||||
conf_key = str(confidence).lower()
|
||||
|
||||
verbs = HEDGED_VERBS.get(f'{conf_key}_confidence', HEDGED_VERBS['low_confidence'])
|
||||
verbs = HEDGED_VERBS.get(f"{conf_key}_confidence", HEDGED_VERBS["low_confidence"])
|
||||
conclusions = HEDGED_CONCLUSIONS.get(conclusion_type, {})
|
||||
conclusion = conclusions.get(conf_key, conclusions.get('low', 'an inconclusive pattern'))
|
||||
conclusion = conclusions.get(conf_key, conclusions.get("low", "an inconclusive pattern"))
|
||||
|
||||
verb = verbs[0] # Use first verb for consistency
|
||||
|
||||
|
||||
Reference in New Issue
Block a user