diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md new file mode 100644 index 0000000..9035c00 --- /dev/null +++ b/.superpowers/sdd/task-2-report.md @@ -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. diff --git a/payload/user/remote_access/pager-webui/server.py b/payload/user/remote_access/pager-webui/server.py index 092eef8..f9dcf8c 100644 --- a/payload/user/remote_access/pager-webui/server.py +++ b/payload/user/remote_access/pager-webui/server.py @@ -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'), '--'), diff --git a/tests/test_recon.py b/tests/test_recon.py index 5cb3320..1b811a7 100644 --- a/tests/test_recon.py +++ b/tests/test_recon.py @@ -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) " "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) " - "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 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)