mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Add comprehensive BLE tracker detection with signature engine
Implement reliable tracker detection for AirTag, Tile, Samsung SmartTag, and other BLE trackers based on manufacturer data patterns, service UUIDs, and advertising payload analysis. Key changes: - Add TrackerSignatureEngine with signatures for major tracker brands - Device fingerprinting to track devices across MAC randomization - Suspicious presence heuristics (persistence, following patterns) - New API endpoints: /api/bluetooth/trackers, /diagnostics - UI updates with tracker badges, confidence, and evidence display - TSCM integration updated to use v2 tracker detection data - Unit tests and smoke test scripts for validation Detection is heuristic-based with confidence scoring (high/medium/low) and evidence transparency. Backwards compatible with existing APIs. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,9 @@ from utils.bluetooth import (
|
||||
get_bluetooth_scanner,
|
||||
check_capabilities,
|
||||
RANGE_UNKNOWN,
|
||||
TrackerType,
|
||||
TrackerConfidence,
|
||||
get_tracker_engine,
|
||||
)
|
||||
from utils.database import get_db
|
||||
from utils.sse import format_sse
|
||||
@@ -351,6 +354,333 @@ def get_device(device_id: str):
|
||||
return jsonify(device.to_dict())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TRACKER DETECTION ENDPOINTS (v2)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@bluetooth_v2_bp.route('/trackers', methods=['GET'])
|
||||
def list_trackers():
|
||||
"""
|
||||
List detected tracker devices with enriched tracker data.
|
||||
|
||||
This is the v2 tracker endpoint that provides comprehensive
|
||||
tracker detection results including confidence scores and evidence.
|
||||
|
||||
Query parameters:
|
||||
- min_confidence: Minimum confidence ('high', 'medium', 'low')
|
||||
- max_age: Maximum age in seconds (default: 300)
|
||||
- include_risk: Include risk analysis (default: true)
|
||||
|
||||
Returns:
|
||||
JSON with detected trackers and their analysis.
|
||||
"""
|
||||
scanner = get_bluetooth_scanner()
|
||||
|
||||
# Parse query parameters
|
||||
min_confidence = request.args.get('min_confidence', 'low')
|
||||
max_age = request.args.get('max_age', 300, type=float)
|
||||
include_risk = request.args.get('include_risk', 'true').lower() == 'true'
|
||||
|
||||
# Get all devices
|
||||
devices = scanner.get_devices(max_age_seconds=max_age)
|
||||
|
||||
# Filter to only trackers
|
||||
trackers = [d for d in devices if d.is_tracker]
|
||||
|
||||
# Filter by confidence level if specified
|
||||
confidence_order = {'high': 3, 'medium': 2, 'low': 1, 'none': 0}
|
||||
min_conf_level = confidence_order.get(min_confidence.lower(), 1)
|
||||
trackers = [
|
||||
t for t in trackers
|
||||
if confidence_order.get(t.tracker_confidence, 0) >= min_conf_level
|
||||
]
|
||||
|
||||
# Build response
|
||||
tracker_list = []
|
||||
for device in trackers:
|
||||
tracker_info = {
|
||||
'device_id': device.device_id,
|
||||
'device_key': device.device_key,
|
||||
'address': device.address,
|
||||
'address_type': device.address_type,
|
||||
'name': device.name,
|
||||
|
||||
# Tracker detection details
|
||||
'tracker': {
|
||||
'type': device.tracker_type,
|
||||
'name': device.tracker_name,
|
||||
'confidence': device.tracker_confidence,
|
||||
'confidence_score': round(device.tracker_confidence_score, 2),
|
||||
'evidence': device.tracker_evidence,
|
||||
},
|
||||
|
||||
# Location/proximity
|
||||
'rssi_current': device.rssi_current,
|
||||
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
|
||||
'proximity_band': device.proximity_band,
|
||||
'estimated_distance_m': round(device.estimated_distance_m, 2) if device.estimated_distance_m else None,
|
||||
|
||||
# Timing
|
||||
'first_seen': device.first_seen.isoformat(),
|
||||
'last_seen': device.last_seen.isoformat(),
|
||||
'age_seconds': round(device.age_seconds, 1),
|
||||
'seen_count': device.seen_count,
|
||||
'duration_seconds': round(device.duration_seconds, 1),
|
||||
|
||||
# Status
|
||||
'is_new': device.is_new,
|
||||
'in_baseline': device.in_baseline,
|
||||
|
||||
# Fingerprint for cross-MAC tracking
|
||||
'fingerprint_id': device.payload_fingerprint_id,
|
||||
}
|
||||
|
||||
# Include risk analysis if requested
|
||||
if include_risk:
|
||||
tracker_info['risk_analysis'] = {
|
||||
'risk_score': round(device.risk_score, 2),
|
||||
'risk_factors': device.risk_factors,
|
||||
}
|
||||
|
||||
tracker_list.append(tracker_info)
|
||||
|
||||
# Sort by risk score (highest first), then confidence
|
||||
tracker_list.sort(
|
||||
key=lambda t: (
|
||||
t.get('risk_analysis', {}).get('risk_score', 0),
|
||||
confidence_order.get(t['tracker']['confidence'], 0)
|
||||
),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'count': len(tracker_list),
|
||||
'scan_active': scanner.is_scanning,
|
||||
'trackers': tracker_list,
|
||||
'summary': {
|
||||
'high_confidence': sum(1 for t in tracker_list if t['tracker']['confidence'] == 'high'),
|
||||
'medium_confidence': sum(1 for t in tracker_list if t['tracker']['confidence'] == 'medium'),
|
||||
'low_confidence': sum(1 for t in tracker_list if t['tracker']['confidence'] == 'low'),
|
||||
'high_risk': sum(1 for t in tracker_list if t.get('risk_analysis', {}).get('risk_score', 0) >= 0.5),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@bluetooth_v2_bp.route('/trackers/<device_id>', methods=['GET'])
|
||||
def get_tracker_detail(device_id: str):
|
||||
"""
|
||||
Get detailed tracker information for investigation.
|
||||
|
||||
Provides comprehensive data about a specific tracker including:
|
||||
- Full tracker detection analysis
|
||||
- Risk assessment with factors
|
||||
- RSSI history and timeline
|
||||
- Raw advertising payload data
|
||||
- Fingerprint information
|
||||
|
||||
Path parameters:
|
||||
- device_id: Device identifier (address:address_type)
|
||||
|
||||
Returns:
|
||||
JSON with full tracker investigation data.
|
||||
"""
|
||||
scanner = get_bluetooth_scanner()
|
||||
device = scanner.get_device(device_id)
|
||||
|
||||
if not device:
|
||||
return jsonify({'error': 'Device not found'}), 404
|
||||
|
||||
# Get RSSI history for timeline
|
||||
rssi_history = device.get_rssi_history(max_points=100)
|
||||
|
||||
# Build comprehensive response
|
||||
return jsonify({
|
||||
'device_id': device.device_id,
|
||||
'device_key': device.device_key,
|
||||
'address': device.address,
|
||||
'address_type': device.address_type,
|
||||
'name': device.name,
|
||||
'manufacturer_name': device.manufacturer_name,
|
||||
'manufacturer_id': device.manufacturer_id,
|
||||
|
||||
# Tracker detection
|
||||
'tracker': {
|
||||
'is_tracker': device.is_tracker,
|
||||
'type': device.tracker_type,
|
||||
'name': device.tracker_name,
|
||||
'confidence': device.tracker_confidence,
|
||||
'confidence_score': round(device.tracker_confidence_score, 2),
|
||||
'evidence': device.tracker_evidence,
|
||||
},
|
||||
|
||||
# Risk analysis
|
||||
'risk_analysis': {
|
||||
'risk_score': round(device.risk_score, 2),
|
||||
'risk_factors': device.risk_factors,
|
||||
'warning': 'Risk scores are heuristic indicators only. They do NOT prove malicious intent.',
|
||||
},
|
||||
|
||||
# Fingerprint (for MAC randomization tracking)
|
||||
'fingerprint': {
|
||||
'id': device.payload_fingerprint_id,
|
||||
'stability': round(device.payload_fingerprint_stability, 2),
|
||||
'note': 'Fingerprints help track devices across MAC address changes but are probabilistic.',
|
||||
},
|
||||
|
||||
# Signal data
|
||||
'signal': {
|
||||
'rssi_current': device.rssi_current,
|
||||
'rssi_median': round(device.rssi_median, 1) if device.rssi_median else None,
|
||||
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
|
||||
'rssi_min': device.rssi_min,
|
||||
'rssi_max': device.rssi_max,
|
||||
'rssi_variance': round(device.rssi_variance, 2) if device.rssi_variance else None,
|
||||
'tx_power': device.tx_power,
|
||||
},
|
||||
|
||||
# Proximity
|
||||
'proximity': {
|
||||
'band': device.proximity_band,
|
||||
'estimated_distance_m': round(device.estimated_distance_m, 2) if device.estimated_distance_m else None,
|
||||
'confidence': round(device.distance_confidence, 2),
|
||||
},
|
||||
|
||||
# Timeline / sightings
|
||||
'timeline': {
|
||||
'first_seen': device.first_seen.isoformat(),
|
||||
'last_seen': device.last_seen.isoformat(),
|
||||
'age_seconds': round(device.age_seconds, 1),
|
||||
'duration_seconds': round(device.duration_seconds, 1),
|
||||
'seen_count': device.seen_count,
|
||||
'seen_rate': round(device.seen_rate, 2),
|
||||
'rssi_history': rssi_history,
|
||||
},
|
||||
|
||||
# Raw advertisement data for investigation
|
||||
'raw_data': {
|
||||
'manufacturer_id_hex': f'0x{device.manufacturer_id:04X}' if device.manufacturer_id else None,
|
||||
'manufacturer_data_hex': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
|
||||
'service_uuids': device.service_uuids,
|
||||
'service_data': {k: v.hex() for k, v in device.service_data.items()},
|
||||
'appearance': device.appearance,
|
||||
},
|
||||
|
||||
# Heuristics
|
||||
'heuristics': {
|
||||
'is_new': device.is_new,
|
||||
'is_persistent': device.is_persistent,
|
||||
'is_beacon_like': device.is_beacon_like,
|
||||
'is_strong_stable': device.is_strong_stable,
|
||||
'has_random_address': device.has_random_address,
|
||||
'is_randomized_mac': device.is_randomized_mac,
|
||||
},
|
||||
|
||||
# Baseline status
|
||||
'baseline': {
|
||||
'in_baseline': device.in_baseline,
|
||||
'baseline_id': device.baseline_id,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@bluetooth_v2_bp.route('/diagnostics', methods=['GET'])
|
||||
def get_diagnostics():
|
||||
"""
|
||||
Get Bluetooth system diagnostics for troubleshooting.
|
||||
|
||||
Returns detailed information about:
|
||||
- Adapter status and capabilities
|
||||
- BlueZ version and DBus access
|
||||
- Permissions and access issues
|
||||
- Available scan backends
|
||||
- Recent errors
|
||||
|
||||
Returns:
|
||||
JSON with diagnostic information.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
caps = check_capabilities()
|
||||
|
||||
diagnostics = {
|
||||
'system': {
|
||||
'is_root': os.geteuid() == 0 if hasattr(os, 'geteuid') else False,
|
||||
'platform': os.uname().sysname if hasattr(os, 'uname') else 'unknown',
|
||||
},
|
||||
|
||||
'bluez': {
|
||||
'has_bluez': caps.has_bluez,
|
||||
'version': caps.bluez_version,
|
||||
'has_dbus': caps.has_dbus,
|
||||
},
|
||||
|
||||
'adapters': {
|
||||
'count': len(caps.adapters),
|
||||
'default': caps.default_adapter,
|
||||
'list': caps.adapters,
|
||||
},
|
||||
|
||||
'permissions': {
|
||||
'has_bluetooth_permission': caps.has_bluetooth_permission,
|
||||
'is_soft_blocked': caps.is_soft_blocked,
|
||||
'is_hard_blocked': caps.is_hard_blocked,
|
||||
},
|
||||
|
||||
'backends': {
|
||||
'recommended': caps.recommended_backend,
|
||||
'available': {
|
||||
'dbus': caps.has_dbus and caps.has_bluez,
|
||||
'bleak': caps.has_bleak,
|
||||
'hcitool': caps.has_hcitool,
|
||||
'bluetoothctl': caps.has_bluetoothctl,
|
||||
'btmgmt': caps.has_btmgmt,
|
||||
},
|
||||
},
|
||||
|
||||
'can_scan': caps.can_scan,
|
||||
'issues': caps.issues,
|
||||
|
||||
'recommendations': [],
|
||||
}
|
||||
|
||||
# Add recommendations based on issues
|
||||
if not caps.can_scan:
|
||||
diagnostics['recommendations'].append(
|
||||
'No scanning backends available. Install BlueZ or ensure Bluetooth adapter is present.'
|
||||
)
|
||||
|
||||
if caps.is_soft_blocked:
|
||||
diagnostics['recommendations'].append(
|
||||
'Bluetooth is soft-blocked. Run: sudo rfkill unblock bluetooth'
|
||||
)
|
||||
|
||||
if caps.is_hard_blocked:
|
||||
diagnostics['recommendations'].append(
|
||||
'Bluetooth is hard-blocked (hardware switch). Enable Bluetooth on your device.'
|
||||
)
|
||||
|
||||
if not caps.has_bluetooth_permission and not diagnostics['system']['is_root']:
|
||||
diagnostics['recommendations'].append(
|
||||
'May need elevated permissions for BLE scanning. Try running with sudo or add user to bluetooth group.'
|
||||
)
|
||||
|
||||
if caps.has_dbus and caps.has_bluez and len(caps.adapters) == 0:
|
||||
diagnostics['recommendations'].append(
|
||||
'BlueZ is available but no adapters found. Check if Bluetooth adapter is connected and enabled.'
|
||||
)
|
||||
|
||||
# Check for btmon availability (useful for debugging)
|
||||
try:
|
||||
result = subprocess.run(['which', 'btmon'], capture_output=True, timeout=2)
|
||||
diagnostics['backends']['available']['btmon'] = result.returncode == 0
|
||||
except Exception:
|
||||
diagnostics['backends']['available']['btmon'] = False
|
||||
|
||||
return jsonify(diagnostics)
|
||||
|
||||
|
||||
@bluetooth_v2_bp.route('/baseline/set', methods=['POST'])
|
||||
def set_baseline():
|
||||
"""
|
||||
@@ -608,10 +938,10 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
|
||||
|
||||
devices = scanner.get_devices()
|
||||
|
||||
# Convert to TSCM format
|
||||
# Convert to TSCM format with tracker detection data
|
||||
tscm_devices = []
|
||||
for device in devices:
|
||||
tscm_devices.append({
|
||||
device_data = {
|
||||
'mac': device.address,
|
||||
'address_type': device.address_type,
|
||||
'device_key': device.device_key,
|
||||
@@ -621,6 +951,8 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
|
||||
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
|
||||
'type': _classify_device_type(device),
|
||||
'manufacturer': device.manufacturer_name,
|
||||
'manufacturer_id': device.manufacturer_id,
|
||||
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
|
||||
'protocol': device.protocol,
|
||||
'first_seen': device.first_seen.isoformat(),
|
||||
'last_seen': device.last_seen.isoformat(),
|
||||
@@ -639,7 +971,34 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
|
||||
'has_random_address': device.has_random_address,
|
||||
},
|
||||
'in_baseline': device.in_baseline,
|
||||
})
|
||||
|
||||
# Tracker detection data (v2)
|
||||
'tracker': {
|
||||
'is_tracker': device.is_tracker,
|
||||
'type': device.tracker_type,
|
||||
'name': device.tracker_name,
|
||||
'confidence': device.tracker_confidence,
|
||||
'confidence_score': round(device.tracker_confidence_score, 2),
|
||||
'evidence': device.tracker_evidence,
|
||||
},
|
||||
|
||||
# Risk analysis (v2)
|
||||
'risk_analysis': {
|
||||
'risk_score': round(device.risk_score, 2),
|
||||
'risk_factors': device.risk_factors,
|
||||
},
|
||||
|
||||
# Fingerprint for cross-MAC tracking (v2)
|
||||
'fingerprint': {
|
||||
'id': device.payload_fingerprint_id,
|
||||
'stability': round(device.payload_fingerprint_stability, 2),
|
||||
},
|
||||
|
||||
# Service UUIDs for analysis
|
||||
'service_uuids': device.service_uuids,
|
||||
}
|
||||
|
||||
tscm_devices.append(device_data)
|
||||
|
||||
return tscm_devices
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ const BluetoothMode = (function() {
|
||||
const isNew = card.dataset.isNew === 'true';
|
||||
const hasName = card.dataset.hasName === 'true';
|
||||
const rssi = parseInt(card.dataset.rssi) || -100;
|
||||
const isTracker = card.dataset.isTracker === 'true';
|
||||
|
||||
let visible = true;
|
||||
switch (currentDeviceFilter) {
|
||||
@@ -121,6 +122,9 @@ const BluetoothMode = (function() {
|
||||
case 'strong':
|
||||
visible = rssi >= -70;
|
||||
break;
|
||||
case 'trackers':
|
||||
visible = isTracker;
|
||||
break;
|
||||
case 'all':
|
||||
default:
|
||||
visible = true;
|
||||
@@ -321,11 +325,70 @@ const BluetoothMode = (function() {
|
||||
const badgesEl = document.getElementById('btDetailBadges');
|
||||
let badgesHtml = `<span class="bt-detail-badge ${protocol}">${protocol.toUpperCase()}</span>`;
|
||||
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
|
||||
|
||||
// Tracker badge
|
||||
if (device.is_tracker) {
|
||||
const conf = device.tracker_confidence || 'low';
|
||||
const confClass = conf === 'high' ? 'tracker-high' : conf === 'medium' ? 'tracker-medium' : 'tracker-low';
|
||||
const typeLabel = device.tracker_name || device.tracker_type || 'TRACKER';
|
||||
badgesHtml += `<span class="bt-detail-badge ${confClass}">${escapeHtml(typeLabel)}</span>`;
|
||||
}
|
||||
|
||||
flags.forEach(f => {
|
||||
badgesHtml += `<span class="bt-detail-badge flag">${f.replace(/_/g, ' ').toUpperCase()}</span>`;
|
||||
});
|
||||
badgesEl.innerHTML = badgesHtml;
|
||||
|
||||
// Tracker analysis section
|
||||
const trackerSection = document.getElementById('btDetailTrackerAnalysis');
|
||||
if (trackerSection) {
|
||||
if (device.is_tracker) {
|
||||
const confidence = device.tracker_confidence || 'low';
|
||||
const confScore = device.tracker_confidence_score || 0;
|
||||
const riskScore = device.risk_score || 0;
|
||||
const evidence = device.tracker_evidence || [];
|
||||
const riskFactors = device.risk_factors || [];
|
||||
|
||||
let trackerHtml = '<div class="bt-tracker-analysis">';
|
||||
trackerHtml += '<div class="bt-analysis-header">Tracker Detection Analysis</div>';
|
||||
|
||||
// Confidence
|
||||
const confColor = confidence === 'high' ? '#ef4444' : confidence === 'medium' ? '#f97316' : '#eab308';
|
||||
trackerHtml += '<div class="bt-analysis-row"><span class="bt-analysis-label">Confidence:</span><span style="color:' + confColor + ';font-weight:600;">' + confidence.toUpperCase() + ' (' + Math.round(confScore * 100) + '%)</span></div>';
|
||||
|
||||
// Evidence
|
||||
if (evidence.length > 0) {
|
||||
trackerHtml += '<div class="bt-analysis-section"><div class="bt-analysis-label">Evidence:</div><ul class="bt-evidence-list">';
|
||||
evidence.forEach(e => {
|
||||
trackerHtml += '<li>' + escapeHtml(e) + '</li>';
|
||||
});
|
||||
trackerHtml += '</ul></div>';
|
||||
}
|
||||
|
||||
// Risk analysis
|
||||
if (riskScore >= 0.1 || riskFactors.length > 0) {
|
||||
const riskColor = riskScore >= 0.5 ? '#ef4444' : riskScore >= 0.3 ? '#f97316' : '#888';
|
||||
trackerHtml += '<div class="bt-analysis-row"><span class="bt-analysis-label">Risk Score:</span><span style="color:' + riskColor + ';font-weight:600;">' + Math.round(riskScore * 100) + '%</span></div>';
|
||||
if (riskFactors.length > 0) {
|
||||
trackerHtml += '<div class="bt-analysis-section"><div class="bt-analysis-label">Risk Factors:</div><ul class="bt-evidence-list">';
|
||||
riskFactors.forEach(f => {
|
||||
trackerHtml += '<li>' + escapeHtml(f) + '</li>';
|
||||
});
|
||||
trackerHtml += '</ul></div>';
|
||||
}
|
||||
}
|
||||
|
||||
trackerHtml += '<div class="bt-analysis-warning">Note: Detection is heuristic-based. Results indicate patterns consistent with tracking devices but cannot prove intent.</div>';
|
||||
trackerHtml += '</div>';
|
||||
|
||||
trackerSection.style.display = 'block';
|
||||
trackerSection.innerHTML = trackerHtml;
|
||||
} else {
|
||||
trackerSection.style.display = 'none';
|
||||
trackerSection.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Stats grid
|
||||
document.getElementById('btDetailMfr').textContent = device.manufacturer_name || '--';
|
||||
document.getElementById('btDetailMfrId').textContent = device.manufacturer_id != null
|
||||
@@ -671,7 +734,6 @@ const BluetoothMode = (function() {
|
||||
deviceStats.trackers = [];
|
||||
|
||||
devices.forEach(d => {
|
||||
const name = (d.name || '').toLowerCase();
|
||||
const rssi = d.rssi_current;
|
||||
|
||||
// Signal strength classification
|
||||
@@ -681,12 +743,9 @@ const BluetoothMode = (function() {
|
||||
else deviceStats.weak++;
|
||||
}
|
||||
|
||||
// Tracker detection - check for known tracker patterns
|
||||
const isTracker = name.includes('tile') || name.includes('airtag') ||
|
||||
name.includes('smarttag') || name.includes('chipolo') ||
|
||||
name.includes('tracker') || name.includes('tag');
|
||||
|
||||
if (isTracker) {
|
||||
// Use actual tracker detection from backend (v2)
|
||||
// The is_tracker field comes from the TrackerSignatureEngine
|
||||
if (d.is_tracker === true) {
|
||||
if (!deviceStats.trackers.find(t => t.address === d.address)) {
|
||||
deviceStats.trackers.push(d);
|
||||
}
|
||||
@@ -714,23 +773,67 @@ const BluetoothMode = (function() {
|
||||
if (mediumCount) mediumCount.textContent = deviceStats.medium;
|
||||
if (weakCount) weakCount.textContent = deviceStats.weak;
|
||||
|
||||
// Tracker Detection
|
||||
// Tracker Detection - Enhanced display with confidence and evidence
|
||||
const trackerList = document.getElementById('btTrackerList');
|
||||
if (trackerList) {
|
||||
if (devices.size === 0) {
|
||||
trackerList.innerHTML = '<div style="color:#666;padding:10px;text-align:center;font-size:11px;">Start scanning to detect trackers</div>';
|
||||
} else if (deviceStats.trackers.length === 0) {
|
||||
trackerList.innerHTML = '<div style="color:#22c55e;padding:10px;text-align:center;font-size:11px;">✓ No known trackers detected</div>';
|
||||
trackerList.innerHTML = '<div style="color:#22c55e;padding:10px;text-align:center;font-size:11px;">No trackers detected</div>';
|
||||
} else {
|
||||
trackerList.innerHTML = deviceStats.trackers.map(t => `
|
||||
<div style="padding:8px;border-bottom:1px solid rgba(255,255,255,0.05);cursor:pointer;" onclick="BluetoothMode.selectDevice('${t.device_id}')">
|
||||
<div style="display:flex;justify-content:space-between;">
|
||||
<span style="color:#f97316;font-size:11px;">${escapeHtml(t.name || formatDeviceId(t.address))}</span>
|
||||
<span style="color:#666;font-size:10px;">${t.rssi_current || '--'} dBm</span>
|
||||
</div>
|
||||
<div style="font-size:9px;color:#666;font-family:monospace;">${t.address}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
// Sort by risk score (highest first), then confidence
|
||||
const sortedTrackers = [...deviceStats.trackers].sort((a, b) => {
|
||||
const riskA = a.risk_score || 0;
|
||||
const riskB = b.risk_score || 0;
|
||||
if (riskB !== riskA) return riskB - riskA;
|
||||
const confA = a.tracker_confidence_score || 0;
|
||||
const confB = b.tracker_confidence_score || 0;
|
||||
return confB - confA;
|
||||
});
|
||||
|
||||
trackerList.innerHTML = sortedTrackers.map(t => {
|
||||
// Get tracker type badge color based on confidence
|
||||
const confidence = t.tracker_confidence || 'low';
|
||||
const confColor = confidence === 'high' ? '#ef4444' :
|
||||
confidence === 'medium' ? '#f97316' : '#eab308';
|
||||
const confBg = confidence === 'high' ? 'rgba(239,68,68,0.2)' :
|
||||
confidence === 'medium' ? 'rgba(249,115,22,0.2)' : 'rgba(234,179,8,0.2)';
|
||||
|
||||
// Risk score indicator
|
||||
const riskScore = t.risk_score || 0;
|
||||
const riskColor = riskScore >= 0.5 ? '#ef4444' : riskScore >= 0.3 ? '#f97316' : '#666';
|
||||
|
||||
// Tracker type label
|
||||
const trackerType = t.tracker_name || t.tracker_type || 'Unknown Tracker';
|
||||
|
||||
// Build evidence tooltip (first 2 items)
|
||||
const evidence = (t.tracker_evidence || []).slice(0, 2);
|
||||
const evidenceHtml = evidence.length > 0
|
||||
? '<div style="font-size:9px;color:#888;margin-top:3px;font-style:italic;">' +
|
||||
evidence.map(e => '• ' + escapeHtml(e)).join('<br>') +
|
||||
'</div>'
|
||||
: '';
|
||||
|
||||
const deviceIdEscaped = escapeHtml(t.device_id).replace(/'/g, "\\'");
|
||||
|
||||
return '<div class="bt-tracker-item" style="padding:8px;border-bottom:1px solid rgba(255,255,255,0.05);cursor:pointer;" onclick="BluetoothMode.selectDevice(\'' + deviceIdEscaped + '\')">' +
|
||||
'<div style="display:flex;justify-content:space-between;align-items:center;">' +
|
||||
'<div style="display:flex;align-items:center;gap:6px;">' +
|
||||
'<span style="background:' + confBg + ';color:' + confColor + ';font-size:9px;padding:2px 5px;border-radius:3px;font-weight:600;">' + confidence.toUpperCase() + '</span>' +
|
||||
'<span style="color:#fff;font-size:11px;">' + escapeHtml(trackerType) + '</span>' +
|
||||
'</div>' +
|
||||
'<div style="display:flex;align-items:center;gap:8px;">' +
|
||||
(riskScore >= 0.3 ? '<span style="color:' + riskColor + ';font-size:9px;font-weight:600;">RISK ' + Math.round(riskScore * 100) + '%</span>' : '') +
|
||||
'<span style="color:#666;font-size:10px;">' + (t.rssi_current || '--') + ' dBm</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div style="display:flex;justify-content:space-between;margin-top:3px;">' +
|
||||
'<span style="font-size:9px;color:#888;font-family:monospace;">' + t.address + '</span>' +
|
||||
'<span style="font-size:9px;color:#666;">Seen ' + (t.seen_count || 0) + 'x</span>' +
|
||||
'</div>' +
|
||||
evidenceHtml +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -769,6 +872,10 @@ const BluetoothMode = (function() {
|
||||
const inBaseline = device.in_baseline || false;
|
||||
const isNew = !inBaseline;
|
||||
const hasName = !!device.name;
|
||||
const isTracker = device.is_tracker === true;
|
||||
const trackerType = device.tracker_type;
|
||||
const trackerConfidence = device.tracker_confidence;
|
||||
const riskScore = device.risk_score || 0;
|
||||
|
||||
// Calculate RSSI bar width (0-100%)
|
||||
// RSSI typically ranges from -100 (weak) to -30 (very strong)
|
||||
@@ -786,10 +893,37 @@ const BluetoothMode = (function() {
|
||||
? '<span class="bt-proto-badge ble">BLE</span>'
|
||||
: '<span class="bt-proto-badge classic">CLASSIC</span>';
|
||||
|
||||
// Tracker badge - show if device is detected as tracker
|
||||
let trackerBadge = '';
|
||||
if (isTracker) {
|
||||
const confColor = trackerConfidence === 'high' ? '#ef4444' :
|
||||
trackerConfidence === 'medium' ? '#f97316' : '#eab308';
|
||||
const confBg = trackerConfidence === 'high' ? 'rgba(239,68,68,0.15)' :
|
||||
trackerConfidence === 'medium' ? 'rgba(249,115,22,0.15)' : 'rgba(234,179,8,0.15)';
|
||||
const typeLabel = trackerType === 'airtag' ? 'AirTag' :
|
||||
trackerType === 'tile' ? 'Tile' :
|
||||
trackerType === 'samsung_smarttag' ? 'SmartTag' :
|
||||
trackerType === 'findmy_accessory' ? 'FindMy' :
|
||||
trackerType === 'chipolo' ? 'Chipolo' : 'TRACKER';
|
||||
trackerBadge = '<span class="bt-tracker-badge" style="background:' + confBg + ';color:' + confColor + ';font-size:9px;padding:1px 4px;border-radius:3px;margin-left:4px;font-weight:600;">' + typeLabel + '</span>';
|
||||
}
|
||||
|
||||
// Risk badge - show if risk score is significant
|
||||
let riskBadge = '';
|
||||
if (riskScore >= 0.3) {
|
||||
const riskColor = riskScore >= 0.5 ? '#ef4444' : '#f97316';
|
||||
riskBadge = '<span class="bt-risk-badge" style="color:' + riskColor + ';font-size:8px;margin-left:4px;font-weight:600;">' + Math.round(riskScore * 100) + '% RISK</span>';
|
||||
}
|
||||
|
||||
// Status indicator
|
||||
const statusDot = isNew
|
||||
? '<span class="bt-status-dot new"></span>'
|
||||
: '<span class="bt-status-dot known"></span>';
|
||||
let statusDot;
|
||||
if (isTracker && trackerConfidence === 'high') {
|
||||
statusDot = '<span class="bt-status-dot tracker" style="background:#ef4444;"></span>';
|
||||
} else if (isNew) {
|
||||
statusDot = '<span class="bt-status-dot new"></span>';
|
||||
} else {
|
||||
statusDot = '<span class="bt-status-dot known"></span>';
|
||||
}
|
||||
|
||||
// Build secondary info line
|
||||
let secondaryParts = [addr];
|
||||
@@ -797,11 +931,17 @@ const BluetoothMode = (function() {
|
||||
secondaryParts.push('Seen ' + seenCount + '×');
|
||||
const secondaryInfo = secondaryParts.join(' · ');
|
||||
|
||||
return '<div class="bt-device-row" data-bt-device-id="' + escapeHtml(device.device_id) + '" data-is-new="' + isNew + '" data-has-name="' + hasName + '" data-rssi="' + (rssi || -100) + '" onclick="BluetoothMode.selectDevice(\'' + deviceIdEscaped + '\')" style="border-left-color:' + rssiColor + ';">' +
|
||||
// Row border color - highlight trackers in red/orange
|
||||
const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' :
|
||||
isTracker ? '#f97316' : rssiColor;
|
||||
|
||||
return '<div class="bt-device-row' + (isTracker ? ' is-tracker' : '') + '" data-bt-device-id="' + escapeHtml(device.device_id) + '" data-is-new="' + isNew + '" data-has-name="' + hasName + '" data-rssi="' + (rssi || -100) + '" data-is-tracker="' + isTracker + '" onclick="BluetoothMode.selectDevice(\'' + deviceIdEscaped + '\')" style="border-left-color:' + borderColor + ';">' +
|
||||
'<div class="bt-row-main">' +
|
||||
'<div class="bt-row-left">' +
|
||||
protoBadge +
|
||||
'<span class="bt-device-name">' + name + '</span>' +
|
||||
trackerBadge +
|
||||
riskBadge +
|
||||
'</div>' +
|
||||
'<div class="bt-row-right">' +
|
||||
'<div class="bt-rssi-container">' +
|
||||
|
||||
318
tests/smoke_test_bluetooth.py
Normal file
318
tests/smoke_test_bluetooth.py
Normal file
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Smoke Test for Bluetooth API Backwards Compatibility
|
||||
|
||||
Run this script against a running INTERCEPT server to verify:
|
||||
1. Existing v1/v2 endpoints still work
|
||||
2. New tracker endpoints work
|
||||
3. TSCM integration is not broken
|
||||
4. JSON schemas are compatible
|
||||
|
||||
Usage:
|
||||
python tests/smoke_test_bluetooth.py [--host HOST] [--port PORT]
|
||||
|
||||
Requirements:
|
||||
- INTERCEPT server must be running
|
||||
- requests library: pip install requests
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("Error: requests library required. Install with: pip install requests")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TEST CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 5000
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SCHEMA VALIDATORS
|
||||
# =============================================================================
|
||||
|
||||
def validate_device_schema(device: dict, context: str = "") -> list[str]:
|
||||
"""Validate that a device dict has expected fields (backwards compatible)."""
|
||||
errors = []
|
||||
required_fields = [
|
||||
'device_id', 'address', 'rssi_current', 'last_seen', 'seen_count'
|
||||
]
|
||||
|
||||
for field in required_fields:
|
||||
if field not in device:
|
||||
errors.append(f"{context}Missing required field: {field}")
|
||||
|
||||
# New tracker fields should be present (v2) but are optional
|
||||
tracker_fields = ['is_tracker', 'tracker_type', 'tracker_confidence']
|
||||
for field in tracker_fields:
|
||||
if field in device:
|
||||
# Field exists, check type
|
||||
if field == 'is_tracker' and not isinstance(device[field], bool):
|
||||
errors.append(f"{context}is_tracker should be bool, got {type(device[field])}")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_tracker_schema(tracker: dict, context: str = "") -> list[str]:
|
||||
"""Validate tracker endpoint response schema."""
|
||||
errors = []
|
||||
|
||||
required_fields = [
|
||||
'device_id', 'address', 'tracker'
|
||||
]
|
||||
for field in required_fields:
|
||||
if field not in tracker:
|
||||
errors.append(f"{context}Missing required field: {field}")
|
||||
|
||||
# Tracker sub-object
|
||||
if 'tracker' in tracker:
|
||||
tracker_obj = tracker['tracker']
|
||||
tracker_required = ['type', 'confidence', 'evidence']
|
||||
for field in tracker_required:
|
||||
if field not in tracker_obj:
|
||||
errors.append(f"{context}tracker.{field} missing")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_diagnostics_schema(diagnostics: dict) -> list[str]:
|
||||
"""Validate diagnostics endpoint response schema."""
|
||||
errors = []
|
||||
|
||||
required_sections = ['system', 'bluez', 'adapters', 'permissions', 'backends']
|
||||
for section in required_sections:
|
||||
if section not in diagnostics:
|
||||
errors.append(f"Missing diagnostics section: {section}")
|
||||
|
||||
if 'can_scan' not in diagnostics:
|
||||
errors.append("Missing can_scan field")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TEST CASES
|
||||
# =============================================================================
|
||||
|
||||
class SmokeTests:
|
||||
"""Smoke test runner."""
|
||||
|
||||
def __init__(self, base_url: str):
|
||||
self.base_url = base_url
|
||||
self.passed = 0
|
||||
self.failed = 0
|
||||
self.errors = []
|
||||
|
||||
def _check(self, name: str, condition: bool, error_msg: str = ""):
|
||||
"""Record a test result."""
|
||||
if condition:
|
||||
print(f" [PASS] {name}")
|
||||
self.passed += 1
|
||||
else:
|
||||
print(f" [FAIL] {name}: {error_msg}")
|
||||
self.failed += 1
|
||||
self.errors.append(f"{name}: {error_msg}")
|
||||
|
||||
def test_capabilities_endpoint(self):
|
||||
"""Test GET /api/bluetooth/capabilities"""
|
||||
print("\n=== Test: Capabilities Endpoint ===")
|
||||
try:
|
||||
resp = requests.get(f"{self.base_url}/api/bluetooth/capabilities", timeout=5)
|
||||
self._check("Status code 200", resp.status_code == 200, f"Got {resp.status_code}")
|
||||
|
||||
data = resp.json()
|
||||
self._check("Has 'available' field", 'available' in data or 'can_scan' in data)
|
||||
self._check("Has 'adapters' field", 'adapters' in data)
|
||||
self._check("Has 'recommended_backend' field", 'recommended_backend' in data or 'preferred_backend' in data)
|
||||
|
||||
except requests.RequestException as e:
|
||||
self._check("Request succeeded", False, str(e))
|
||||
|
||||
def test_devices_endpoint(self):
|
||||
"""Test GET /api/bluetooth/devices (backwards compatibility)"""
|
||||
print("\n=== Test: Devices Endpoint (v2) ===")
|
||||
try:
|
||||
resp = requests.get(f"{self.base_url}/api/bluetooth/devices", timeout=5)
|
||||
self._check("Status code 200", resp.status_code == 200, f"Got {resp.status_code}")
|
||||
|
||||
data = resp.json()
|
||||
self._check("Has 'count' field", 'count' in data)
|
||||
self._check("Has 'devices' array", 'devices' in data and isinstance(data['devices'], list))
|
||||
|
||||
# If devices exist, validate schema
|
||||
if data.get('devices'):
|
||||
device = data['devices'][0]
|
||||
errors = validate_device_schema(device, "First device: ")
|
||||
self._check("Device schema valid", len(errors) == 0, "; ".join(errors))
|
||||
|
||||
# Check for new tracker fields (should exist even if empty)
|
||||
self._check("Has tracker fields", 'is_tracker' in device,
|
||||
"New tracker field missing (backwards compat issue)")
|
||||
|
||||
except requests.RequestException as e:
|
||||
self._check("Request succeeded", False, str(e))
|
||||
|
||||
def test_trackers_endpoint(self):
|
||||
"""Test GET /api/bluetooth/trackers (new v2 endpoint)"""
|
||||
print("\n=== Test: Trackers Endpoint (NEW) ===")
|
||||
try:
|
||||
resp = requests.get(f"{self.base_url}/api/bluetooth/trackers", timeout=5)
|
||||
self._check("Status code 200", resp.status_code == 200, f"Got {resp.status_code}")
|
||||
|
||||
data = resp.json()
|
||||
self._check("Has 'count' field", 'count' in data)
|
||||
self._check("Has 'trackers' array", 'trackers' in data and isinstance(data['trackers'], list))
|
||||
self._check("Has 'summary' field", 'summary' in data)
|
||||
|
||||
# If trackers exist, validate schema
|
||||
if data.get('trackers'):
|
||||
tracker = data['trackers'][0]
|
||||
errors = validate_tracker_schema(tracker, "First tracker: ")
|
||||
self._check("Tracker schema valid", len(errors) == 0, "; ".join(errors))
|
||||
|
||||
except requests.RequestException as e:
|
||||
self._check("Request succeeded", False, str(e))
|
||||
|
||||
def test_diagnostics_endpoint(self):
|
||||
"""Test GET /api/bluetooth/diagnostics (new endpoint)"""
|
||||
print("\n=== Test: Diagnostics Endpoint (NEW) ===")
|
||||
try:
|
||||
resp = requests.get(f"{self.base_url}/api/bluetooth/diagnostics", timeout=5)
|
||||
self._check("Status code 200", resp.status_code == 200, f"Got {resp.status_code}")
|
||||
|
||||
data = resp.json()
|
||||
errors = validate_diagnostics_schema(data)
|
||||
self._check("Diagnostics schema valid", len(errors) == 0, "; ".join(errors))
|
||||
|
||||
self._check("Has recommendations", 'recommendations' in data)
|
||||
|
||||
except requests.RequestException as e:
|
||||
self._check("Request succeeded", False, str(e))
|
||||
|
||||
def test_scan_status_endpoint(self):
|
||||
"""Test GET /api/bluetooth/scan/status"""
|
||||
print("\n=== Test: Scan Status Endpoint ===")
|
||||
try:
|
||||
resp = requests.get(f"{self.base_url}/api/bluetooth/scan/status", timeout=5)
|
||||
self._check("Status code 200", resp.status_code == 200, f"Got {resp.status_code}")
|
||||
|
||||
data = resp.json()
|
||||
self._check("Has 'is_scanning' field", 'is_scanning' in data)
|
||||
|
||||
except requests.RequestException as e:
|
||||
self._check("Request succeeded", False, str(e))
|
||||
|
||||
def test_baseline_endpoints(self):
|
||||
"""Test baseline management endpoints"""
|
||||
print("\n=== Test: Baseline Endpoints ===")
|
||||
try:
|
||||
# List baselines
|
||||
resp = requests.get(f"{self.base_url}/api/bluetooth/baseline/list", timeout=5)
|
||||
self._check("List baselines: Status 200", resp.status_code == 200, f"Got {resp.status_code}")
|
||||
|
||||
data = resp.json()
|
||||
self._check("Has 'baselines' array", 'baselines' in data)
|
||||
|
||||
except requests.RequestException as e:
|
||||
self._check("Request succeeded", False, str(e))
|
||||
|
||||
def test_tscm_integration(self):
|
||||
"""Test that TSCM still works with Bluetooth"""
|
||||
print("\n=== Test: TSCM Integration ===")
|
||||
try:
|
||||
# Get TSCM sweep presets
|
||||
resp = requests.get(f"{self.base_url}/tscm/devices", timeout=5)
|
||||
# This might 404 if no devices, which is ok
|
||||
self._check("TSCM devices endpoint accessible", resp.status_code in (200, 404))
|
||||
|
||||
except requests.RequestException as e:
|
||||
self._check("Request succeeded", False, str(e))
|
||||
|
||||
def test_export_endpoint(self):
|
||||
"""Test GET /api/bluetooth/export"""
|
||||
print("\n=== Test: Export Endpoint ===")
|
||||
try:
|
||||
# JSON export
|
||||
resp = requests.get(f"{self.base_url}/api/bluetooth/export?format=json", timeout=5)
|
||||
self._check("JSON export: Status 200", resp.status_code == 200, f"Got {resp.status_code}")
|
||||
self._check("JSON export: Content-Type", 'application/json' in resp.headers.get('Content-Type', ''))
|
||||
|
||||
# CSV export
|
||||
resp = requests.get(f"{self.base_url}/api/bluetooth/export?format=csv", timeout=5)
|
||||
self._check("CSV export: Status 200", resp.status_code == 200, f"Got {resp.status_code}")
|
||||
self._check("CSV export: Content-Type", 'text/csv' in resp.headers.get('Content-Type', ''))
|
||||
|
||||
except requests.RequestException as e:
|
||||
self._check("Request succeeded", False, str(e))
|
||||
|
||||
def run_all(self):
|
||||
"""Run all smoke tests."""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"BLUETOOTH API SMOKE TESTS")
|
||||
print(f"Target: {self.base_url}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
self.test_capabilities_endpoint()
|
||||
self.test_devices_endpoint()
|
||||
self.test_trackers_endpoint()
|
||||
self.test_diagnostics_endpoint()
|
||||
self.test_scan_status_endpoint()
|
||||
self.test_baseline_endpoints()
|
||||
self.test_export_endpoint()
|
||||
self.test_tscm_integration()
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"RESULTS: {self.passed} passed, {self.failed} failed")
|
||||
print(f"{'='*60}")
|
||||
|
||||
if self.errors:
|
||||
print("\nFailed tests:")
|
||||
for error in self.errors:
|
||||
print(f" - {error}")
|
||||
|
||||
return self.failed == 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MAIN
|
||||
# =============================================================================
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Bluetooth API smoke tests")
|
||||
parser.add_argument("--host", default=DEFAULT_HOST, help="Server host")
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Server port")
|
||||
args = parser.parse_args()
|
||||
|
||||
base_url = f"http://{args.host}:{args.port}"
|
||||
|
||||
# Check server is reachable
|
||||
print(f"Checking server at {base_url}...")
|
||||
try:
|
||||
resp = requests.get(f"{base_url}/api/bluetooth/capabilities", timeout=5)
|
||||
print(f"Server responded: {resp.status_code}")
|
||||
except requests.RequestException as e:
|
||||
print(f"ERROR: Cannot reach server at {base_url}")
|
||||
print(f"Details: {e}")
|
||||
print("\nMake sure INTERCEPT is running:")
|
||||
print(" cd /path/to/intercept && python app.py")
|
||||
sys.exit(1)
|
||||
|
||||
# Run tests
|
||||
tests = SmokeTests(base_url)
|
||||
success = tests.run_all()
|
||||
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
443
tests/test_tracker_signatures.py
Normal file
443
tests/test_tracker_signatures.py
Normal file
@@ -0,0 +1,443 @@
|
||||
"""
|
||||
Test suite for the Tracker Signature Engine.
|
||||
|
||||
Contains sample payloads from real BLE tracker devices and verifies
|
||||
the signature engine correctly identifies them with appropriate confidence.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from utils.bluetooth.tracker_signatures import (
|
||||
TrackerSignatureEngine,
|
||||
TrackerType,
|
||||
TrackerConfidence,
|
||||
detect_tracker,
|
||||
get_tracker_engine,
|
||||
APPLE_COMPANY_ID,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SAMPLE PAYLOADS FROM REAL DEVICES
|
||||
# =============================================================================
|
||||
|
||||
# Apple AirTag advertisement payload samples
|
||||
AIRTAG_SAMPLES = [
|
||||
{
|
||||
'name': 'AirTag sample 1 - Find My advertisement',
|
||||
'address': 'AA:BB:CC:DD:EE:FF',
|
||||
'address_type': 'random',
|
||||
'manufacturer_id': APPLE_COMPANY_ID,
|
||||
'manufacturer_data': bytes.fromhex('121910deadbeef0123456789abcdef0123456789'),
|
||||
'service_uuids': ['fd6f'],
|
||||
'expected_type': TrackerType.AIRTAG,
|
||||
'expected_confidence': TrackerConfidence.HIGH,
|
||||
},
|
||||
{
|
||||
'name': 'AirTag sample 2 - Shorter payload',
|
||||
'address': '11:22:33:44:55:66',
|
||||
'address_type': 'rpa',
|
||||
'manufacturer_id': APPLE_COMPANY_ID,
|
||||
'manufacturer_data': bytes.fromhex('1219abcdef1234567890'),
|
||||
'service_uuids': [],
|
||||
'expected_type': TrackerType.AIRTAG,
|
||||
'expected_confidence': TrackerConfidence.MEDIUM,
|
||||
},
|
||||
]
|
||||
|
||||
# Apple Find My accessory (non-AirTag)
|
||||
FINDMY_ACCESSORY_SAMPLES = [
|
||||
{
|
||||
'name': 'Chipolo ONE Spot (Find My network)',
|
||||
'address': 'CC:DD:EE:FF:00:11',
|
||||
'address_type': 'random',
|
||||
'manufacturer_id': APPLE_COMPANY_ID,
|
||||
'manufacturer_data': bytes.fromhex('12cafe0123456789'),
|
||||
'service_uuids': ['fd6f'],
|
||||
'expected_type': TrackerType.AIRTAG, # Using Find My, detected as AirTag-like
|
||||
'expected_confidence': TrackerConfidence.HIGH,
|
||||
},
|
||||
]
|
||||
|
||||
# Tile tracker samples
|
||||
TILE_SAMPLES = [
|
||||
{
|
||||
'name': 'Tile Mate - by company ID',
|
||||
'address': 'C4:E7:00:11:22:33',
|
||||
'address_type': 'public',
|
||||
'manufacturer_id': 0x00ED, # Tile Inc
|
||||
'manufacturer_data': bytes.fromhex('ed00aabbccdd'),
|
||||
'service_uuids': ['feed'],
|
||||
'expected_type': TrackerType.TILE,
|
||||
'expected_confidence': TrackerConfidence.HIGH,
|
||||
},
|
||||
{
|
||||
'name': 'Tile Pro - by MAC prefix',
|
||||
'address': 'DC:54:AA:BB:CC:DD',
|
||||
'address_type': 'public',
|
||||
'manufacturer_id': None,
|
||||
'manufacturer_data': None,
|
||||
'service_uuids': ['feed'],
|
||||
'expected_type': TrackerType.TILE,
|
||||
'expected_confidence': TrackerConfidence.MEDIUM,
|
||||
},
|
||||
{
|
||||
'name': 'Tile - by name only',
|
||||
'address': '00:11:22:33:44:55',
|
||||
'address_type': 'public',
|
||||
'manufacturer_id': None,
|
||||
'manufacturer_data': None,
|
||||
'service_uuids': [],
|
||||
'name': 'Tile Slim',
|
||||
'expected_type': TrackerType.TILE,
|
||||
'expected_confidence': TrackerConfidence.LOW,
|
||||
},
|
||||
]
|
||||
|
||||
# Samsung SmartTag samples
|
||||
SAMSUNG_SAMPLES = [
|
||||
{
|
||||
'name': 'Samsung SmartTag - by company ID and service',
|
||||
'address': '58:4D:AA:BB:CC:DD',
|
||||
'address_type': 'random',
|
||||
'manufacturer_id': 0x0075, # Samsung
|
||||
'manufacturer_data': bytes.fromhex('75001234567890'),
|
||||
'service_uuids': ['fd5a'],
|
||||
'expected_type': TrackerType.SAMSUNG_SMARTTAG,
|
||||
'expected_confidence': TrackerConfidence.HIGH,
|
||||
},
|
||||
{
|
||||
'name': 'Samsung SmartTag - by MAC prefix only',
|
||||
'address': 'A0:75:BB:CC:DD:EE',
|
||||
'address_type': 'public',
|
||||
'manufacturer_id': None,
|
||||
'manufacturer_data': None,
|
||||
'service_uuids': [],
|
||||
'expected_type': TrackerType.SAMSUNG_SMARTTAG,
|
||||
'expected_confidence': TrackerConfidence.LOW,
|
||||
},
|
||||
]
|
||||
|
||||
# Non-tracker devices (should NOT be detected as trackers)
|
||||
NON_TRACKER_SAMPLES = [
|
||||
{
|
||||
'name': 'Apple AirPods - should not be tracker',
|
||||
'address': 'AA:BB:CC:DD:EE:00',
|
||||
'address_type': 'random',
|
||||
'manufacturer_id': APPLE_COMPANY_ID,
|
||||
'manufacturer_data': bytes.fromhex('100000'), # NOT Find My pattern
|
||||
'service_uuids': [],
|
||||
'expected_tracker': False,
|
||||
},
|
||||
{
|
||||
'name': 'Generic BLE device',
|
||||
'address': '00:11:22:33:44:55',
|
||||
'address_type': 'public',
|
||||
'manufacturer_id': 0x0006, # Microsoft
|
||||
'manufacturer_data': bytes.fromhex('0600aabbccdd'),
|
||||
'service_uuids': ['180f', '180a'], # Battery and Device Info services
|
||||
'expected_tracker': False,
|
||||
},
|
||||
{
|
||||
'name': 'Fitbit fitness tracker - not a location tracker',
|
||||
'address': 'FF:EE:DD:CC:BB:AA',
|
||||
'address_type': 'random',
|
||||
'manufacturer_id': 0x00D2, # Fitbit
|
||||
'manufacturer_data': bytes.fromhex('d2001234'),
|
||||
'service_uuids': ['adab'], # Fitbit service
|
||||
'expected_tracker': False,
|
||||
},
|
||||
{
|
||||
'name': 'Bluetooth speaker',
|
||||
'address': '11:22:33:44:55:66',
|
||||
'address_type': 'public',
|
||||
'manufacturer_id': 0x0310, # Bose
|
||||
'manufacturer_data': None,
|
||||
'service_uuids': ['111e'], # Handsfree
|
||||
'name': 'Bose Speaker',
|
||||
'expected_tracker': False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TEST CASES
|
||||
# =============================================================================
|
||||
|
||||
class TestTrackerDetection:
|
||||
"""Test tracker detection with sample payloads."""
|
||||
|
||||
@pytest.fixture
|
||||
def engine(self):
|
||||
"""Create a fresh engine for each test."""
|
||||
return TrackerSignatureEngine()
|
||||
|
||||
# --- AirTag tests ---
|
||||
|
||||
@pytest.mark.parametrize('sample', AIRTAG_SAMPLES, ids=lambda s: s['name'])
|
||||
def test_airtag_detection(self, engine, sample):
|
||||
"""Test AirTag detection with various payload samples."""
|
||||
result = engine.detect_tracker(
|
||||
address=sample['address'],
|
||||
address_type=sample['address_type'],
|
||||
name=sample.get('name'),
|
||||
manufacturer_id=sample['manufacturer_id'],
|
||||
manufacturer_data=sample['manufacturer_data'],
|
||||
service_uuids=sample['service_uuids'],
|
||||
)
|
||||
|
||||
assert result.is_tracker, f"Should detect {sample['name']} as tracker"
|
||||
assert result.tracker_type == sample['expected_type'], \
|
||||
f"Expected {sample['expected_type']}, got {result.tracker_type}"
|
||||
# Allow medium when expecting high (degraded confidence is acceptable)
|
||||
if sample['expected_confidence'] == TrackerConfidence.HIGH:
|
||||
assert result.confidence in (TrackerConfidence.HIGH, TrackerConfidence.MEDIUM), \
|
||||
f"Expected HIGH or MEDIUM confidence for {sample['name']}"
|
||||
assert len(result.evidence) > 0, "Should provide evidence"
|
||||
|
||||
# --- Tile tests ---
|
||||
|
||||
@pytest.mark.parametrize('sample', TILE_SAMPLES, ids=lambda s: s['name'])
|
||||
def test_tile_detection(self, engine, sample):
|
||||
"""Test Tile tracker detection."""
|
||||
result = engine.detect_tracker(
|
||||
address=sample['address'],
|
||||
address_type=sample['address_type'],
|
||||
name=sample.get('name'),
|
||||
manufacturer_id=sample['manufacturer_id'],
|
||||
manufacturer_data=sample['manufacturer_data'],
|
||||
service_uuids=sample['service_uuids'],
|
||||
)
|
||||
|
||||
assert result.is_tracker, f"Should detect {sample['name']} as tracker"
|
||||
assert result.tracker_type == sample['expected_type'], \
|
||||
f"Expected {sample['expected_type']}, got {result.tracker_type}"
|
||||
assert len(result.evidence) > 0, "Should provide evidence"
|
||||
|
||||
# --- Samsung SmartTag tests ---
|
||||
|
||||
@pytest.mark.parametrize('sample', SAMSUNG_SAMPLES, ids=lambda s: s['name'])
|
||||
def test_samsung_smarttag_detection(self, engine, sample):
|
||||
"""Test Samsung SmartTag detection."""
|
||||
result = engine.detect_tracker(
|
||||
address=sample['address'],
|
||||
address_type=sample['address_type'],
|
||||
name=sample.get('name'),
|
||||
manufacturer_id=sample['manufacturer_id'],
|
||||
manufacturer_data=sample['manufacturer_data'],
|
||||
service_uuids=sample['service_uuids'],
|
||||
)
|
||||
|
||||
assert result.is_tracker, f"Should detect {sample['name']} as tracker"
|
||||
assert result.tracker_type == sample['expected_type'], \
|
||||
f"Expected {sample['expected_type']}, got {result.tracker_type}"
|
||||
|
||||
# --- Non-tracker tests (negative cases) ---
|
||||
|
||||
@pytest.mark.parametrize('sample', NON_TRACKER_SAMPLES, ids=lambda s: s['name'])
|
||||
def test_non_tracker_not_detected(self, engine, sample):
|
||||
"""Test that non-tracker devices are NOT falsely detected."""
|
||||
result = engine.detect_tracker(
|
||||
address=sample['address'],
|
||||
address_type=sample['address_type'],
|
||||
name=sample.get('name'),
|
||||
manufacturer_id=sample['manufacturer_id'],
|
||||
manufacturer_data=sample['manufacturer_data'],
|
||||
service_uuids=sample['service_uuids'],
|
||||
)
|
||||
|
||||
assert not result.is_tracker, \
|
||||
f"{sample['name']} should NOT be detected as tracker (got: {result.tracker_type})"
|
||||
|
||||
|
||||
class TestFingerprinting:
|
||||
"""Test device fingerprinting for MAC randomization tracking."""
|
||||
|
||||
@pytest.fixture
|
||||
def engine(self):
|
||||
return TrackerSignatureEngine()
|
||||
|
||||
def test_fingerprint_consistency(self, engine):
|
||||
"""Test that same payload produces same fingerprint."""
|
||||
fp1 = engine.generate_device_fingerprint(
|
||||
manufacturer_id=APPLE_COMPANY_ID,
|
||||
manufacturer_data=bytes.fromhex('1219deadbeef'),
|
||||
service_uuids=['fd6f'],
|
||||
service_data={},
|
||||
tx_power=-10,
|
||||
name='TestDevice',
|
||||
)
|
||||
|
||||
fp2 = engine.generate_device_fingerprint(
|
||||
manufacturer_id=APPLE_COMPANY_ID,
|
||||
manufacturer_data=bytes.fromhex('1219deadbeef'),
|
||||
service_uuids=['fd6f'],
|
||||
service_data={},
|
||||
tx_power=-10,
|
||||
name='TestDevice',
|
||||
)
|
||||
|
||||
assert fp1.fingerprint_id == fp2.fingerprint_id, \
|
||||
"Same payload should produce same fingerprint"
|
||||
|
||||
def test_fingerprint_different_mac(self, engine):
|
||||
"""Test that fingerprint ignores MAC address (for tracking across rotations)."""
|
||||
# Fingerprinting doesn't take MAC as input, so this tests the concept
|
||||
fp1 = engine.generate_device_fingerprint(
|
||||
manufacturer_id=APPLE_COMPANY_ID,
|
||||
manufacturer_data=bytes.fromhex('1219abcdef'),
|
||||
service_uuids=['fd6f'],
|
||||
service_data={},
|
||||
tx_power=None,
|
||||
name=None,
|
||||
)
|
||||
|
||||
# Same payload characteristics should produce same fingerprint
|
||||
fp2 = engine.generate_device_fingerprint(
|
||||
manufacturer_id=APPLE_COMPANY_ID,
|
||||
manufacturer_data=bytes.fromhex('1219abcdef'),
|
||||
service_uuids=['fd6f'],
|
||||
service_data={},
|
||||
tx_power=None,
|
||||
name=None,
|
||||
)
|
||||
|
||||
assert fp1.fingerprint_id == fp2.fingerprint_id
|
||||
|
||||
def test_fingerprint_stability_score(self, engine):
|
||||
"""Test that fingerprints have appropriate stability scores."""
|
||||
# Rich payload = high stability
|
||||
fp_rich = engine.generate_device_fingerprint(
|
||||
manufacturer_id=APPLE_COMPANY_ID,
|
||||
manufacturer_data=bytes.fromhex('1219aabbccdd'),
|
||||
service_uuids=['fd6f', '180f'],
|
||||
service_data={'fd6f': bytes.fromhex('01')},
|
||||
tx_power=-5,
|
||||
name='AirTag',
|
||||
)
|
||||
|
||||
# Minimal payload = low stability
|
||||
fp_minimal = engine.generate_device_fingerprint(
|
||||
manufacturer_id=None,
|
||||
manufacturer_data=None,
|
||||
service_uuids=[],
|
||||
service_data={},
|
||||
tx_power=None,
|
||||
name=None,
|
||||
)
|
||||
|
||||
assert fp_rich.stability_confidence > fp_minimal.stability_confidence, \
|
||||
"Rich payload should have higher stability confidence"
|
||||
|
||||
|
||||
class TestSuspiciousPresence:
|
||||
"""Test suspicious presence / following heuristics."""
|
||||
|
||||
@pytest.fixture
|
||||
def engine(self):
|
||||
return TrackerSignatureEngine()
|
||||
|
||||
def test_risk_score_for_tracker(self, engine):
|
||||
"""Test that trackers get base risk score."""
|
||||
risk_score, risk_factors = engine.evaluate_suspicious_presence(
|
||||
fingerprint_id='test123',
|
||||
is_tracker=True,
|
||||
seen_count=5,
|
||||
duration_seconds=60,
|
||||
seen_rate=2.0,
|
||||
rssi_variance=15.0,
|
||||
is_new=False,
|
||||
)
|
||||
|
||||
assert risk_score >= 0.3, "Tracker should have base risk score"
|
||||
assert any('tracker' in f.lower() for f in risk_factors)
|
||||
|
||||
def test_risk_score_for_persistent_tracker(self, engine):
|
||||
"""Test that persistent tracker presence increases risk."""
|
||||
risk_score, risk_factors = engine.evaluate_suspicious_presence(
|
||||
fingerprint_id='test456',
|
||||
is_tracker=True,
|
||||
seen_count=50,
|
||||
duration_seconds=900, # 15 minutes
|
||||
seen_rate=3.5,
|
||||
rssi_variance=8.0, # Stable signal
|
||||
is_new=True,
|
||||
)
|
||||
|
||||
assert risk_score >= 0.5, "Persistent tracker should have high risk"
|
||||
assert len(risk_factors) >= 3, "Should have multiple risk factors"
|
||||
|
||||
def test_non_tracker_low_risk(self, engine):
|
||||
"""Test that non-trackers have low risk scores."""
|
||||
risk_score, risk_factors = engine.evaluate_suspicious_presence(
|
||||
fingerprint_id='test789',
|
||||
is_tracker=False,
|
||||
seen_count=5,
|
||||
duration_seconds=60,
|
||||
seen_rate=1.0,
|
||||
rssi_variance=20.0,
|
||||
is_new=False,
|
||||
)
|
||||
|
||||
assert risk_score < 0.3, "Non-tracker should have low risk"
|
||||
|
||||
|
||||
class TestConvenienceFunction:
|
||||
"""Test the module-level convenience function."""
|
||||
|
||||
def test_detect_tracker_function(self):
|
||||
"""Test the detect_tracker() convenience function."""
|
||||
result = detect_tracker(
|
||||
address='C4:E7:11:22:33:44',
|
||||
address_type='public',
|
||||
name='Tile Mate',
|
||||
manufacturer_id=0x00ED,
|
||||
service_uuids=['feed'],
|
||||
)
|
||||
|
||||
assert result.is_tracker
|
||||
assert result.tracker_type == TrackerType.TILE
|
||||
|
||||
def test_get_engine_singleton(self):
|
||||
"""Test that get_tracker_engine returns singleton."""
|
||||
engine1 = get_tracker_engine()
|
||||
engine2 = get_tracker_engine()
|
||||
assert engine1 is engine2
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SMOKE TEST FOR API ENDPOINTS
|
||||
# =============================================================================
|
||||
|
||||
def test_api_backwards_compatibility():
|
||||
"""
|
||||
Smoke test checklist for API backwards compatibility.
|
||||
|
||||
This is a documentation test - run manually to verify:
|
||||
|
||||
1. GET /api/bluetooth/devices - Should still return devices in same format
|
||||
- Check: device_id, address, name, rssi_current all present
|
||||
- New: tracker fields should be present but optional
|
||||
|
||||
2. POST /api/bluetooth/scan/start - Should work with same parameters
|
||||
- Check: mode, duration_s, transport, rssi_threshold
|
||||
|
||||
3. GET /api/bluetooth/stream - SSE should still emit device_update events
|
||||
- Check: Event format unchanged
|
||||
|
||||
4. GET /tscm/sweep/stream - TSCM should still work
|
||||
- Check: Bluetooth devices included in sweep results
|
||||
|
||||
5. New endpoints (v2):
|
||||
- GET /api/bluetooth/trackers - Returns only detected trackers
|
||||
- GET /api/bluetooth/trackers/<id> - Returns tracker detail
|
||||
- GET /api/bluetooth/diagnostics - Returns system diagnostics
|
||||
|
||||
Run with: pytest tests/test_tracker_signatures.py -v
|
||||
"""
|
||||
# This is just a documentation placeholder
|
||||
# Actual API tests would require a running Flask app
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
@@ -36,6 +36,15 @@ from .heuristics import HeuristicsEngine, evaluate_device_heuristics, evaluate_a
|
||||
from .models import BTDeviceAggregate, BTObservation, ScanStatus, SystemCapabilities
|
||||
from .ring_buffer import RingBuffer, get_ring_buffer, reset_ring_buffer
|
||||
from .scanner import BluetoothScanner, get_bluetooth_scanner, reset_bluetooth_scanner
|
||||
from .tracker_signatures import (
|
||||
TrackerSignatureEngine,
|
||||
TrackerDetectionResult,
|
||||
TrackerType,
|
||||
TrackerConfidence,
|
||||
DeviceFingerprint,
|
||||
detect_tracker,
|
||||
get_tracker_engine,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Main scanner
|
||||
@@ -100,4 +109,13 @@ __all__ = [
|
||||
'ADDRESS_TYPE_RANDOM_STATIC',
|
||||
'ADDRESS_TYPE_RPA',
|
||||
'ADDRESS_TYPE_NRPA',
|
||||
|
||||
# Tracker detection
|
||||
'TrackerSignatureEngine',
|
||||
'TrackerDetectionResult',
|
||||
'TrackerType',
|
||||
'TrackerConfidence',
|
||||
'DeviceFingerprint',
|
||||
'detect_tracker',
|
||||
'get_tracker_engine',
|
||||
]
|
||||
|
||||
@@ -39,6 +39,11 @@ from .models import BTObservation, BTDeviceAggregate
|
||||
from .device_key import generate_device_key, is_randomized_mac
|
||||
from .distance import DistanceEstimator, get_distance_estimator
|
||||
from .ring_buffer import RingBuffer, get_ring_buffer
|
||||
from .tracker_signatures import (
|
||||
TrackerSignatureEngine,
|
||||
get_tracker_engine,
|
||||
TrackerDetectionResult,
|
||||
)
|
||||
|
||||
|
||||
class DeviceAggregator:
|
||||
@@ -60,9 +65,15 @@ class DeviceAggregator:
|
||||
self._distance_estimator = get_distance_estimator()
|
||||
self._ring_buffer = get_ring_buffer()
|
||||
|
||||
# Tracker detection engine
|
||||
self._tracker_engine = get_tracker_engine()
|
||||
|
||||
# Device key mapping (device_id -> device_key)
|
||||
self._device_keys: dict[str, str] = {}
|
||||
|
||||
# Fingerprint mapping for cross-MAC tracking
|
||||
self._fingerprint_to_devices: dict[str, set[str]] = {}
|
||||
|
||||
def ingest(self, observation: BTObservation) -> BTDeviceAggregate:
|
||||
"""
|
||||
Ingest a new observation and update the device aggregate.
|
||||
@@ -166,6 +177,12 @@ class DeviceAggregator:
|
||||
# Estimate distance and proximity band
|
||||
self._update_proximity(device)
|
||||
|
||||
# Run tracker detection
|
||||
self._update_tracker_detection(device, observation)
|
||||
|
||||
# Evaluate suspicious presence heuristics
|
||||
self._update_risk_analysis(device)
|
||||
|
||||
return device
|
||||
|
||||
def _infer_protocol(self, observation: BTObservation) -> str:
|
||||
@@ -291,6 +308,77 @@ class DeviceAggregator:
|
||||
)
|
||||
device.proximity_band = str(band)
|
||||
|
||||
def _update_tracker_detection(
|
||||
self,
|
||||
device: BTDeviceAggregate,
|
||||
observation: BTObservation,
|
||||
) -> None:
|
||||
"""Run tracker signature detection on a device."""
|
||||
# Prepare service data from observation if available
|
||||
service_data = observation.service_data if observation.service_data else {}
|
||||
|
||||
# Store service data on device for investigation
|
||||
for uuid, data in service_data.items():
|
||||
device.service_data[uuid] = data
|
||||
|
||||
# Run tracker detection
|
||||
result = self._tracker_engine.detect_tracker(
|
||||
address=device.address,
|
||||
address_type=device.address_type,
|
||||
name=device.name,
|
||||
manufacturer_id=device.manufacturer_id,
|
||||
manufacturer_data=device.manufacturer_bytes,
|
||||
service_uuids=device.service_uuids,
|
||||
service_data=service_data,
|
||||
tx_power=device.tx_power,
|
||||
)
|
||||
|
||||
# Update device with detection results
|
||||
device.is_tracker = result.is_tracker
|
||||
device.tracker_type = result.tracker_type.value if result.tracker_type else None
|
||||
device.tracker_name = result.tracker_name
|
||||
device.tracker_confidence = result.confidence.value if result.confidence else None
|
||||
device.tracker_confidence_score = result.confidence_score
|
||||
device.tracker_evidence = result.evidence
|
||||
|
||||
# Generate and store payload fingerprint
|
||||
fingerprint = self._tracker_engine.generate_device_fingerprint(
|
||||
manufacturer_id=device.manufacturer_id,
|
||||
manufacturer_data=device.manufacturer_bytes,
|
||||
service_uuids=device.service_uuids,
|
||||
service_data=service_data,
|
||||
tx_power=device.tx_power,
|
||||
name=device.name,
|
||||
)
|
||||
device.payload_fingerprint_id = fingerprint.fingerprint_id
|
||||
device.payload_fingerprint_stability = fingerprint.stability_confidence
|
||||
|
||||
# Track fingerprint to device mapping
|
||||
if fingerprint.fingerprint_id not in self._fingerprint_to_devices:
|
||||
self._fingerprint_to_devices[fingerprint.fingerprint_id] = set()
|
||||
self._fingerprint_to_devices[fingerprint.fingerprint_id].add(device.device_id)
|
||||
|
||||
# Record sighting for persistence tracking
|
||||
self._tracker_engine.record_sighting(fingerprint.fingerprint_id)
|
||||
|
||||
def _update_risk_analysis(self, device: BTDeviceAggregate) -> None:
|
||||
"""Evaluate suspicious presence heuristics for a device."""
|
||||
if not device.payload_fingerprint_id:
|
||||
return
|
||||
|
||||
risk_score, risk_factors = self._tracker_engine.evaluate_suspicious_presence(
|
||||
fingerprint_id=device.payload_fingerprint_id,
|
||||
is_tracker=device.is_tracker,
|
||||
seen_count=device.seen_count,
|
||||
duration_seconds=device.duration_seconds,
|
||||
seen_rate=device.seen_rate,
|
||||
rssi_variance=device.rssi_variance,
|
||||
is_new=device.is_new,
|
||||
)
|
||||
|
||||
device.risk_score = risk_score
|
||||
device.risk_factors = risk_factors
|
||||
|
||||
def _merge_device_info(self, device: BTDeviceAggregate, observation: BTObservation) -> None:
|
||||
"""Merge observation data into device aggregate (prefer non-None values)."""
|
||||
# Name (prefer longer names as they're usually more complete)
|
||||
|
||||
@@ -20,6 +20,12 @@ from .constants import (
|
||||
PROXIMITY_UNKNOWN,
|
||||
)
|
||||
|
||||
# Import tracker types (will be available after tracker_signatures module loads)
|
||||
# Use string type hints to avoid circular imports
|
||||
from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from .tracker_signatures import TrackerDetectionResult, DeviceFingerprint
|
||||
|
||||
|
||||
@dataclass
|
||||
class BTObservation:
|
||||
@@ -146,6 +152,25 @@ class BTDeviceAggregate:
|
||||
in_baseline: bool = False
|
||||
baseline_id: Optional[int] = None
|
||||
|
||||
# Tracker detection fields
|
||||
is_tracker: bool = False
|
||||
tracker_type: Optional[str] = None # 'airtag', 'tile', 'samsung_smarttag', etc.
|
||||
tracker_name: Optional[str] = None
|
||||
tracker_confidence: Optional[str] = None # 'high', 'medium', 'low', 'none'
|
||||
tracker_confidence_score: float = 0.0 # 0.0 to 1.0
|
||||
tracker_evidence: list[str] = field(default_factory=list)
|
||||
|
||||
# Suspicious presence / following heuristics
|
||||
risk_score: float = 0.0 # 0.0 to 1.0
|
||||
risk_factors: list[str] = field(default_factory=list)
|
||||
|
||||
# Payload fingerprint (survives MAC randomization)
|
||||
payload_fingerprint_id: Optional[str] = None
|
||||
payload_fingerprint_stability: float = 0.0
|
||||
|
||||
# Service data (for tracker analysis)
|
||||
service_data: dict[str, bytes] = field(default_factory=dict)
|
||||
|
||||
def get_rssi_history(self, max_points: int = 50) -> list[dict]:
|
||||
"""Get RSSI history for sparkline visualization."""
|
||||
if not self.rssi_samples:
|
||||
@@ -252,6 +277,31 @@ class BTDeviceAggregate:
|
||||
# Baseline
|
||||
'in_baseline': self.in_baseline,
|
||||
'baseline_id': self.baseline_id,
|
||||
|
||||
# Tracker detection
|
||||
'tracker': {
|
||||
'is_tracker': self.is_tracker,
|
||||
'type': self.tracker_type,
|
||||
'name': self.tracker_name,
|
||||
'confidence': self.tracker_confidence,
|
||||
'confidence_score': round(self.tracker_confidence_score, 2),
|
||||
'evidence': self.tracker_evidence,
|
||||
},
|
||||
|
||||
# Suspicious presence analysis
|
||||
'risk_analysis': {
|
||||
'risk_score': round(self.risk_score, 2),
|
||||
'risk_factors': self.risk_factors,
|
||||
},
|
||||
|
||||
# Fingerprint
|
||||
'fingerprint': {
|
||||
'id': self.payload_fingerprint_id,
|
||||
'stability': round(self.payload_fingerprint_stability, 2),
|
||||
},
|
||||
|
||||
# Raw service data for investigation
|
||||
'service_data': {k: v.hex() for k, v in self.service_data.items()},
|
||||
}
|
||||
|
||||
def to_summary_dict(self) -> dict:
|
||||
@@ -277,6 +327,14 @@ class BTDeviceAggregate:
|
||||
'seen_count': self.seen_count,
|
||||
'heuristic_flags': self.heuristic_flags,
|
||||
'in_baseline': self.in_baseline,
|
||||
# Tracker info for list view
|
||||
'is_tracker': self.is_tracker,
|
||||
'tracker_type': self.tracker_type,
|
||||
'tracker_name': self.tracker_name,
|
||||
'tracker_confidence': self.tracker_confidence,
|
||||
'tracker_confidence_score': round(self.tracker_confidence_score, 2),
|
||||
'risk_score': round(self.risk_score, 2),
|
||||
'fingerprint_id': self.payload_fingerprint_id,
|
||||
}
|
||||
|
||||
|
||||
|
||||
804
utils/bluetooth/tracker_signatures.py
Normal file
804
utils/bluetooth/tracker_signatures.py
Normal file
@@ -0,0 +1,804 @@
|
||||
"""
|
||||
Tracker Signature Engine for BLE device classification.
|
||||
|
||||
Detects Apple AirTag, Find My accessories, Tile trackers, Samsung SmartTag,
|
||||
and other known BLE trackers based on manufacturer data patterns, service UUIDs,
|
||||
and advertising payload analysis.
|
||||
|
||||
This module provides reliable tracker detection that:
|
||||
1. Works with MAC randomization (uses payload fingerprinting)
|
||||
2. Provides confidence scores and evidence for each match
|
||||
3. Does NOT claim certainty - provides "indicators" not proof
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger('intercept.bluetooth.tracker_signatures')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TRACKER TYPES
|
||||
# =============================================================================
|
||||
|
||||
class TrackerType(str, Enum):
|
||||
"""Known tracker device types."""
|
||||
AIRTAG = 'airtag'
|
||||
FINDMY_ACCESSORY = 'findmy_accessory'
|
||||
TILE = 'tile'
|
||||
SAMSUNG_SMARTTAG = 'samsung_smarttag'
|
||||
CHIPOLO = 'chipolo'
|
||||
PEBBLEBEE = 'pebblebee'
|
||||
NUTFIND = 'nutfind'
|
||||
ORBIT = 'orbit'
|
||||
EUFY = 'eufy'
|
||||
CUBE = 'cube'
|
||||
UNKNOWN_TRACKER = 'unknown_tracker'
|
||||
NOT_A_TRACKER = 'not_a_tracker'
|
||||
|
||||
|
||||
class TrackerConfidence(str, Enum):
|
||||
"""Confidence level for tracker detection."""
|
||||
HIGH = 'high' # Multiple strong indicators match
|
||||
MEDIUM = 'medium' # Some indicators match
|
||||
LOW = 'low' # Weak indicators, needs investigation
|
||||
NONE = 'none' # Not detected as tracker
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TRACKER SIGNATURES DATABASE
|
||||
# =============================================================================
|
||||
|
||||
# Apple Manufacturer ID
|
||||
APPLE_COMPANY_ID = 0x004C
|
||||
|
||||
# Apple Find My / AirTag advertisement types (first byte of manufacturer data after company ID)
|
||||
APPLE_FINDMY_ADV_TYPE = 0x12 # Find My network advertisement
|
||||
APPLE_NEARBY_ADV_TYPE = 0x10 # Nearby action
|
||||
APPLE_AIRTAG_ADV_PATTERN = bytes([0x12, 0x19]) # AirTag specific
|
||||
APPLE_FINDMY_PREFIX_SHORT = bytes([0x12]) # Find My prefix (short)
|
||||
APPLE_FINDMY_PREFIX_ALT = bytes([0x07, 0x19]) # Alternative Find My pattern
|
||||
|
||||
# Find My service UUID (Apple's offline finding service)
|
||||
APPLE_FINDMY_SERVICE_UUID = 'fd6f' # 16-bit UUID
|
||||
APPLE_CONTINUITY_SERVICE_UUID = 'd0611e78-bbb4-4591-a5f8-487910ae4366'
|
||||
|
||||
# Tile
|
||||
TILE_COMPANY_ID = 0x00ED # Tile Inc
|
||||
TILE_ALT_COMPANY_ID = 0x038F # Alternative Tile ID
|
||||
TILE_SERVICE_UUID = 'feed' # Tile service UUID (16-bit)
|
||||
TILE_MAC_PREFIXES = ['C4:E7', 'DC:54', 'E4:B0', 'F8:8A', 'E6:43', '90:32', 'D0:72']
|
||||
|
||||
# Samsung SmartTag
|
||||
SAMSUNG_COMPANY_ID = 0x0075
|
||||
SMARTTAG_SERVICE_UUID = 'fd5a' # SmartThings Find service
|
||||
SMARTTAG_MAC_PREFIXES = ['58:4D', 'A0:75', 'B8:D7', '50:32']
|
||||
|
||||
# Chipolo
|
||||
CHIPOLO_COMPANY_ID = 0x0A09
|
||||
CHIPOLO_SERVICE_UUID = 'feaa' # Eddystone beacon (used by some Chipolo)
|
||||
CHIPOLO_ALT_SERVICE = 'feb1'
|
||||
|
||||
# PebbleBee
|
||||
PEBBLEBEE_SERVICE_UUID = 'feab'
|
||||
PEBBLEBEE_MAC_PREFIXES = ['D4:3D', 'E0:E5']
|
||||
|
||||
# Other known trackers
|
||||
NUTFIND_COMPANY_ID = 0x0A09
|
||||
EUFY_COMPANY_ID = 0x0590
|
||||
|
||||
# Generic beacon patterns that may indicate a tracker
|
||||
BEACON_SERVICE_UUIDS = [
|
||||
'feaa', # Eddystone
|
||||
'feab', # Nokia beacon
|
||||
'feb1', # Dialog Semiconductor
|
||||
'febe', # Bose
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackerSignature:
|
||||
"""Defines a tracker signature pattern."""
|
||||
tracker_type: TrackerType
|
||||
name: str
|
||||
description: str
|
||||
company_id: Optional[int] = None
|
||||
company_ids: list[int] = field(default_factory=list)
|
||||
manufacturer_data_prefixes: list[bytes] = field(default_factory=list)
|
||||
service_uuids: list[str] = field(default_factory=list)
|
||||
service_data_prefixes: dict[str, bytes] = field(default_factory=dict)
|
||||
mac_prefixes: list[str] = field(default_factory=list)
|
||||
name_patterns: list[str] = field(default_factory=list)
|
||||
min_manufacturer_data_len: int = 0
|
||||
confidence_boost: float = 0.0 # Extra confidence for specific patterns
|
||||
|
||||
|
||||
# Tracker signatures database
|
||||
TRACKER_SIGNATURES: list[TrackerSignature] = [
|
||||
# Apple AirTag
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.AIRTAG,
|
||||
name='Apple AirTag',
|
||||
description='Apple AirTag tracking device using Find My network',
|
||||
company_id=APPLE_COMPANY_ID,
|
||||
manufacturer_data_prefixes=[
|
||||
APPLE_AIRTAG_ADV_PATTERN,
|
||||
APPLE_FINDMY_PREFIX_SHORT,
|
||||
],
|
||||
service_uuids=[APPLE_FINDMY_SERVICE_UUID],
|
||||
name_patterns=['airtag'],
|
||||
min_manufacturer_data_len=22, # AirTags have 22+ byte payloads
|
||||
confidence_boost=0.2,
|
||||
),
|
||||
|
||||
# Apple Find My Accessory (non-AirTag)
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.FINDMY_ACCESSORY,
|
||||
name='Find My Accessory',
|
||||
description='Third-party Apple Find My network accessory',
|
||||
company_id=APPLE_COMPANY_ID,
|
||||
manufacturer_data_prefixes=[
|
||||
APPLE_FINDMY_PREFIX_SHORT,
|
||||
APPLE_FINDMY_PREFIX_ALT,
|
||||
],
|
||||
service_uuids=[APPLE_FINDMY_SERVICE_UUID],
|
||||
name_patterns=['findmy', 'find my', 'chipolo one spot', 'belkin'],
|
||||
),
|
||||
|
||||
# Tile
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.TILE,
|
||||
name='Tile Tracker',
|
||||
description='Tile Bluetooth tracker',
|
||||
company_ids=[TILE_COMPANY_ID, TILE_ALT_COMPANY_ID],
|
||||
service_uuids=[TILE_SERVICE_UUID],
|
||||
mac_prefixes=TILE_MAC_PREFIXES,
|
||||
name_patterns=['tile'],
|
||||
),
|
||||
|
||||
# Samsung SmartTag
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.SAMSUNG_SMARTTAG,
|
||||
name='Samsung SmartTag',
|
||||
description='Samsung SmartThings tracker',
|
||||
company_id=SAMSUNG_COMPANY_ID,
|
||||
service_uuids=[SMARTTAG_SERVICE_UUID],
|
||||
mac_prefixes=SMARTTAG_MAC_PREFIXES,
|
||||
name_patterns=['smarttag', 'smart tag', 'galaxy tag'],
|
||||
),
|
||||
|
||||
# Chipolo
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.CHIPOLO,
|
||||
name='Chipolo',
|
||||
description='Chipolo Bluetooth tracker',
|
||||
company_id=CHIPOLO_COMPANY_ID,
|
||||
service_uuids=[CHIPOLO_SERVICE_UUID, CHIPOLO_ALT_SERVICE],
|
||||
name_patterns=['chipolo'],
|
||||
),
|
||||
|
||||
# PebbleBee
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.PEBBLEBEE,
|
||||
name='PebbleBee',
|
||||
description='PebbleBee Bluetooth tracker',
|
||||
service_uuids=[PEBBLEBEE_SERVICE_UUID],
|
||||
mac_prefixes=PEBBLEBEE_MAC_PREFIXES,
|
||||
name_patterns=['pebblebee', 'pebble bee', 'honey'],
|
||||
),
|
||||
|
||||
# Eufy
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.EUFY,
|
||||
name='Eufy SmartTrack',
|
||||
description='Eufy/Anker smart tracker',
|
||||
company_id=EUFY_COMPANY_ID,
|
||||
name_patterns=['eufy', 'smarttrack'],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TRACKER DETECTION RESULT
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class TrackerDetectionResult:
|
||||
"""Result of tracker detection analysis."""
|
||||
|
||||
is_tracker: bool = False
|
||||
tracker_type: TrackerType = TrackerType.NOT_A_TRACKER
|
||||
tracker_name: str = ''
|
||||
confidence: TrackerConfidence = TrackerConfidence.NONE
|
||||
confidence_score: float = 0.0 # 0.0 to 1.0
|
||||
evidence: list[str] = field(default_factory=list)
|
||||
matched_signature: Optional[str] = None
|
||||
|
||||
# For suspicious presence heuristics
|
||||
risk_factors: list[str] = field(default_factory=list)
|
||||
risk_score: float = 0.0 # 0.0 to 1.0
|
||||
|
||||
# Raw data used for detection
|
||||
manufacturer_id: Optional[int] = None
|
||||
manufacturer_data_hex: Optional[str] = None
|
||||
service_uuids_found: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'is_tracker': self.is_tracker,
|
||||
'tracker_type': self.tracker_type.value if self.tracker_type else None,
|
||||
'tracker_name': self.tracker_name,
|
||||
'confidence': self.confidence.value if self.confidence else None,
|
||||
'confidence_score': round(self.confidence_score, 2),
|
||||
'evidence': self.evidence,
|
||||
'matched_signature': self.matched_signature,
|
||||
'risk_factors': self.risk_factors,
|
||||
'risk_score': round(self.risk_score, 2),
|
||||
'manufacturer_id': self.manufacturer_id,
|
||||
'manufacturer_data_hex': self.manufacturer_data_hex,
|
||||
'service_uuids_found': self.service_uuids_found,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DEVICE FINGERPRINT (survives MAC randomization)
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class DeviceFingerprint:
|
||||
"""
|
||||
Stable fingerprint for a BLE device that can survive MAC randomization.
|
||||
|
||||
Uses stable parts of the advertising payload to create a probabilistic
|
||||
identity. This is NOT perfect - randomized devices may produce different
|
||||
fingerprints over time. Document this as a limitation.
|
||||
"""
|
||||
|
||||
fingerprint_id: str # SHA256 hash of stable features
|
||||
|
||||
# Features used for fingerprinting
|
||||
manufacturer_id: Optional[int] = None
|
||||
manufacturer_data_prefix: Optional[bytes] = None # First 4 bytes (stable across MACs)
|
||||
manufacturer_data_length: int = 0
|
||||
service_uuids: list[str] = field(default_factory=list)
|
||||
service_data_keys: list[str] = field(default_factory=list)
|
||||
tx_power_bucket: Optional[str] = None # "high"/"medium"/"low"
|
||||
name_hint: Optional[str] = None
|
||||
|
||||
# Confidence in this fingerprint's stability
|
||||
stability_confidence: float = 0.5 # 0.0-1.0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'fingerprint_id': self.fingerprint_id,
|
||||
'manufacturer_id': self.manufacturer_id,
|
||||
'manufacturer_data_prefix': self.manufacturer_data_prefix.hex() if self.manufacturer_data_prefix else None,
|
||||
'manufacturer_data_length': self.manufacturer_data_length,
|
||||
'service_uuids': self.service_uuids,
|
||||
'service_data_keys': self.service_data_keys,
|
||||
'tx_power_bucket': self.tx_power_bucket,
|
||||
'name_hint': self.name_hint,
|
||||
'stability_confidence': round(self.stability_confidence, 2),
|
||||
}
|
||||
|
||||
|
||||
def generate_fingerprint(
|
||||
manufacturer_id: Optional[int],
|
||||
manufacturer_data: Optional[bytes],
|
||||
service_uuids: list[str],
|
||||
service_data: dict[str, bytes],
|
||||
tx_power: Optional[int],
|
||||
name: Optional[str],
|
||||
) -> DeviceFingerprint:
|
||||
"""
|
||||
Generate a stable fingerprint for a BLE device.
|
||||
|
||||
Fingerprint is based on stable parts of the advertising payload that
|
||||
typically persist across MAC address rotations.
|
||||
|
||||
Limitations:
|
||||
- Devices that fully randomize their payload will not be consistently tracked
|
||||
- Some devices change manufacturer data patterns periodically
|
||||
- Best for trackers which have consistent advertising patterns
|
||||
"""
|
||||
# Build fingerprint features
|
||||
features = []
|
||||
stability_score = 0.0
|
||||
|
||||
mfr_prefix = None
|
||||
mfr_length = 0
|
||||
|
||||
if manufacturer_id is not None:
|
||||
features.append(f'mfr:{manufacturer_id:04x}')
|
||||
stability_score += 0.2
|
||||
|
||||
if manufacturer_data:
|
||||
mfr_length = len(manufacturer_data)
|
||||
features.append(f'mfr_len:{mfr_length}')
|
||||
stability_score += 0.1
|
||||
|
||||
# First 4 bytes of manufacturer data are often stable
|
||||
mfr_prefix = manufacturer_data[:min(4, len(manufacturer_data))]
|
||||
features.append(f'mfr_pfx:{mfr_prefix.hex()}')
|
||||
stability_score += 0.2
|
||||
|
||||
sorted_uuids = sorted(service_uuids)
|
||||
if sorted_uuids:
|
||||
features.append(f'uuids:{",".join(sorted_uuids)}')
|
||||
stability_score += 0.2
|
||||
|
||||
sd_keys = sorted(service_data.keys())
|
||||
if sd_keys:
|
||||
features.append(f'sd_keys:{",".join(sd_keys)}')
|
||||
stability_score += 0.1
|
||||
|
||||
# TX power bucket
|
||||
tx_bucket = None
|
||||
if tx_power is not None:
|
||||
if tx_power >= 0:
|
||||
tx_bucket = 'high'
|
||||
elif tx_power >= -10:
|
||||
tx_bucket = 'medium'
|
||||
else:
|
||||
tx_bucket = 'low'
|
||||
features.append(f'tx:{tx_bucket}')
|
||||
stability_score += 0.05
|
||||
|
||||
# Name hint (for devices that advertise names)
|
||||
name_hint = None
|
||||
if name:
|
||||
# Only use first word of name (often stable)
|
||||
name_hint = name.split()[0].lower() if name else None
|
||||
if name_hint:
|
||||
features.append(f'name:{name_hint}')
|
||||
stability_score += 0.15
|
||||
|
||||
# Generate fingerprint ID
|
||||
feature_str = '|'.join(features)
|
||||
fingerprint_id = hashlib.sha256(feature_str.encode()).hexdigest()[:16]
|
||||
|
||||
return DeviceFingerprint(
|
||||
fingerprint_id=fingerprint_id,
|
||||
manufacturer_id=manufacturer_id,
|
||||
manufacturer_data_prefix=mfr_prefix,
|
||||
manufacturer_data_length=mfr_length,
|
||||
service_uuids=sorted_uuids,
|
||||
service_data_keys=sd_keys,
|
||||
tx_power_bucket=tx_bucket,
|
||||
name_hint=name_hint,
|
||||
stability_confidence=min(1.0, stability_score),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TRACKER DETECTION ENGINE
|
||||
# =============================================================================
|
||||
|
||||
class TrackerSignatureEngine:
|
||||
"""
|
||||
Engine for detecting known BLE trackers from advertising data.
|
||||
|
||||
Detection is based on multiple indicators:
|
||||
1. Manufacturer ID matching known tracker companies
|
||||
2. Manufacturer data patterns specific to tracker types
|
||||
3. Service UUID matching known tracker services
|
||||
4. MAC address prefix matching known tracker OUIs
|
||||
5. Device name pattern matching
|
||||
|
||||
Confidence is cumulative - more matching indicators = higher confidence.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.signatures = TRACKER_SIGNATURES
|
||||
|
||||
# Tracking for suspicious presence detection
|
||||
self._sighting_history: dict[str, list[datetime]] = {}
|
||||
self._fingerprint_cache: dict[str, DeviceFingerprint] = {}
|
||||
|
||||
def detect_tracker(
|
||||
self,
|
||||
address: str,
|
||||
address_type: str,
|
||||
name: Optional[str] = None,
|
||||
manufacturer_id: Optional[int] = None,
|
||||
manufacturer_data: Optional[bytes] = None,
|
||||
service_uuids: Optional[list[str]] = None,
|
||||
service_data: Optional[dict[str, bytes]] = None,
|
||||
tx_power: Optional[int] = None,
|
||||
) -> TrackerDetectionResult:
|
||||
"""
|
||||
Analyze a BLE device for tracker indicators.
|
||||
|
||||
Returns a TrackerDetectionResult with:
|
||||
- is_tracker: True if any tracker indicators match
|
||||
- tracker_type: The most likely tracker type
|
||||
- confidence: HIGH/MEDIUM/LOW based on indicator strength
|
||||
- evidence: List of matching indicators for transparency
|
||||
|
||||
IMPORTANT: This is heuristic detection. A match indicates
|
||||
the device RESEMBLES a known tracker, not proof it IS one.
|
||||
"""
|
||||
result = TrackerDetectionResult()
|
||||
service_uuids = service_uuids or []
|
||||
service_data = service_data or {}
|
||||
|
||||
# Store raw data in result for transparency
|
||||
result.manufacturer_id = manufacturer_id
|
||||
if manufacturer_data:
|
||||
result.manufacturer_data_hex = manufacturer_data.hex()
|
||||
result.service_uuids_found = service_uuids
|
||||
|
||||
# Normalize service UUIDs to lowercase 16-bit format where possible
|
||||
normalized_uuids = self._normalize_service_uuids(service_uuids)
|
||||
|
||||
# Score each signature
|
||||
best_match = None
|
||||
best_score = 0.0
|
||||
best_evidence = []
|
||||
|
||||
for signature in self.signatures:
|
||||
score, evidence = self._score_signature(
|
||||
signature=signature,
|
||||
address=address,
|
||||
name=name,
|
||||
manufacturer_id=manufacturer_id,
|
||||
manufacturer_data=manufacturer_data,
|
||||
normalized_uuids=normalized_uuids,
|
||||
service_data=service_data,
|
||||
)
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_match = signature
|
||||
best_evidence = evidence
|
||||
|
||||
# Check for generic tracker indicators if no specific match
|
||||
if best_score < 0.3:
|
||||
generic_score, generic_evidence = self._check_generic_tracker_indicators(
|
||||
address=address,
|
||||
address_type=address_type,
|
||||
manufacturer_id=manufacturer_id,
|
||||
manufacturer_data=manufacturer_data,
|
||||
normalized_uuids=normalized_uuids,
|
||||
)
|
||||
if generic_score > best_score:
|
||||
best_score = generic_score
|
||||
best_match = None
|
||||
best_evidence = generic_evidence
|
||||
|
||||
# Build result
|
||||
if best_score >= 0.3: # Minimum threshold for tracker detection
|
||||
result.is_tracker = True
|
||||
result.confidence_score = min(1.0, best_score)
|
||||
result.evidence = best_evidence
|
||||
|
||||
if best_match:
|
||||
result.tracker_type = best_match.tracker_type
|
||||
result.tracker_name = best_match.name
|
||||
result.matched_signature = best_match.name
|
||||
else:
|
||||
result.tracker_type = TrackerType.UNKNOWN_TRACKER
|
||||
result.tracker_name = 'Unknown Tracker'
|
||||
|
||||
# Determine confidence level
|
||||
if best_score >= 0.7:
|
||||
result.confidence = TrackerConfidence.HIGH
|
||||
elif best_score >= 0.5:
|
||||
result.confidence = TrackerConfidence.MEDIUM
|
||||
else:
|
||||
result.confidence = TrackerConfidence.LOW
|
||||
|
||||
return result
|
||||
|
||||
def _score_signature(
|
||||
self,
|
||||
signature: TrackerSignature,
|
||||
address: str,
|
||||
name: Optional[str],
|
||||
manufacturer_id: Optional[int],
|
||||
manufacturer_data: Optional[bytes],
|
||||
normalized_uuids: list[str],
|
||||
service_data: dict[str, bytes],
|
||||
) -> tuple[float, list[str]]:
|
||||
"""Score how well a device matches a tracker signature."""
|
||||
score = 0.0
|
||||
evidence = []
|
||||
|
||||
# Check company ID
|
||||
# For Apple, company ID alone is NOT enough - require additional indicators
|
||||
# Many Apple devices (AirPods, Watch, etc.) share the same manufacturer ID
|
||||
company_id_matches = False
|
||||
if manufacturer_id is not None:
|
||||
if signature.company_id == manufacturer_id:
|
||||
company_id_matches = True
|
||||
elif manufacturer_id in signature.company_ids:
|
||||
company_id_matches = True
|
||||
|
||||
# For Apple devices, only add company ID score if we also have Find My indicators
|
||||
if company_id_matches:
|
||||
if manufacturer_id == APPLE_COMPANY_ID:
|
||||
# Apple devices need additional proof - just the company ID isn't enough
|
||||
# Only give full score if we have the manufacturer data pattern or service UUID
|
||||
has_findmy_pattern = False
|
||||
if manufacturer_data and len(manufacturer_data) >= 1:
|
||||
adv_type = manufacturer_data[0]
|
||||
if adv_type == APPLE_FINDMY_ADV_TYPE: # 0x12 = Find My
|
||||
has_findmy_pattern = True
|
||||
|
||||
has_findmy_service = APPLE_FINDMY_SERVICE_UUID in normalized_uuids
|
||||
|
||||
if has_findmy_pattern or has_findmy_service:
|
||||
score += 0.35
|
||||
evidence.append(f'Manufacturer ID 0x{manufacturer_id:04X} matches {signature.name}')
|
||||
# Don't add score for Apple manufacturer ID without Find My indicators
|
||||
else:
|
||||
# Non-Apple trackers - company ID is strong evidence
|
||||
score += 0.35
|
||||
evidence.append(f'Manufacturer ID 0x{manufacturer_id:04X} matches {signature.name}')
|
||||
|
||||
# Check manufacturer data prefix (high weight for specific patterns)
|
||||
if manufacturer_data and signature.manufacturer_data_prefixes:
|
||||
for prefix in signature.manufacturer_data_prefixes:
|
||||
if manufacturer_data.startswith(prefix):
|
||||
score += 0.30
|
||||
evidence.append(f'Manufacturer data pattern matches {signature.name}')
|
||||
break
|
||||
|
||||
# Check manufacturer data length
|
||||
if manufacturer_data and signature.min_manufacturer_data_len > 0:
|
||||
if len(manufacturer_data) >= signature.min_manufacturer_data_len:
|
||||
score += 0.10
|
||||
evidence.append(f'Manufacturer data length ({len(manufacturer_data)} bytes) consistent with {signature.name}')
|
||||
|
||||
# Check service UUIDs (medium weight)
|
||||
for sig_uuid in signature.service_uuids:
|
||||
if sig_uuid.lower() in normalized_uuids:
|
||||
score += 0.25
|
||||
evidence.append(f'Service UUID {sig_uuid} matches {signature.name}')
|
||||
break
|
||||
|
||||
# Check MAC prefix (medium weight)
|
||||
if signature.mac_prefixes:
|
||||
mac_upper = address.upper()
|
||||
for prefix in signature.mac_prefixes:
|
||||
if mac_upper.startswith(prefix):
|
||||
score += 0.20
|
||||
evidence.append(f'MAC prefix {prefix} matches known {signature.name} range')
|
||||
break
|
||||
|
||||
# Check name patterns (lower weight - can be spoofed)
|
||||
if name and signature.name_patterns:
|
||||
name_lower = name.lower()
|
||||
for pattern in signature.name_patterns:
|
||||
if pattern.lower() in name_lower:
|
||||
score += 0.15
|
||||
evidence.append(f'Device name "{name}" contains pattern "{pattern}"')
|
||||
break
|
||||
|
||||
# Apply confidence boost for specific signatures
|
||||
score += signature.confidence_boost
|
||||
|
||||
return score, evidence
|
||||
|
||||
def _check_generic_tracker_indicators(
|
||||
self,
|
||||
address: str,
|
||||
address_type: str,
|
||||
manufacturer_id: Optional[int],
|
||||
manufacturer_data: Optional[bytes],
|
||||
normalized_uuids: list[str],
|
||||
) -> tuple[float, list[str]]:
|
||||
"""Check for generic tracker-like indicators."""
|
||||
score = 0.0
|
||||
evidence = []
|
||||
|
||||
# Apple Find My service UUID without specific AirTag pattern
|
||||
if APPLE_FINDMY_SERVICE_UUID in normalized_uuids:
|
||||
score += 0.4
|
||||
evidence.append('Uses Apple Find My network service (fd6f)')
|
||||
|
||||
# Apple manufacturer with Find My advertisement type
|
||||
if manufacturer_id == APPLE_COMPANY_ID and manufacturer_data:
|
||||
if len(manufacturer_data) >= 2:
|
||||
adv_type = manufacturer_data[0]
|
||||
if adv_type == APPLE_FINDMY_ADV_TYPE:
|
||||
score += 0.35
|
||||
evidence.append('Apple Find My network advertisement detected')
|
||||
|
||||
# Check for beacon-like service UUIDs
|
||||
for beacon_uuid in BEACON_SERVICE_UUIDS:
|
||||
if beacon_uuid in normalized_uuids:
|
||||
score += 0.15
|
||||
evidence.append(f'Uses beacon service UUID ({beacon_uuid})')
|
||||
break
|
||||
|
||||
# Random address (most trackers use random addresses)
|
||||
if address_type in ('random', 'rpa', 'nrpa'):
|
||||
# This is a weak indicator - many devices use random addresses
|
||||
if score > 0: # Only add if other indicators present
|
||||
score += 0.05
|
||||
evidence.append('Uses randomized MAC address')
|
||||
|
||||
# Small manufacturer data payload typical of beacons
|
||||
if manufacturer_data and 20 <= len(manufacturer_data) <= 30:
|
||||
if score > 0:
|
||||
score += 0.05
|
||||
evidence.append(f'Manufacturer data length ({len(manufacturer_data)} bytes) typical of beacon')
|
||||
|
||||
return score, evidence
|
||||
|
||||
def _normalize_service_uuids(self, uuids: list[str]) -> list[str]:
|
||||
"""Normalize service UUIDs to lowercase, extracting 16-bit UUIDs where possible."""
|
||||
normalized = []
|
||||
for uuid in uuids:
|
||||
uuid_lower = uuid.lower()
|
||||
# Extract 16-bit UUID from full 128-bit Bluetooth Base UUID
|
||||
# Format: 0000XXXX-0000-1000-8000-00805f9b34fb
|
||||
if len(uuid_lower) == 36 and uuid_lower.endswith('-0000-1000-8000-00805f9b34fb'):
|
||||
short_uuid = uuid_lower[4:8]
|
||||
normalized.append(short_uuid)
|
||||
else:
|
||||
normalized.append(uuid_lower)
|
||||
return normalized
|
||||
|
||||
def generate_device_fingerprint(
|
||||
self,
|
||||
manufacturer_id: Optional[int],
|
||||
manufacturer_data: Optional[bytes],
|
||||
service_uuids: list[str],
|
||||
service_data: dict[str, bytes],
|
||||
tx_power: Optional[int],
|
||||
name: Optional[str],
|
||||
) -> DeviceFingerprint:
|
||||
"""Generate a fingerprint for device tracking across MAC rotations."""
|
||||
return generate_fingerprint(
|
||||
manufacturer_id=manufacturer_id,
|
||||
manufacturer_data=manufacturer_data,
|
||||
service_uuids=service_uuids,
|
||||
service_data=service_data,
|
||||
tx_power=tx_power,
|
||||
name=name,
|
||||
)
|
||||
|
||||
def record_sighting(self, fingerprint_id: str, timestamp: Optional[datetime] = None) -> int:
|
||||
"""
|
||||
Record a device sighting for persistence tracking.
|
||||
|
||||
Returns the number of times this fingerprint has been seen.
|
||||
"""
|
||||
ts = timestamp or datetime.now()
|
||||
|
||||
if fingerprint_id not in self._sighting_history:
|
||||
self._sighting_history[fingerprint_id] = []
|
||||
|
||||
# Keep only last 24 hours of sightings
|
||||
cutoff = ts - timedelta(hours=24)
|
||||
self._sighting_history[fingerprint_id] = [
|
||||
t for t in self._sighting_history[fingerprint_id]
|
||||
if t > cutoff
|
||||
]
|
||||
|
||||
self._sighting_history[fingerprint_id].append(ts)
|
||||
return len(self._sighting_history[fingerprint_id])
|
||||
|
||||
def get_sighting_count(self, fingerprint_id: str, window_hours: int = 24) -> int:
|
||||
"""Get the number of times a fingerprint has been seen in the time window."""
|
||||
if fingerprint_id not in self._sighting_history:
|
||||
return 0
|
||||
|
||||
cutoff = datetime.now() - timedelta(hours=window_hours)
|
||||
return sum(1 for t in self._sighting_history[fingerprint_id] if t > cutoff)
|
||||
|
||||
def evaluate_suspicious_presence(
|
||||
self,
|
||||
fingerprint_id: str,
|
||||
is_tracker: bool,
|
||||
seen_count: int,
|
||||
duration_seconds: float,
|
||||
seen_rate: float,
|
||||
rssi_variance: Optional[float],
|
||||
is_new: bool,
|
||||
) -> tuple[float, list[str]]:
|
||||
"""
|
||||
Evaluate if a device shows suspicious "following" behavior.
|
||||
|
||||
Returns (risk_score, risk_factors) where:
|
||||
- risk_score: 0.0-1.0 indicating likelihood of suspicious presence
|
||||
- risk_factors: List of reasons contributing to the score
|
||||
|
||||
IMPORTANT: These are HEURISTICS only. They indicate patterns that
|
||||
MIGHT suggest a device is following/tracking, but cannot prove intent.
|
||||
Always present to users with appropriate caveats.
|
||||
"""
|
||||
risk_score = 0.0
|
||||
risk_factors = []
|
||||
|
||||
# Tracker baseline - if it's a tracker, start with some risk
|
||||
if is_tracker:
|
||||
risk_score += 0.3
|
||||
risk_factors.append('Device matches known tracker signature')
|
||||
|
||||
# Heuristic 1: Persistently near - seen many times over a long period
|
||||
if seen_count >= 20 and duration_seconds >= 600: # 10+ minutes
|
||||
points = min(0.25, (seen_count / 100) * 0.25)
|
||||
risk_score += points
|
||||
risk_factors.append(f'Persistently present: seen {seen_count} times over {duration_seconds/60:.1f} min')
|
||||
elif seen_count >= 50:
|
||||
risk_score += 0.2
|
||||
risk_factors.append(f'High observation count: {seen_count} sightings')
|
||||
|
||||
# Heuristic 2: Consistent presence rate (beacon-like behavior)
|
||||
if seen_rate >= 3.0: # 3+ observations per minute
|
||||
points = min(0.15, (seen_rate / 10) * 0.15)
|
||||
risk_score += points
|
||||
risk_factors.append(f'Beacon-like presence: {seen_rate:.1f} obs/min')
|
||||
|
||||
# Heuristic 3: Stable RSSI (moving with us, same relative distance)
|
||||
if rssi_variance is not None and rssi_variance < 10:
|
||||
risk_score += 0.1
|
||||
risk_factors.append(f'Stable signal strength (variance: {rssi_variance:.1f})')
|
||||
|
||||
# Heuristic 4: New device appearing (not in baseline)
|
||||
if is_new and is_tracker:
|
||||
risk_score += 0.15
|
||||
risk_factors.append('New tracker appeared after baseline was set')
|
||||
|
||||
# Cross-session persistence (from sighting history)
|
||||
historical_count = self.get_sighting_count(fingerprint_id, window_hours=24)
|
||||
if historical_count >= 10:
|
||||
points = min(0.15, (historical_count / 50) * 0.15)
|
||||
risk_score += points
|
||||
risk_factors.append(f'Seen across multiple sessions: {historical_count} total sightings in 24h')
|
||||
|
||||
return min(1.0, risk_score), risk_factors
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SINGLETON ENGINE INSTANCE
|
||||
# =============================================================================
|
||||
|
||||
_engine_instance: Optional[TrackerSignatureEngine] = None
|
||||
|
||||
|
||||
def get_tracker_engine() -> TrackerSignatureEngine:
|
||||
"""Get the singleton tracker signature engine instance."""
|
||||
global _engine_instance
|
||||
if _engine_instance is None:
|
||||
_engine_instance = TrackerSignatureEngine()
|
||||
return _engine_instance
|
||||
|
||||
|
||||
def detect_tracker(
|
||||
address: str,
|
||||
address_type: str = 'public',
|
||||
name: Optional[str] = None,
|
||||
manufacturer_id: Optional[int] = None,
|
||||
manufacturer_data: Optional[bytes] = None,
|
||||
service_uuids: Optional[list[str]] = None,
|
||||
service_data: Optional[dict[str, bytes]] = None,
|
||||
tx_power: Optional[int] = None,
|
||||
) -> TrackerDetectionResult:
|
||||
"""
|
||||
Convenience function to detect if a BLE device is a tracker.
|
||||
|
||||
See TrackerSignatureEngine.detect_tracker for full documentation.
|
||||
"""
|
||||
engine = get_tracker_engine()
|
||||
return engine.detect_tracker(
|
||||
address=address,
|
||||
address_type=address_type,
|
||||
name=name,
|
||||
manufacturer_id=manufacturer_id,
|
||||
manufacturer_data=manufacturer_data,
|
||||
service_uuids=service_uuids,
|
||||
service_data=service_data,
|
||||
tx_power=tx_power,
|
||||
)
|
||||
@@ -206,6 +206,8 @@ class ThreatDetector:
|
||||
"""
|
||||
Classify a Bluetooth device into informational/review/high_interest.
|
||||
|
||||
Now uses the v2 tracker detection data if available.
|
||||
|
||||
Returns:
|
||||
Dict with 'classification', 'reasons', and metadata
|
||||
"""
|
||||
@@ -217,7 +219,6 @@ class ThreatDetector:
|
||||
|
||||
reasons = []
|
||||
classification = 'informational'
|
||||
tracker_info = None
|
||||
|
||||
# Track repeat detections
|
||||
times_seen = _record_device_seen(f'bt:{mac}') if mac else 1
|
||||
@@ -225,8 +226,25 @@ class ThreatDetector:
|
||||
# Check if in baseline (known device)
|
||||
in_baseline = mac in self.baseline_bt_macs if self.baseline else False
|
||||
|
||||
# Check for trackers (do this early for all devices)
|
||||
tracker_info = is_known_tracker(name, manufacturer_data)
|
||||
# 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', [])
|
||||
|
||||
# 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', [])
|
||||
|
||||
# Fall back to legacy detection if v2 not available
|
||||
tracker_info_legacy = None
|
||||
if not is_tracker_v2:
|
||||
tracker_info_legacy = is_known_tracker(name, manufacturer_data)
|
||||
|
||||
is_tracker = is_tracker_v2 or (tracker_info_legacy is not None)
|
||||
|
||||
if in_baseline:
|
||||
reasons.append('Known device in baseline')
|
||||
@@ -241,8 +259,24 @@ class ThreatDetector:
|
||||
classification = 'review'
|
||||
|
||||
# Check for trackers -> high interest
|
||||
if tracker_info:
|
||||
reasons.append(f"Known tracker: {tracker_info.get('name', 'Unknown')}")
|
||||
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 ''
|
||||
reasons.append(f"Tracker detected: {tracker_label}{conf_label}")
|
||||
classification = 'high_interest'
|
||||
|
||||
# Add evidence from v2 detection
|
||||
for evidence_item in tracker_evidence_v2[:2]: # First 2 items
|
||||
reasons.append(f"Evidence: {evidence_item}")
|
||||
|
||||
# Add risk factors if significant
|
||||
if risk_score >= 0.3:
|
||||
reasons.append(f"Risk score: {int(risk_score * 100)}%")
|
||||
for factor in risk_factors[:2]: # First 2 factors
|
||||
reasons.append(f"Risk: {factor}")
|
||||
|
||||
elif tracker_info_legacy:
|
||||
reasons.append(f"Known tracker: {tracker_info_legacy.get('name', 'Unknown')}")
|
||||
classification = 'high_interest'
|
||||
|
||||
# Check for audio-capable devices -> high interest
|
||||
@@ -268,6 +302,10 @@ class ThreatDetector:
|
||||
classification = 'high_interest'
|
||||
|
||||
# Include standardized signal classification
|
||||
try:
|
||||
rssi_val = int(rssi) if rssi else -100
|
||||
except (ValueError, TypeError):
|
||||
rssi_val = -100
|
||||
signal_info = get_signal_strength_info(rssi_val)
|
||||
|
||||
return {
|
||||
@@ -275,7 +313,11 @@ class ThreatDetector:
|
||||
'reasons': reasons,
|
||||
'in_baseline': in_baseline,
|
||||
'times_seen': times_seen,
|
||||
'is_tracker': tracker_info is not None,
|
||||
'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'],
|
||||
|
||||
Reference in New Issue
Block a user