feat: associate recon clients with confirmed networks
This commit is contained in:
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user