feat: associate recon clients with confirmed networks

This commit is contained in:
2026-08-21 08:26:58 -05:00
parent 2c9fa8d137
commit a67faecee9
3 changed files with 149 additions and 14 deletions
+25
View File
@@ -0,0 +1,25 @@
# Task 2 Report: Enrich Scan Associations and APs
## Status
Implemented and verified. The Task 2 changes are committed as `feat: associate recon clients with confirmed networks`.
## Changes
- Added scan-scoped handshake and optional `hostap_client` evidence queries.
- Added deterministic association deduplication keyed by client, BSSID, and SSID.
- Kept directed probe (`ssid.type = 5`) records out of associations.
- Added AP `device_identity`, `clients`, and unique `client_count` fields.
- Added client `vendor` and `associations` fields, including empty associations for unassociated clients.
- Preserved SSID-only `hostap_client` evidence without assigning a BSSID.
- Added fixture coverage for duplicate evidence, a second AP/client pair, directed probes, missing optional tables, identity fields, and timestamps.
## Verification
- Focused tests: `python3 -m unittest tests.test_recon.ReconDataTest -v` -> 7/7 passed.
- Complete Recon tests: `python3 -m unittest tests.test_recon -v` -> 121/121 passed.
- Diff validation: `git diff --check` -> clean.
## Concerns
- The working tree contains pre-existing unstaged HTML report signal-order changes and untracked `loot/` and certificate files; these were intentionally preserved and excluded from the Task 2 commit.
@@ -1341,17 +1341,79 @@ def recon_scan_data(scan_id, _timeout=20, _limit=None, db=None):
'encryption': decode_encryption(r.get('encryption')), 'encryption': decode_encryption(r.get('encryption')),
'band': band_of(r.get('freq')), 'band': band_of(r.get('freq')),
'vendor': oui_vendor(fmt_mac(r.get('bssid'))), 'vendor': oui_vendor(fmt_mac(r.get('bssid'))),
'device_identity': oui_identity(fmt_mac(r.get('bssid'))),
'first_seen': lo, 'first_seen': lo,
'last_seen': hi}) 'last_seen': hi})
aps.sort(key=lambda row: row['signal'] if row['signal'] is not None else 0) aps.sort(key=lambda row: row['signal'] if row['signal'] is not None else 0)
devices = [r for r in rows if r.get('kind') == 'device'] devices = [r for r in rows if r.get('kind') == 'device']
mac_of = {r['row_id']: fmt_mac(r.get('mac')) for r in devices}
ssid_of = {}
for r in (row for row in rows if row.get('kind') == 'ap'):
bssid = _norm_mac(r.get('bssid'))
if bssid and bssid not in ssid_of:
ssid_of[bssid] = decode_ssid(r.get('ssid'))
evidence = {}
def add_association(client_mac, ssid, bssid=None, source=None, **timestamps):
client_mac = _norm_mac(client_mac)
bssid = _norm_mac(bssid) if bssid else None
ssid = decode_ssid(ssid)
if not client_mac or not ssid or not source:
return
key = (client_mac, bssid, ssid)
association = evidence.get(key)
if association is None:
association = {'ssid': ssid, 'sources': []}
if bssid:
association['bssid'] = bssid
evidence[key] = association
if source not in association['sources']:
association['sources'].append(source)
for name, value in timestamps.items():
if value is not None:
association[name] = value
for r in (row for row in rows if row.get('kind') == 'handshake'):
client = mac_of.get(r.get('stahash'))
bssid = mac_of.get(r.get('aphash'))
add_association(client, ssid_of.get(_norm_mac(bssid)), bssid, 'handshake')
try:
hostap_rows = _db_rows(
db, 'SELECT mac, ssid, connected_time, disconnected_time '
'FROM hostap_client WHERE scan = %d ORDER BY connected_time, id' % scan_id,
timeout=_timeout)
except Exception:
hostap_rows = []
for r in hostap_rows:
add_association(r.get('mac'), r.get('ssid'), source='hostap_client',
connected_time=r.get('connected_time'),
disconnected_time=r.get('disconnected_time'))
associations_by_client = {}
for key, association in evidence.items():
associations_by_client.setdefault(key[0], []).append(association)
for ap in aps:
ap['clients'] = []
ap['client_count'] = 0
ap_by_bssid = {_norm_mac(ap['bssid']): ap for ap in aps}
clients = [] clients = []
for r in sorted(devices, key=lambda row: row.get('time') or 0): for r in sorted(devices, key=lambda row: row.get('time') or 0):
if (r.get('mac') or '').strip().upper() in ap_macs: if (r.get('mac') or '').strip().upper() in ap_macs:
continue continue
clients.append({'mac': fmt_mac(r.get('mac')), 'signal': r.get('signal'), mac = _norm_mac(r.get('mac'))
'freq': r.get('freq'), 'packets': r.get('packets')}) client_associations = associations_by_client.get(mac, [])
mac_of = {r['row_id']: fmt_mac(r.get('mac')) for r in devices} client = {'mac': fmt_mac(r.get('mac')), 'signal': r.get('signal'),
'freq': r.get('freq'), 'packets': r.get('packets'),
'vendor': oui_identity(fmt_mac(r.get('mac'))),
'associations': client_associations}
clients.append(client)
for association in client_associations:
ap = ap_by_bssid.get(_norm_mac(association.get('bssid')))
if ap is None:
continue
if not any(item['mac'] == client['mac'] for item in ap['clients']):
ap['clients'].append(client)
ap['client_count'] += 1
handshakes = [] handshakes = []
for r in (row for row in rows if row.get('kind') == 'handshake'): for r in (row for row in rows if row.get('kind') == 'handshake'):
handshakes.append({'ap': mac_of.get(r.get('aphash'), '--'), handshakes.append({'ap': mac_of.get(r.get('aphash'), '--'),
+59 -11
View File
@@ -39,8 +39,22 @@ def make_db():
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) " conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
"VALUES (11, 2, 1, 8, '506F9A010000', X'', 1, 1786466532, -64, 5745, 149, 0)") "VALUES (11, 2, 1, 8, '506F9A010000', X'', 1, 1786466532, -64, 5745, 149, 0)")
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) " conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
"VALUES (12, 1, 1, 4, NULL, X'5A6E6574', NULL, 1786466531, -40, 2412, NULL, NULL)") "VALUES (12, 1, 1, 4, NULL, X'5A6E6574', NULL, 1786466531, -40, 2412, NULL, NULL)")
conn.execute("INSERT INTO handshake (hash, scan, stahash, aphash, time) VALUES (20, 1, 1, 2, 1786466600)") conn.execute("INSERT INTO handshake (hash, scan, stahash, aphash, time) VALUES (20, 1, 1, 2, 1786466600)")
conn.execute("INSERT INTO wifi_device (hash, scan, mac, time, signal, freq, packets) VALUES (101, 1, 'AA11BB22CC33', 1786466533, -61, 2412, 4)")
conn.execute("INSERT INTO wifi_device (hash, scan, mac, time, signal, freq, packets) VALUES (102, 1, 'DDEEFFEEDD00', 1786466534, -58, 2412, 7)")
conn.execute("INSERT INTO wifi_device (hash, scan, mac, time, signal, freq, packets) VALUES (103, 1, '021122334455', 1786466535, -80, 2412, 1)")
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
"VALUES (101, 102, 1, 8, 'DDEEFFEEDD00', X'5365636F6E642D4E6574', 0, 1786466534, -58, 2412, 1, 0)")
conn.execute("INSERT INTO handshake (hash, scan, stahash, aphash, time) VALUES (101, 1, 101, 102, 1786466602)")
conn.execute("INSERT INTO hostap_client (id, scan, hash, mac, ssid, connected_time, disconnected_time) "
"VALUES (101, 1, 20, 'AE77C0EB3141', X'416E646572736F6E2D35', 1786466601, 1786466605)")
conn.execute("INSERT INTO hostap_client (id, scan, hash, mac, ssid, connected_time, disconnected_time) "
"VALUES (102, 1, 22, 'AA11BB22CC33', X'486F73744150', 1786466603, NULL)")
conn.execute("INSERT INTO hostap_client (id, scan, hash, mac, ssid, connected_time, disconnected_time) "
"VALUES (103, 1, 23, 'AA11BB22CC33', X'486F73744150', 1786466604, 1786466606)")
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
"VALUES (102, 101, 1, 5, NULL, X'50726F62654F6E6C7953534944', 0, 1786466604, -30, 2412, NULL, NULL)")
conn.execute("INSERT INTO hostap_basic (scan, time, type, identity, password, verified) VALUES (1, 1786466601, 'WPA', 'bob', '', 0)") conn.execute("INSERT INTO hostap_basic (scan, time, type, identity, password, verified) VALUES (1, 1786466601, 'WPA', 'bob', '', 0)")
conn.commit() conn.commit()
conn.close() conn.close()
@@ -101,16 +115,16 @@ class ReconDataTest(unittest.TestCase):
self.assertEqual(newest['time'], 1786466848) self.assertEqual(newest['time'], 1786466848)
self.assertEqual(newest['name'], 'pager') self.assertEqual(newest['name'], 'pager')
old = data['scans'][1] old = data['scans'][1]
self.assertEqual(old['devices'], 2) self.assertEqual(old['devices'], 5)
self.assertEqual(old['aps'], 2) self.assertEqual(old['aps'], 3)
self.assertEqual(old['handshakes'], 1) self.assertEqual(old['handshakes'], 2)
self.assertNotIn('uuid', old) self.assertNotIn('uuid', old)
def test_scan_detail_decodes_aps(self): def test_scan_detail_decodes_aps(self):
data = server.recon_scan_data(1) data = server.recon_scan_data(1)
self.assertEqual(data['scan']['id'], 1) self.assertEqual(data['scan']['id'], 1)
self.assertEqual(data['scan']['time'], 1786466531) self.assertEqual(data['scan']['time'], 1786466531)
self.assertEqual(len(data['aps']), 2) self.assertEqual(len(data['aps']), 3)
aps = {a['bssid']: a for a in data['aps']} aps = {a['bssid']: a for a in data['aps']}
a = aps['C8:9E:43:64:80:80'] a = aps['C8:9E:43:64:80:80']
self.assertEqual(a['ssid'], 'Anderson-5') self.assertEqual(a['ssid'], 'Anderson-5')
@@ -125,16 +139,50 @@ class ReconDataTest(unittest.TestCase):
def test_scan_detail_clients_exclude_ap_macs(self): def test_scan_detail_clients_exclude_ap_macs(self):
data = server.recon_scan_data(1) data = server.recon_scan_data(1)
macs = [c['mac'] for c in data['clients']] macs = [c['mac'] for c in data['clients']]
self.assertEqual(macs, ['AE:77:C0:EB:31:41']) self.assertEqual(macs, ['AE:77:C0:EB:31:41', 'AA:11:BB:22:CC:33',
'02:11:22:33:44:55'])
def test_scan_detail_handshakes_resolve_macs(self): def test_scan_detail_handshakes_resolve_macs(self):
data = server.recon_scan_data(1) data = server.recon_scan_data(1)
self.assertEqual(len(data['handshakes']), 1) self.assertEqual(len(data['handshakes']), 2)
hs = data['handshakes'][0] hs = next(h for h in data['handshakes']
if h['client'] == 'AE:77:C0:EB:31:41')
self.assertEqual(hs['ap'], 'C8:9E:43:64:80:80') self.assertEqual(hs['ap'], 'C8:9E:43:64:80:80')
self.assertEqual(hs['client'], 'AE:77:C0:EB:31:41') self.assertEqual(hs['client'], 'AE:77:C0:EB:31:41')
self.assertEqual(hs['time'], 1786466600) self.assertEqual(hs['time'], 1786466600)
def test_scan_detail_associations_are_confirmed_only(self):
data = server.recon_scan_data(1)
client = next(c for c in data['clients'] if c['mac'] == 'AE:77:C0:EB:31:41')
handshake = next(a for a in client['associations'] if 'bssid' in a)
self.assertEqual(handshake['sources'], ['handshake'])
self.assertEqual(handshake['ssid'], 'Anderson-5')
self.assertEqual(handshake['bssid'], 'C8:9E:43:64:80:80')
self.assertEqual(client['vendor']['manufacturer'], 'Local/Randomized')
self.assertEqual(next(a for a in data['aps'] if a['bssid'] == 'C8:9E:43:64:80:80')['client_count'], 1)
self.assertEqual(next(a for a in data['aps'] if a['bssid'] == 'C8:9E:43:64:80:80')['clients'][0]['mac'], client['mac'])
self.assertNotIn('ProbeOnlySSID', [a['ssid'] for a in client['associations']])
hostap_client = next(c for c in data['clients'] if c['mac'] == 'AA:11:BB:22:CC:33')
ssid_only = next(a for a in hostap_client['associations'] if a['ssid'] == 'HostAP')
self.assertEqual(ssid_only['sources'], ['hostap_client'])
self.assertNotIn('bssid', ssid_only)
self.assertEqual(ssid_only['connected_time'], 1786466604)
self.assertEqual(ssid_only['disconnected_time'], 1786466606)
self.assertEqual(sum(a['ssid'] == 'HostAP' for a in hostap_client['associations']), 1)
second_ap = next(a for a in hostap_client['associations'] if 'bssid' in a)
self.assertEqual(second_ap['ssid'], 'Second-Net')
unassociated = next(c for c in data['clients'] if c['mac'] == '02:11:22:33:44:55')
self.assertEqual(unassociated['associations'], [])
def test_scan_detail_allows_missing_hostap_client_table(self):
conn = sqlite3.connect(self.db)
conn.execute('DROP TABLE hostap_client')
conn.commit()
conn.close()
data = server.recon_scan_data(1)
client = next(c for c in data['clients'] if c['mac'] == 'AE:77:C0:EB:31:41')
self.assertEqual([a['sources'] for a in client['associations']], [['handshake']])
def test_scan_detail_missing_returns_none(self): def test_scan_detail_missing_returns_none(self):
self.assertIsNone(server.recon_scan_data(999)) self.assertIsNone(server.recon_scan_data(999))
@@ -525,7 +573,7 @@ class ReconExtrasTest(unittest.TestCase):
status, data = server.h_recon_status(type('C', (), {'args': ()})()) status, data = server.h_recon_status(type('C', (), {'args': ()})())
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertEqual(data['last_scan'], 1786466848) self.assertEqual(data['last_scan'], 1786466848)
self.assertEqual(data['last_activity'], 1786466532) self.assertEqual(data['last_activity'], 1786466535)
self.assertTrue(data['active']) self.assertTrue(data['active'])
server.time.time = lambda: 1786466532 + 1000 server.time.time = lambda: 1786466532 + 1000
status, data = server.h_recon_status(type('C', (), {'args': ()})()) status, data = server.h_recon_status(type('C', (), {'args': ()})())
@@ -580,7 +628,7 @@ class ReconExtrasTest(unittest.TestCase):
self.assertEqual(status, 200) self.assertEqual(status, 200)
kinds = [e['type'] for e in data['events']] kinds = [e['type'] for e in data['events']]
self.assertIn('auth attempt', kinds) self.assertIn('auth attempt', kinds)
self.assertEqual(data['events'][0]['time'], 1786466601) self.assertEqual(data['events'][0]['time'], 1786466602)
class ReconExamineTest(unittest.TestCase): class ReconExamineTest(unittest.TestCase):
@@ -988,7 +1036,7 @@ class ReconEnrichmentTest(unittest.TestCase):
def test_scan_detail_bounded_mode_counts_unassociated(self): def test_scan_detail_bounded_mode_counts_unassociated(self):
data = server.recon_scan_data(1, _limit=1) data = server.recon_scan_data(1, _limit=1)
self.assertEqual(data['unassociated'], 1) self.assertEqual(data['unassociated'], 1)
self.assertEqual(len(data['aps']), 2) self.assertEqual(len(data['aps']), 3)
self.assertLessEqual(len(data['clients']), 1) self.assertLessEqual(len(data['clients']), 1)
self.assertEqual(data['scan']['id'], 1) self.assertEqual(data['scan']['id'], 1)