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')),
'band': band_of(r.get('freq')),
'vendor': oui_vendor(fmt_mac(r.get('bssid'))),
'device_identity': oui_identity(fmt_mac(r.get('bssid'))),
'first_seen': lo,
'last_seen': hi})
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']
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 = []
for r in sorted(devices, key=lambda row: row.get('time') or 0):
if (r.get('mac') or '').strip().upper() in ap_macs:
continue
clients.append({'mac': fmt_mac(r.get('mac')), 'signal': r.get('signal'),
'freq': r.get('freq'), 'packets': r.get('packets')})
mac_of = {r['row_id']: fmt_mac(r.get('mac')) for r in devices}
mac = _norm_mac(r.get('mac'))
client_associations = associations_by_client.get(mac, [])
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 = []
for r in (row for row in rows if row.get('kind') == 'handshake'):
handshakes.append({'ap': mac_of.get(r.get('aphash'), '--'),
+58 -10
View File
@@ -41,6 +41,20 @@ def make_db():
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)")
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.commit()
conn.close()
@@ -101,16 +115,16 @@ class ReconDataTest(unittest.TestCase):
self.assertEqual(newest['time'], 1786466848)
self.assertEqual(newest['name'], 'pager')
old = data['scans'][1]
self.assertEqual(old['devices'], 2)
self.assertEqual(old['aps'], 2)
self.assertEqual(old['handshakes'], 1)
self.assertEqual(old['devices'], 5)
self.assertEqual(old['aps'], 3)
self.assertEqual(old['handshakes'], 2)
self.assertNotIn('uuid', old)
def test_scan_detail_decodes_aps(self):
data = server.recon_scan_data(1)
self.assertEqual(data['scan']['id'], 1)
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']}
a = aps['C8:9E:43:64:80:80']
self.assertEqual(a['ssid'], 'Anderson-5')
@@ -125,16 +139,50 @@ class ReconDataTest(unittest.TestCase):
def test_scan_detail_clients_exclude_ap_macs(self):
data = server.recon_scan_data(1)
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):
data = server.recon_scan_data(1)
self.assertEqual(len(data['handshakes']), 1)
hs = data['handshakes'][0]
self.assertEqual(len(data['handshakes']), 2)
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['client'], 'AE:77:C0:EB:31:41')
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):
self.assertIsNone(server.recon_scan_data(999))
@@ -525,7 +573,7 @@ class ReconExtrasTest(unittest.TestCase):
status, data = server.h_recon_status(type('C', (), {'args': ()})())
self.assertEqual(status, 200)
self.assertEqual(data['last_scan'], 1786466848)
self.assertEqual(data['last_activity'], 1786466532)
self.assertEqual(data['last_activity'], 1786466535)
self.assertTrue(data['active'])
server.time.time = lambda: 1786466532 + 1000
status, data = server.h_recon_status(type('C', (), {'args': ()})())
@@ -580,7 +628,7 @@ class ReconExtrasTest(unittest.TestCase):
self.assertEqual(status, 200)
kinds = [e['type'] for e in data['events']]
self.assertIn('auth attempt', kinds)
self.assertEqual(data['events'][0]['time'], 1786466601)
self.assertEqual(data['events'][0]['time'], 1786466602)
class ReconExamineTest(unittest.TestCase):
@@ -988,7 +1036,7 @@ class ReconEnrichmentTest(unittest.TestCase):
def test_scan_detail_bounded_mode_counts_unassociated(self):
data = server.recon_scan_data(1, _limit=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.assertEqual(data['scan']['id'], 1)