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:
James Smith
2026-07-05 14:48:11 +01:00
parent 82e64104fe
commit 96172ca593
189 changed files with 19883 additions and 19552 deletions
+250 -266
View File
@@ -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)