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

480
enrich.py
View file

@ -1,118 +1,24 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse import argparse
import csv import csv
from datetime import datetime
import pyshark import pyshark
from statistics import mean from statistics import mean
from collections import defaultdict from collections import defaultdict
from enrichment.utils import get_channel_from_freq, convert_timestamp_to_epoch
# United States regulatory domain channel lookup table from enrichment.filters import filter_by_time
from enrichment.metrics_clients import get_clients_on_ap, get_clients_on_channel
CHANNEL_LOOKUP_TABLE = { from enrichment.metrics_signals import (
# 2.4 GHz (non-DFS, always allowed) get_aps_on_channel,
1: {"freq": 2412, "dfs": False, "band": "2.4GHz"}, calculate_signal_strength_stats,
2: {"freq": 2417, "dfs": False, "band": "2.4GHz"}, get_unlinked_devices
3: {"freq": 2422, "dfs": False, "band": "2.4GHz"}, )
4: {"freq": 2427, "dfs": False, "band": "2.4GHz"}, from enrichment.metrics_ssid import extract_ssid_metrics
5: {"freq": 2432, "dfs": False, "band": "2.4GHz"}, from enrichment.csv_handler import (
6: {"freq": 2437, "dfs": False, "band": "2.4GHz"}, read_csv_input,
7: {"freq": 2442, "dfs": False, "band": "2.4GHz"}, write_enriched_csv,
8: {"freq": 2447, "dfs": False, "band": "2.4GHz"}, write_ssid_sidecar
9: {"freq": 2452, "dfs": False, "band": "2.4GHz"}, )
10: {"freq": 2457, "dfs": False, "band": "2.4GHz"}, from enrichment.merg_ssid_summaries import merge_ssid_summaries
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()}
def get_channel_from_freq(freq):
return FREQ_LOOKUP_TABLE.get(freq, None)
def get_freq_details(channel):
return CHANNEL_LOOKUP_TABLE.get(channel, None)
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()
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)
# For debugging purposes, print the channel and frequency
# print(f"Packet Channel: {packet_channel}, Frequency: {packet_freq} MHz")
if packet_channel != ap_channel:
continue
# Check for beacon or probe response
ts_hex = getattr(wlan, 'type_subtype', None)
if ts_hex is None:
continue
ts = int(ts_hex, 16)
if ts not in (5, 8): # Probe Response or Beacon
continue
# Grab BSSID
bssid = getattr(wlan, 'bssid', '').lower()
if bssid and bssid != 'ff:ff:ff:ff:ff:ff':
aps.add(bssid)
except Exception as e:
print(f"[DEBUG] Packet parse error: {e}")
continue
return aps
def parse_args(): def parse_args():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
@ -121,167 +27,6 @@ def parse_args():
parser.add_argument('--output', required=True, help='Output enriched CSV') parser.add_argument('--output', required=True, help='Output enriched CSV')
return parser.parse_args() return parser.parse_args()
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
def get_clients_on_ap(capture, ap_bssid):
clients = defaultdict(int)
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()
# Count any frame *to or from* a client, if AP is involved
if bssid == ap_bssid or sa == ap_bssid or da == ap_bssid:
# If it's the AP sending, add the destination (client)
if sa == ap_bssid and da and da != ap_bssid and not da.startswith("ff:ff:ff"):
clients[da] += 1
# If it's the client sending, add the source
elif sa and sa != ap_bssid and not sa.startswith("ff:ff:ff"):
clients[sa] += 1
except AttributeError:
continue
# Only count clients that show up more than 3 times — tweak as needed
stable_clients = [mac for mac, count in clients.items() if count > 3]
return len(stable_clients)
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)
# For debugging purposes, print the channel and frequency
# print(f"Packet Channel: {packet_channel}, Frequency: {packet_freq} MHz")
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)
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
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
# Check for beacon or probe response
ts_hex = getattr(wlan, 'type_subtype', None)
if ts_hex is None:
continue
ts = int(ts_hex, 16)
if ts not in (5, 8): # Probe Response or Beacon
continue
# Get signal strength
signal_strength = getattr(radio, 'dbm_antsignal', None)
if signal_strength is not None:
ap_signals.append(int(signal_strength))
except Exception as e:
print(f"[DEBUG] Signal strength parse error: {e}")
continue
if ap_signals:
return mean(ap_signals), max(ap_signals)
else:
return 0, 0
def get_unlinked_devices(capture, ap_channel):
aps = get_aps(capture, ap_channel)
ghost_clients = set()
for packet in capture:
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)
return len(ghost_clients)
def analyze_pcap(pcapng_path, start_ts, end_ts, ap_bssid, ap_channel): def analyze_pcap(pcapng_path, start_ts, end_ts, ap_bssid, ap_channel):
cap = pyshark.FileCapture( cap = pyshark.FileCapture(
pcapng_path, pcapng_path,
@ -309,94 +54,20 @@ def analyze_pcap(pcapng_path, start_ts, end_ts, ap_bssid, ap_channel):
ssid_packet_counts = defaultdict(int) ssid_packet_counts = defaultdict(int)
try: try:
# Filter packets manually by timestamp filtered_packets = filter_by_time(cap, start_ts, end_ts)
filtered_packets = []
for packet in cap:
try:
frame_time = float(packet.frame_info.time_epoch)
if start_ts <= frame_time <= end_ts:
filtered_packets.append(packet)
except Exception:
continue
for packet in filtered_packets: (
try: bssid_to_ssid,
if 'radiotap' not in packet or 'wlan' not in packet: ssid_to_bssids,
continue ssid_hidden_status,
ssid_encryption_status,
radio = packet.radiotap ssid_signals,
wlan = packet.wlan cisco_ssid_clients,
cisco_reported_clients,
if not hasattr(radio.channel, 'freq'): ssid_packet_counts
continue ) = extract_ssid_metrics(filtered_packets)
packet_freq = int(radio.channel.freq)
packet_channel = get_channel_from_freq(packet_freq)
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
# Determine encryption from .privacy field
privacy_bit = mgt._all_fields.get('wlan_mgt.fixed.capabilities.privacy')
is_open = (str(privacy_bit) != '1') # 1 = encrypted, 0 = open
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
our_ssid = bssid_to_ssid.get(ap_bssid, None) our_ssid = bssid_to_ssid.get(ap_bssid, None)
clients_on_ap = get_clients_on_ap(filtered_packets, ap_bssid) clients_on_ap = get_clients_on_ap(filtered_packets, ap_bssid)
clients_on_channel = get_clients_on_channel(filtered_packets, ap_channel, ap_bssid) clients_on_channel = get_clients_on_channel(filtered_packets, ap_channel, ap_bssid)
aps_on_channel = get_aps_on_channel(filtered_packets, ap_channel) aps_on_channel = get_aps_on_channel(filtered_packets, ap_channel)
@ -462,63 +133,60 @@ def main():
finally: finally:
cap.close() cap.close()
with open(args.csv, newline='') as infile, open(args.output, 'w', newline='', encoding='utf-8') as outfile: rows, original_fields = read_csv_input(args.csv)
reader = csv.DictReader(infile) fieldnames = original_fields + [
fieldnames = reader.fieldnames + [ 'ClientsOnAP', 'ClientsOnChannel', 'APsOnChannel',
'ClientsOnAP', 'ClientsOnChannel', 'APsOnChannel', 'AvgAPSignal', 'StrongestAPSignal', 'UnlinkedDevices',
'AvgAPSignal', 'StrongestAPSignal', 'UnlinkedDevices', 'CiscoAvgReportedClients', 'CiscoMaxReportedClients', 'NumberofBSSIDsOnSSID',
'CiscoAvgReportedClients', 'CiscoMaxReportedClients', 'NumberofBSSIDsOnSSID', 'AvgSSIDSignal', 'MaxSSIDSignal', 'NumberofChannelsOnSSID', 'PacketCount'
'AvgSSIDSignal', 'MaxSSIDSignal', 'NumberofChannelsOnSSID', 'PacketCount' ]
]
writer = csv.DictWriter(outfile, fieldnames=fieldnames)
writer.writeheader()
for row in reader: enriched_rows = []
tstart = convert_timestamp_to_epoch(row.get("StartTimestamp")) ssid_summary = None
tend = convert_timestamp_to_epoch(row.get("EndTimestamp")) all_ssid_summaries = []
ap_bssid = row.get("BSSID", "").strip().lower()
ap_channel = row.get("Channel")
if not tstart or not tend: for row in rows:
writer.writerow(row) tstart = convert_timestamp_to_epoch(row.get("StartTimestamp"))
continue tend = convert_timestamp_to_epoch(row.get("EndTimestamp"))
ap_bssid = row.get("BSSID", "").strip().lower()
ap_channel = row.get("Channel")
clients_ap, clients_chan, aps_chan, \ if not tstart or not tend:
avg_signal, strongest_signal, unlinked, \ enriched_rows.append(row)
cisco_avg_reported_clients, cisco_max_reported_clients, num_bssids, \ continue
average_signal, max_ssid_signal, num_channels_ssid, \
ssid_summary, packet_count = analyze_pcap(args.pcapng, tstart, tend, ap_bssid, ap_channel)
row.update({ result = analyze_pcap(args.pcapng, tstart, tend, ap_bssid, ap_channel)
'ClientsOnAP': clients_ap, (
'ClientsOnChannel': clients_chan, clients_ap, clients_chan, aps_chan,
'APsOnChannel': aps_chan, avg_signal, strongest_signal, unlinked,
'AvgAPSignal': avg_signal, cisco_avg_reported_clients, cisco_max_reported_clients, num_bssids,
'StrongestAPSignal': strongest_signal, average_signal, max_ssid_signal, num_channels_ssid,
'UnlinkedDevices': unlinked, ssid_summary, packet_count
'CiscoAvgReportedClients': cisco_avg_reported_clients, ) = result
'CiscoMaxReportedClients': cisco_max_reported_clients, row.update({
'NumberofBSSIDsOnSSID': num_bssids, 'ClientsOnAP': clients_ap,
'AvgSSIDSignal': average_signal, 'ClientsOnChannel': clients_chan,
'MaxSSIDSignal': max_ssid_signal, 'APsOnChannel': aps_chan,
'NumberofChannelsOnSSID': num_channels_ssid, 'AvgAPSignal': avg_signal,
'PacketCount': packet_count 'StrongestAPSignal': strongest_signal,
}) 'UnlinkedDevices': unlinked,
'CiscoAvgReportedClients': cisco_avg_reported_clients,
'CiscoMaxReportedClients': cisco_max_reported_clients,
'NumberofBSSIDsOnSSID': num_bssids,
'AvgSSIDSignal': average_signal,
'MaxSSIDSignal': max_ssid_signal,
'NumberofChannelsOnSSID': num_channels_ssid,
'PacketCount': packet_count
})
enriched_rows.append(row)
writer.writerow(row) ssid_summary = result[-2]
all_ssid_summaries.append(ssid_summary)
# Dump SSID metrics sidecar write_enriched_csv(args.output, fieldnames, enriched_rows)
if ssid_summary:
ssid_outfile = args.output.replace('.csv+rf.csv', '-ssid-metrics.csv') merged_ssid_summary = merge_ssid_summaries(all_ssid_summaries)
with open(ssid_outfile, 'w', newline='', encoding='utf-8') as f: write_ssid_sidecar(args.output, merged_ssid_summary)
fieldnames = [
'SSID', 'Hidden', 'Open', 'BSSID_Count', 'BSSIDs', 'Avg_Signal', 'Max_Signal',
'Min_Signal', 'Clients_Seen', 'CiscoAvgClients', 'CiscoMaxClients', 'PacketCount'
]
ssid_writer = csv.DictWriter(f, fieldnames=fieldnames)
ssid_writer.writeheader()
for row in ssid_summary:
ssid_writer.writerow(row)
print(f"[+] Enrichment complete: {args.output}") print(f"[+] Enrichment complete: {args.output}")

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

View file

@ -118,6 +118,7 @@ for ((COUNTER = 1; COUNTER <= NUM_TESTS; COUNTER++)); do
log " Speed test took $SECONDS seconds" log " Speed test took $SECONDS seconds"
[[ -n "$speed_results" ]] && break [[ -n "$speed_results" ]] && break
warn " Speedtest failed. Retrying in $RETRY_DELAY seconds..." warn " Speedtest failed. Retrying in $RETRY_DELAY seconds..."
echo "$(date -Iseconds),Speedtest failed on attempt $retry for test $COUNTER, sample $i" >> "$FAILURE_LOG"
sleep "$RETRY_DELAY" sleep "$RETRY_DELAY"
done done