Refactor enrichment module by adding utility functions, enhancing CSV handling, and implementing SSID metrics extraction. Update run_test script to improve logging on speed test failures.

This commit is contained in:
Yaro Kasear 2025-04-24 15:43:36 -05:00
parent 4b9ad6f609
commit 55b0835dd7
11 changed files with 541 additions and 406 deletions

0
enrichment/__init__.py Normal file
View file

50
enrichment/constants.py Normal file
View file

@ -0,0 +1,50 @@
# United States regulatory domain channel lookup table
CHANNEL_LOOKUP_TABLE = {
# 2.4 GHz (non-DFS, always allowed)
1: {"freq": 2412, "dfs": False, "band": "2.4GHz"},
2: {"freq": 2417, "dfs": False, "band": "2.4GHz"},
3: {"freq": 2422, "dfs": False, "band": "2.4GHz"},
4: {"freq": 2427, "dfs": False, "band": "2.4GHz"},
5: {"freq": 2432, "dfs": False, "band": "2.4GHz"},
6: {"freq": 2437, "dfs": False, "band": "2.4GHz"},
7: {"freq": 2442, "dfs": False, "band": "2.4GHz"},
8: {"freq": 2447, "dfs": False, "band": "2.4GHz"},
9: {"freq": 2452, "dfs": False, "band": "2.4GHz"},
10: {"freq": 2457, "dfs": False, "band": "2.4GHz"},
11: {"freq": 2462, "dfs": False, "band": "2.4GHz"},
# 5 GHz UNII-1 (indoor only)
36: {"freq": 5180, "dfs": False, "band": "UNII-1"},
40: {"freq": 5200, "dfs": False, "band": "UNII-1"},
44: {"freq": 5220, "dfs": False, "band": "UNII-1"},
48: {"freq": 5240, "dfs": False, "band": "UNII-1"},
# 5 GHz UNII-2 (DFS required)
52: {"freq": 5260, "dfs": True, "band": "UNII-2"},
56: {"freq": 5280, "dfs": True, "band": "UNII-2"},
60: {"freq": 5300, "dfs": True, "band": "UNII-2"},
64: {"freq": 5320, "dfs": True, "band": "UNII-2"},
# 5 GHz UNII-2e (DFS required)
100: {"freq": 5500, "dfs": True, "band": "UNII-2e"},
104: {"freq": 5520, "dfs": True, "band": "UNII-2e"},
108: {"freq": 5540, "dfs": True, "band": "UNII-2e"},
112: {"freq": 5560, "dfs": True, "band": "UNII-2e"},
116: {"freq": 5580, "dfs": True, "band": "UNII-2e"},
120: {"freq": 5600, "dfs": True, "band": "UNII-2e"},
124: {"freq": 5620, "dfs": True, "band": "UNII-2e"},
128: {"freq": 5640, "dfs": True, "band": "UNII-2e"},
132: {"freq": 5660, "dfs": True, "band": "UNII-2e"},
136: {"freq": 5680, "dfs": True, "band": "UNII-2e"},
140: {"freq": 5700, "dfs": True, "band": "UNII-2e"},
# 5 GHz UNII-3 (outdoor/indoor, no DFS)
149: {"freq": 5745, "dfs": False, "band": "UNII-3"},
153: {"freq": 5765, "dfs": False, "band": "UNII-3"},
157: {"freq": 5785, "dfs": False, "band": "UNII-3"},
161: {"freq": 5805, "dfs": False, "band": "UNII-3"},
165: {"freq": 5825, "dfs": False, "band": "UNII-3"},
}
FREQ_LOOKUP_TABLE = {v["freq"]: ch for ch, v in CHANNEL_LOOKUP_TABLE.items()}

43
enrichment/csv_handler.py Normal file
View file

@ -0,0 +1,43 @@
import csv
import os
from pathlib import Path
def read_csv_input(path):
"""Read a CSV file and return a list of row dictionaries."""
with open(path, newline='', encoding='utf-8') as infile:
reader = csv.DictReader(infile)
rows = list(reader)
print(f"[+] Loaded {len(rows)} rows from {path}")
return rows, reader.fieldnames
def write_enriched_csv(path, fieldnames, rows):
"""Write enriched rows to the specified output CSV."""
with open(path, 'w', newline='', encoding='utf-8') as outfile:
writer = csv.DictWriter(outfile, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
writer.writerow(row)
print(f"[+] Wrote {len(rows)} rows to {path}")
def write_ssid_sidecar(enriched_path, ssid_summary):
"""
Given the path to the enriched CSV and the SSID summary,
write a sidecar CSV file next to it.
"""
enriched = Path(enriched_path)
ssid_outfile = enriched.with_name(enriched.stem + '-ssid-metrics.csv')
with ssid_outfile.open('w', newline='', encoding='utf-8') as f:
fieldnames = [
'SSID', 'Hidden', 'Open', 'BSSID_Count', 'BSSIDs', 'Avg_Signal', 'Max_Signal',
'Min_Signal', 'Clients_Seen', 'CiscoAvgClients', 'CiscoMaxClients', 'PacketCount'
]
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for row in ssid_summary:
writer.writerow(row)
print(f"[+] Wrote SSID metrics to {ssid_outfile}")

12
enrichment/filters.py Normal file
View file

@ -0,0 +1,12 @@
def filter_by_time(capture, start_ts, end_ts):
filtered = []
for packet in capture:
try:
frame_time = float(packet.frame_info.time_epoch)
if start_ts <= frame_time <= end_ts:
filtered.append(packet)
except Exception:
continue
return filtered

View file

@ -0,0 +1,56 @@
from collections import defaultdict
from statistics import mean
def merge_ssid_summaries(summary_lists):
merged = {}
for summary in summary_lists:
for entry in summary:
ssid = entry['SSID']
key = ssid # You could also key on both SSID + BSSID set if you're feeling spicy
if key not in merged:
merged[key] = {
'SSID': ssid,
'Hidden': entry['Hidden'],
'Open': entry['Open'],
'BSSID_Count': entry['BSSID_Count'],
'BSSIDs': set(entry['BSSIDs'].split(";")),
'Avg_Signal': [entry['Avg_Signal']],
'Max_Signal': entry['Max_Signal'],
'Min_Signal': entry['Min_Signal'],
'Clients_Seen': entry['Clients_Seen'],
'CiscoAvgClients': [entry['CiscoAvgClients']],
'CiscoMaxClients': entry['CiscoMaxClients'],
'PacketCount': entry['PacketCount']
}
else:
merged[key]['Hidden'] = merged[key]['Hidden'] or entry['Hidden']
merged[key]['Open'] = merged[key]['Open'] and entry['Open']
merged[key]['BSSIDs'].update(entry['BSSIDs'].split(";"))
merged[key]['Avg_Signal'].append(entry['Avg_Signal'])
merged[key]['Max_Signal'] = max(merged[key]['Max_Signal'], entry['Max_Signal'])
merged[key]['Min_Signal'] = min(merged[key]['Min_Signal'], entry['Min_Signal'])
merged[key]['Clients_Seen'] += entry['Clients_Seen']
merged[key]['CiscoAvgClients'].append(entry['CiscoAvgClients'])
merged[key]['CiscoMaxClients'] = max(merged[key]['CiscoMaxClients'], entry['CiscoMaxClients'])
merged[key]['PacketCount'] += entry['PacketCount']
final_list = []
for ssid, data in merged.items():
final_list.append({
'SSID': data['SSID'],
'Hidden': data['Hidden'],
'Open': data['Open'],
'BSSID_Count': len(data['BSSIDs']),
'BSSIDs': ";".join(sorted(data['BSSIDs'])),
'Avg_Signal': round(mean(data['Avg_Signal']), 2),
'Max_Signal': data['Max_Signal'],
'Min_Signal': data['Min_Signal'],
'Clients_Seen': data['Clients_Seen'],
'CiscoAvgClients': round(mean(data['CiscoAvgClients']), 2),
'CiscoMaxClients': data['CiscoMaxClients'],
'PacketCount': data['PacketCount']
})
return final_list

View file

@ -0,0 +1,66 @@
from collections import defaultdict
from enrichment.utils import get_channel_from_freq
def get_clients_on_ap(capture, ap_bssid):
clients = defaultdict(list)
ap_bssid = ap_bssid.lower()
for packet in capture:
try:
if not hasattr(packet, "wlan"):
continue
sa = getattr(packet.wlan, "sa", '').lower()
da = getattr(packet.wlan, "da", '').lower()
bssid = getattr(packet.wlan, "bssid", '').lower()
if bssid == ap_bssid or sa == ap_bssid or da == ap_bssid:
if sa == ap_bssid and da and da != ap_bssid and not da.startswith("ff:ff:ff:ff:ff:ff"):
clients[da] += 1
elif sa and sa != ap_bssid and not sa.startswith("ff:ff:ff:ff:ff:ff"):
clients[sa] += 1
except AttributeError:
continue
return len([mac for mac, count in clients.items() if count > 3])
def get_clients_on_channel(capture, ap_channel, ap_bssid):
try:
ap_channel = int(ap_channel)
except ValueError:
print(f"[!] Could not parse channel number: {ap_channel}")
return 0
clients = set()
for packet in capture:
try:
if 'radiotap' not in packet or 'wlan' not in packet:
continue
radio = packet.radiotap
wlan = packet.wlan
if not hasattr(radio, 'channel') or not hasattr(radio.channel, 'freq'):
continue
packet_freq = int(radio.channel.freq)
packet_channel = get_channel_from_freq(packet_freq)
if packet_channel != ap_channel:
continue
sa = getattr(wlan, 'sa', '').lower()
da = getattr(wlan, 'da', '').lower()
for mac in (sa, da):
if mac and mac != 'ff:ff:ff:ff:ff:ff' and mac != ap_bssid:
clients.add(mac)
except AttributeError:
continue
except Exception as e:
print(f"[!] Error parsing packet: {e}")
continue
return len(clients)

View file

@ -0,0 +1,126 @@
# enrichment/metrics_signals.py
from statistics import mean
from enrichment.utils import get_channel_from_freq
from enrichment.metrics_clients import get_clients_on_channel # in case you want to consolidate later
def get_aps(capture, ap_channel):
try:
ap_channel = int(ap_channel)
except ValueError:
print(f"[!] Could not parse channel number: {ap_channel}")
return 0
aps = set()
for packet in capture:
try:
if 'radiotap' not in packet or 'wlan' not in packet:
continue
radio = packet.radiotap
wlan = packet.wlan
if not hasattr(radio, 'channel') or not hasattr(radio.channel, 'freq'):
continue
packet_freq = int(radio.channel.freq)
packet_channel = get_channel_from_freq(packet_freq)
if packet_channel != ap_channel:
continue
ts_hex = getattr(wlan, 'type_subtype', None)
if ts_hex is None:
continue
ts = int(ts_hex, 16)
if ts not in (5, 8): # Beacon/Probe
continue
bssid = getattr(wlan, 'bssid', '').lower()
if bssid and bssid != 'ff:ff:ff:ff:ff:ff':
aps.add(bssid)
except Exception:
continue
return aps
def get_aps_on_channel(capture, ap_channel):
return len(get_aps(capture, ap_channel))
def calculate_signal_strength_stats(capture, ap_channel):
try:
ap_channel = int(ap_channel)
except ValueError:
print(f"[!] Could not parse channel number: {ap_channel}")
return 0, 0
ap_signals = []
for packet in capture:
try:
if 'radiotap' not in packet or 'wlan' not in packet:
continue
radio = packet.radiotap
wlan = packet.wlan
if not hasattr(radio, 'channel') or not hasattr(radio.channel, 'freq'):
continue
packet_freq = int(radio.channel.freq)
packet_channel = get_channel_from_freq(packet_freq)
if packet_channel != ap_channel:
continue
ts_hex = getattr(wlan, 'type_subtype', None)
if ts_hex is None:
continue
ts = int(ts_hex, 16)
if ts not in (5, 8): # Beacon or Probe
continue
signal_strength = getattr(radio, 'dbm_antsignal', None)
if signal_strength is not None:
ap_signals.append(int(signal_strength))
except Exception:
continue
return (mean(ap_signals), max(ap_signals)) if ap_signals else (0, 0)
def get_unlinked_devices(capture, ap_channel):
aps = get_aps(capture, ap_channel)
ghost_clients = set()
for packet in capture:
try:
if 'radiotap' not in packet or 'wlan' not in packet:
continue
radio = packet.radiotap
wlan = packet.wlan
if not hasattr(radio, 'channel') or not hasattr(radio.channel, 'freq'):
continue
packet_freq = int(radio.channel.freq)
packet_channel = get_channel_from_freq(packet_freq)
if packet_channel != ap_channel:
continue
for mac in (getattr(wlan, 'sa', ''), getattr(wlan, 'da', '')):
mac = mac.lower()
if mac and mac != 'ff:ff:ff:ff:ff:ff' and mac not in aps:
ghost_clients.add(mac)
except Exception:
continue
return len(ghost_clients)

View file

@ -0,0 +1,97 @@
from statistics import mean
from collections import defaultdict
from enrichment.utils import get_channel_from_freq
def extract_ssid_metrics(packets):
bssid_to_ssid = {}
ssid_to_bssids = defaultdict(set)
ssid_hidden_status = {}
ssid_encryption_status = {}
cisco_ssid_clients = defaultdict(list)
cisco_reported_clients = []
ssid_signals = defaultdict(list)
ssid_packet_counts = defaultdict(int)
for packet in packets:
try:
if 'radiotap' not in packet or 'wlan' not in packet:
continue
radio = packet.radiotap
wlan = packet.wlan
if not hasattr(radio.channel, 'freq'):
continue
packet_freq = int(radio.channel.freq)
get_channel_from_freq(packet_freq) # validate channel, or skip
subtype = int(getattr(wlan, 'type_subtype', 0), 16)
if subtype not in (5, 8): # Beacon or Probe Response
continue
try:
mgt = packet.get_multiple_layers('wlan.mgt')[0]
tags = mgt._all_fields.get('wlan.tagged.all', {}).get('wlan.tag', [])
except Exception:
continue
ssid = None
hidden_ssid = False
privacy_bit = mgt._all_fields.get('wlan_mgt.fixed.capabilities.privacy')
is_open = (str(privacy_bit) != '1')
for tag in tags:
tag_number = tag.get('wlan.tag.number')
if tag_number == '0':
raw_ssid = tag.get('wlan.ssid', '')
if not raw_ssid:
hidden_ssid = True
ssid = '<hidden>'
else:
try:
ssid_bytes = bytes.fromhex(raw_ssid.replace(':', ''))
ssid = ssid_bytes.decode('utf-8', errors='replace')
except Exception:
ssid = None
if tag_number == '133':
try:
num_clients = int(tag.get('wlan.cisco.ccx1.clients'))
if ssid:
cisco_ssid_clients[ssid].append(num_clients)
cisco_reported_clients.append(num_clients)
except (TypeError, ValueError):
pass
if not ssid:
continue
ssid_hidden_status[ssid] = hidden_ssid
ssid_encryption_status.setdefault(ssid, is_open)
ssid_packet_counts[ssid] += 1
bssid = getattr(wlan, 'bssid', '').lower()
if not bssid or bssid == 'ff:ff:ff:ff:ff:ff':
continue
bssid_to_ssid[bssid] = ssid
ssid_to_bssids[ssid].add(bssid)
signal = getattr(radio, 'dbm_antsignal', None)
if signal:
ssid_signals[ssid].append(int(signal))
except Exception:
continue
return (
bssid_to_ssid,
ssid_to_bssids,
ssid_hidden_status,
ssid_encryption_status,
ssid_signals,
cisco_ssid_clients,
cisco_reported_clients,
ssid_packet_counts
)

16
enrichment/utils.py Normal file
View file

@ -0,0 +1,16 @@
from datetime import datetime
from .constants import FREQ_LOOKUP_TABLE, CHANNEL_LOOKUP_TABLE
def get_channel_from_freq(freq):
return FREQ_LOOKUP_TABLE.get(freq)
def get_freq_details(channel):
return CHANNEL_LOOKUP_TABLE.get(channel)
def convert_timestamp_to_epoch(ts_string):
try:
return int(datetime.fromisoformat(ts_string.replace("Z", "+00:00")).timestamp())
except Exception as e:
print(f"[!] Failed to parse timestamp: {ts_string}")
return None