mirror of
https://github.com/smittix/intercept.git
synced 2026-06-18 18:39:47 -07:00
fix: tracker signature scoring — gate boost/length signals, name-only detects LOW
confidence_boost and the manufacturer-data-length signal applied without any identifying indicator match, giving every device a phantom AirTag baseline (a 22+ byte payload from any vendor scored 0.30 and was flagged as an AirTag). Both now require a matched indicator, mirroring the score>0 gating already used in _check_generic_tracker_indicators. Name-pattern weight raised 0.15 -> 0.30 so a device advertising a known tracker name yields a LOW-confidence detection, consistent with the TSCM BLE scanner's name-only detection and the engine docstring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+170
-156
@@ -23,138 +23,147 @@ from utils.bluetooth.tracker_signatures import (
|
||||
# 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 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,
|
||||
"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,
|
||||
"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 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 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,
|
||||
"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 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,
|
||||
"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": "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": "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": "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,
|
||||
"name": "Generic device with long payload - length alone is not evidence",
|
||||
"address": "22:33:44:55:66:77",
|
||||
"address_type": "public",
|
||||
"manufacturer_id": 0x0006, # Microsoft
|
||||
"manufacturer_data": bytes.fromhex("0600" + "ab" * 23), # 25 bytes
|
||||
"service_uuids": [],
|
||||
"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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -163,6 +172,7 @@ NON_TRACKER_SAMPLES = [
|
||||
# TEST CASES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestTrackerDetection:
|
||||
"""Test tracker detection with sample payloads."""
|
||||
|
||||
@@ -173,80 +183,83 @@ class TestTrackerDetection:
|
||||
|
||||
# --- AirTag tests ---
|
||||
|
||||
@pytest.mark.parametrize('sample', AIRTAG_SAMPLES, ids=lambda s: s['name'])
|
||||
@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'],
|
||||
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'], \
|
||||
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), \
|
||||
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'])
|
||||
@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'],
|
||||
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'], \
|
||||
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'])
|
||||
@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'],
|
||||
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'], \
|
||||
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'])
|
||||
@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'],
|
||||
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})"
|
||||
assert not result.is_tracker, f"{sample['name']} should NOT be detected as tracker (got: {result.tracker_type})"
|
||||
|
||||
|
||||
class TestFingerprinting:
|
||||
@@ -260,32 +273,31 @@ class TestFingerprinting:
|
||||
"""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'],
|
||||
manufacturer_data=bytes.fromhex("1219deadbeef"),
|
||||
service_uuids=["fd6f"],
|
||||
service_data={},
|
||||
tx_power=-10,
|
||||
name='TestDevice',
|
||||
name="TestDevice",
|
||||
)
|
||||
|
||||
fp2 = engine.generate_device_fingerprint(
|
||||
manufacturer_id=APPLE_COMPANY_ID,
|
||||
manufacturer_data=bytes.fromhex('1219deadbeef'),
|
||||
service_uuids=['fd6f'],
|
||||
manufacturer_data=bytes.fromhex("1219deadbeef"),
|
||||
service_uuids=["fd6f"],
|
||||
service_data={},
|
||||
tx_power=-10,
|
||||
name='TestDevice',
|
||||
name="TestDevice",
|
||||
)
|
||||
|
||||
assert fp1.fingerprint_id == fp2.fingerprint_id, \
|
||||
"Same payload should produce same fingerprint"
|
||||
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'],
|
||||
manufacturer_data=bytes.fromhex("1219abcdef"),
|
||||
service_uuids=["fd6f"],
|
||||
service_data={},
|
||||
tx_power=None,
|
||||
name=None,
|
||||
@@ -294,8 +306,8 @@ class TestFingerprinting:
|
||||
# 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'],
|
||||
manufacturer_data=bytes.fromhex("1219abcdef"),
|
||||
service_uuids=["fd6f"],
|
||||
service_data={},
|
||||
tx_power=None,
|
||||
name=None,
|
||||
@@ -308,11 +320,11 @@ class TestFingerprinting:
|
||||
# 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')},
|
||||
manufacturer_data=bytes.fromhex("1219aabbccdd"),
|
||||
service_uuids=["fd6f", "180f"],
|
||||
service_data={"fd6f": bytes.fromhex("01")},
|
||||
tx_power=-5,
|
||||
name='AirTag',
|
||||
name="AirTag",
|
||||
)
|
||||
|
||||
# Minimal payload = low stability
|
||||
@@ -325,8 +337,9 @@ class TestFingerprinting:
|
||||
name=None,
|
||||
)
|
||||
|
||||
assert fp_rich.stability_confidence > fp_minimal.stability_confidence, \
|
||||
assert fp_rich.stability_confidence > fp_minimal.stability_confidence, (
|
||||
"Rich payload should have higher stability confidence"
|
||||
)
|
||||
|
||||
|
||||
class TestSuspiciousPresence:
|
||||
@@ -339,7 +352,7 @@ class TestSuspiciousPresence:
|
||||
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',
|
||||
fingerprint_id="test123",
|
||||
is_tracker=True,
|
||||
seen_count=5,
|
||||
duration_seconds=60,
|
||||
@@ -349,12 +362,12 @@ class TestSuspiciousPresence:
|
||||
)
|
||||
|
||||
assert risk_score >= 0.3, "Tracker should have base risk score"
|
||||
assert any('tracker' in f.lower() for f in risk_factors)
|
||||
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',
|
||||
fingerprint_id="test456",
|
||||
is_tracker=True,
|
||||
seen_count=50,
|
||||
duration_seconds=900, # 15 minutes
|
||||
@@ -369,7 +382,7 @@ class TestSuspiciousPresence:
|
||||
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',
|
||||
fingerprint_id="test789",
|
||||
is_tracker=False,
|
||||
seen_count=5,
|
||||
duration_seconds=60,
|
||||
@@ -387,11 +400,11 @@ class TestConvenienceFunction:
|
||||
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',
|
||||
address="C4:E7:11:22:33:44",
|
||||
address_type="public",
|
||||
name="Tile Mate",
|
||||
manufacturer_id=0x00ED,
|
||||
service_uuids=['feed'],
|
||||
service_uuids=["feed"],
|
||||
)
|
||||
|
||||
assert result.is_tracker
|
||||
@@ -408,6 +421,7 @@ class TestConvenienceFunction:
|
||||
# SMOKE TEST FOR API ENDPOINTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_api_backwards_compatibility():
|
||||
"""
|
||||
Smoke test checklist for API backwards compatibility.
|
||||
@@ -439,5 +453,5 @@ def test_api_backwards_compatibility():
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
@@ -19,35 +19,38 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
|
||||
logger = logging.getLogger('intercept.bluetooth.tracker_signatures')
|
||||
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'
|
||||
|
||||
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
|
||||
|
||||
HIGH = "high" # Multiple strong indicators match
|
||||
MEDIUM = "medium" # Some indicators match
|
||||
LOW = "low" # Weak indicators, needs investigation
|
||||
NONE = "none" # Not detected as tracker
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -65,28 +68,28 @@ 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'
|
||||
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']
|
||||
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']
|
||||
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'
|
||||
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']
|
||||
PEBBLEBEE_SERVICE_UUID = "feab"
|
||||
PEBBLEBEE_MAC_PREFIXES = ["D4:3D", "E0:E5"]
|
||||
|
||||
# Other known trackers
|
||||
NUTFIND_COMPANY_ID = 0x0A09
|
||||
@@ -94,16 +97,17 @@ 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
|
||||
"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
|
||||
@@ -123,82 +127,76 @@ TRACKER_SIGNATURES: list[TrackerSignature] = [
|
||||
# Apple AirTag
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.AIRTAG,
|
||||
name='Apple AirTag',
|
||||
description='Apple AirTag tracking device using Find My network',
|
||||
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'],
|
||||
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',
|
||||
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'],
|
||||
name_patterns=["findmy", "find my", "chipolo one spot", "belkin"],
|
||||
),
|
||||
|
||||
# Tile
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.TILE,
|
||||
name='Tile Tracker',
|
||||
description='Tile Bluetooth tracker',
|
||||
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'],
|
||||
name_patterns=["tile"],
|
||||
),
|
||||
|
||||
# Samsung SmartTag
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.SAMSUNG_SMARTTAG,
|
||||
name='Samsung SmartTag',
|
||||
description='Samsung SmartThings tracker',
|
||||
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'],
|
||||
name_patterns=["smarttag", "smart tag", "galaxy tag"],
|
||||
),
|
||||
|
||||
# Chipolo
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.CHIPOLO,
|
||||
name='Chipolo',
|
||||
description='Chipolo Bluetooth tracker',
|
||||
name="Chipolo",
|
||||
description="Chipolo Bluetooth tracker",
|
||||
company_id=CHIPOLO_COMPANY_ID,
|
||||
service_uuids=[CHIPOLO_SERVICE_UUID, CHIPOLO_ALT_SERVICE],
|
||||
name_patterns=['chipolo'],
|
||||
name_patterns=["chipolo"],
|
||||
),
|
||||
|
||||
# PebbleBee
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.PEBBLEBEE,
|
||||
name='PebbleBee',
|
||||
description='PebbleBee Bluetooth tracker',
|
||||
name="PebbleBee",
|
||||
description="PebbleBee Bluetooth tracker",
|
||||
service_uuids=[PEBBLEBEE_SERVICE_UUID],
|
||||
mac_prefixes=PEBBLEBEE_MAC_PREFIXES,
|
||||
name_patterns=['pebblebee', 'pebble bee', 'honey'],
|
||||
name_patterns=["pebblebee", "pebble bee", "honey"],
|
||||
),
|
||||
|
||||
# Eufy
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.EUFY,
|
||||
name='Eufy SmartTrack',
|
||||
description='Eufy/Anker smart tracker',
|
||||
name="Eufy SmartTrack",
|
||||
description="Eufy/Anker smart tracker",
|
||||
company_id=EUFY_COMPANY_ID,
|
||||
name_patterns=['eufy', 'smarttrack'],
|
||||
name_patterns=["eufy", "smarttrack"],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -207,13 +205,14 @@ TRACKER_SIGNATURES: list[TrackerSignature] = [
|
||||
# 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 = ''
|
||||
tracker_name: str = ""
|
||||
confidence: TrackerConfidence = TrackerConfidence.NONE
|
||||
confidence_score: float = 0.0 # 0.0 to 1.0
|
||||
evidence: list[str] = field(default_factory=list)
|
||||
@@ -231,18 +230,18 @@ class TrackerDetectionResult:
|
||||
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,
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
@@ -250,6 +249,7 @@ class TrackerDetectionResult:
|
||||
# DEVICE FINGERPRINT (survives MAC randomization)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceFingerprint:
|
||||
"""
|
||||
@@ -277,15 +277,15 @@ class DeviceFingerprint:
|
||||
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),
|
||||
"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),
|
||||
}
|
||||
|
||||
|
||||
@@ -316,39 +316,39 @@ def generate_fingerprint(
|
||||
mfr_length = 0
|
||||
|
||||
if manufacturer_id is not None:
|
||||
features.append(f'mfr:{manufacturer_id:04x}')
|
||||
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}')
|
||||
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()}')
|
||||
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)}')
|
||||
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)}')
|
||||
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'
|
||||
tx_bucket = "high"
|
||||
elif tx_power >= -10:
|
||||
tx_bucket = 'medium'
|
||||
tx_bucket = "medium"
|
||||
else:
|
||||
tx_bucket = 'low'
|
||||
features.append(f'tx:{tx_bucket}')
|
||||
tx_bucket = "low"
|
||||
features.append(f"tx:{tx_bucket}")
|
||||
stability_score += 0.05
|
||||
|
||||
# Name hint (for devices that advertise names)
|
||||
@@ -357,11 +357,11 @@ def generate_fingerprint(
|
||||
# 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}')
|
||||
features.append(f"name:{name_hint}")
|
||||
stability_score += 0.15
|
||||
|
||||
# Generate fingerprint ID
|
||||
feature_str = '|'.join(features)
|
||||
feature_str = "|".join(features)
|
||||
fingerprint_id = hashlib.sha256(feature_str.encode()).hexdigest()[:16]
|
||||
|
||||
return DeviceFingerprint(
|
||||
@@ -381,6 +381,7 @@ def generate_fingerprint(
|
||||
# TRACKER DETECTION ENGINE
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TrackerSignatureEngine:
|
||||
"""
|
||||
Engine for detecting known BLE trackers from advertising data.
|
||||
@@ -485,7 +486,7 @@ class TrackerSignatureEngine:
|
||||
result.matched_signature = best_match.name
|
||||
else:
|
||||
result.tracker_type = TrackerType.UNKNOWN_TRACKER
|
||||
result.tracker_name = 'Unknown Tracker'
|
||||
result.tracker_name = "Unknown Tracker"
|
||||
|
||||
# Determine confidence level
|
||||
if best_score >= 0.7:
|
||||
@@ -534,32 +535,35 @@ class TrackerSignatureEngine:
|
||||
|
||||
if has_findmy_pattern or has_findmy_service:
|
||||
score += 0.35
|
||||
evidence.append(f'Manufacturer ID 0x{manufacturer_id:04X} matches {signature.name}')
|
||||
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}')
|
||||
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}')
|
||||
evidence.append(f"Manufacturer data pattern matches {signature.name}")
|
||||
break
|
||||
|
||||
# Check manufacturer data length
|
||||
if manufacturer_data and signature.min_manufacturer_data_len > 0:
|
||||
# Check manufacturer data length (corroborative - only counts alongside
|
||||
# an identifying indicator, mirroring _check_generic_tracker_indicators)
|
||||
if manufacturer_data and signature.min_manufacturer_data_len > 0 and score > 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}')
|
||||
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}')
|
||||
evidence.append(f"Service UUID {sig_uuid} matches {signature.name}")
|
||||
break
|
||||
|
||||
# Check MAC prefix (medium weight)
|
||||
@@ -568,20 +572,24 @@ class TrackerSignatureEngine:
|
||||
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')
|
||||
evidence.append(f"MAC prefix {prefix} matches known {signature.name} range")
|
||||
break
|
||||
|
||||
# Check name patterns (lower weight - can be spoofed)
|
||||
# Check name patterns - a name match alone yields a LOW-confidence
|
||||
# detection (0.30 = detection threshold); names can be spoofed, so it
|
||||
# stays below the company-ID weight
|
||||
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
|
||||
score += 0.30
|
||||
evidence.append(f'Device name "{name}" contains pattern "{pattern}"')
|
||||
break
|
||||
|
||||
# Apply confidence boost for specific signatures
|
||||
score += signature.confidence_boost
|
||||
# Apply confidence boost for specific signatures, but only when at
|
||||
# least one indicator actually matched - never as a free baseline
|
||||
if score > 0:
|
||||
score += signature.confidence_boost
|
||||
|
||||
return score, evidence
|
||||
|
||||
@@ -600,33 +608,33 @@ class TrackerSignatureEngine:
|
||||
# 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)')
|
||||
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 and 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')
|
||||
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})')
|
||||
evidence.append(f"Uses beacon service UUID ({beacon_uuid})")
|
||||
break
|
||||
|
||||
# Random address (most trackers use random addresses)
|
||||
if address_type in ('random', 'rpa', 'nrpa'):
|
||||
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')
|
||||
evidence.append("Uses randomized MAC address")
|
||||
|
||||
# Small manufacturer data payload typical of beacons
|
||||
if manufacturer_data and 20 <= len(manufacturer_data) <= 30 and score > 0:
|
||||
score += 0.05
|
||||
evidence.append(f'Manufacturer data length ({len(manufacturer_data)} bytes) typical of beacon')
|
||||
evidence.append(f"Manufacturer data length ({len(manufacturer_data)} bytes) typical of beacon")
|
||||
|
||||
return score, evidence
|
||||
|
||||
@@ -637,7 +645,7 @@ class TrackerSignatureEngine:
|
||||
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'):
|
||||
if len(uuid_lower) == 36 and uuid_lower.endswith("-0000-1000-8000-00805f9b34fb"):
|
||||
short_uuid = uuid_lower[4:8]
|
||||
normalized.append(short_uuid)
|
||||
else:
|
||||
@@ -676,10 +684,7 @@ class TrackerSignatureEngine:
|
||||
|
||||
# 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] = [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])
|
||||
@@ -719,39 +724,39 @@ class TrackerSignatureEngine:
|
||||
# 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')
|
||||
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')
|
||||
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')
|
||||
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')
|
||||
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})')
|
||||
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')
|
||||
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')
|
||||
risk_factors.append(f"Seen across multiple sessions: {historical_count} total sightings in 24h")
|
||||
|
||||
return min(1.0, risk_score), risk_factors
|
||||
|
||||
@@ -773,7 +778,7 @@ def get_tracker_engine() -> TrackerSignatureEngine:
|
||||
|
||||
def detect_tracker(
|
||||
address: str,
|
||||
address_type: str = 'public',
|
||||
address_type: str = "public",
|
||||
name: str | None = None,
|
||||
manufacturer_id: int | None = None,
|
||||
manufacturer_data: bytes | None = None,
|
||||
|
||||
Reference in New Issue
Block a user