mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 07:10:00 -07:00
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>
444 lines
16 KiB
Python
444 lines
16 KiB
Python
"""
|
|
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'])
|