From 55b0835dd788be91f4aff7f924bf643607a1d25a Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Thu, 24 Apr 2025 15:43:36 -0500 Subject: [PATCH] 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. --- enrich.py | 480 +++++------------------------ enrichment/__init__.py | 0 enrichment/constants.py | 50 +++ enrichment/csv_handler.py | 43 +++ enrichment/filters.py | 12 + enrichment/merge_ssid_summaries.py | 56 ++++ enrichment/metrics_clients.py | 66 ++++ enrichment/metrics_signals.py | 126 ++++++++ enrichment/metrics_ssid.py | 97 ++++++ enrichment/utils.py | 16 + runtest.sh | 1 + 11 files changed, 541 insertions(+), 406 deletions(-) create mode 100644 enrichment/__init__.py create mode 100644 enrichment/constants.py create mode 100644 enrichment/csv_handler.py create mode 100644 enrichment/filters.py create mode 100644 enrichment/merge_ssid_summaries.py create mode 100644 enrichment/metrics_clients.py create mode 100644 enrichment/metrics_signals.py create mode 100644 enrichment/metrics_ssid.py create mode 100644 enrichment/utils.py diff --git a/enrich.py b/enrich.py index e520dcb..63edf3c 100755 --- a/enrich.py +++ b/enrich.py @@ -1,118 +1,24 @@ #!/usr/bin/env python3 import argparse import csv -from datetime import datetime import pyshark from statistics import mean from collections import defaultdict - -# 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()} - -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 +from enrichment.utils import get_channel_from_freq, convert_timestamp_to_epoch +from enrichment.filters import filter_by_time +from enrichment.metrics_clients import get_clients_on_ap, get_clients_on_channel +from enrichment.metrics_signals import ( + get_aps_on_channel, + calculate_signal_strength_stats, + get_unlinked_devices +) +from enrichment.metrics_ssid import extract_ssid_metrics +from enrichment.csv_handler import ( + read_csv_input, + write_enriched_csv, + write_ssid_sidecar +) +from enrichment.merg_ssid_summaries import merge_ssid_summaries def parse_args(): parser = argparse.ArgumentParser() @@ -121,167 +27,6 @@ def parse_args(): parser.add_argument('--output', required=True, help='Output enriched CSV') 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): cap = pyshark.FileCapture( pcapng_path, @@ -309,94 +54,20 @@ def analyze_pcap(pcapng_path, start_ts, end_ts, ap_bssid, ap_channel): ssid_packet_counts = defaultdict(int) try: - # Filter packets manually by timestamp - 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 + filtered_packets = filter_by_time(cap, start_ts, end_ts) - for packet in filtered_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) - 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 = '' - 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 + ( + bssid_to_ssid, + ssid_to_bssids, + ssid_hidden_status, + ssid_encryption_status, + ssid_signals, + cisco_ssid_clients, + cisco_reported_clients, + ssid_packet_counts + ) = extract_ssid_metrics(filtered_packets) our_ssid = bssid_to_ssid.get(ap_bssid, None) - clients_on_ap = get_clients_on_ap(filtered_packets, 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) @@ -462,63 +133,60 @@ def main(): finally: cap.close() - with open(args.csv, newline='') as infile, open(args.output, 'w', newline='', encoding='utf-8') as outfile: - reader = csv.DictReader(infile) - fieldnames = reader.fieldnames + [ - 'ClientsOnAP', 'ClientsOnChannel', 'APsOnChannel', - 'AvgAPSignal', 'StrongestAPSignal', 'UnlinkedDevices', - 'CiscoAvgReportedClients', 'CiscoMaxReportedClients', 'NumberofBSSIDsOnSSID', - 'AvgSSIDSignal', 'MaxSSIDSignal', 'NumberofChannelsOnSSID', 'PacketCount' - ] - writer = csv.DictWriter(outfile, fieldnames=fieldnames) - writer.writeheader() + rows, original_fields = read_csv_input(args.csv) + fieldnames = original_fields + [ + 'ClientsOnAP', 'ClientsOnChannel', 'APsOnChannel', + 'AvgAPSignal', 'StrongestAPSignal', 'UnlinkedDevices', + 'CiscoAvgReportedClients', 'CiscoMaxReportedClients', 'NumberofBSSIDsOnSSID', + 'AvgSSIDSignal', 'MaxSSIDSignal', 'NumberofChannelsOnSSID', 'PacketCount' + ] - for row in reader: - tstart = convert_timestamp_to_epoch(row.get("StartTimestamp")) - tend = convert_timestamp_to_epoch(row.get("EndTimestamp")) - ap_bssid = row.get("BSSID", "").strip().lower() - ap_channel = row.get("Channel") + enriched_rows = [] + ssid_summary = None + all_ssid_summaries = [] - if not tstart or not tend: - writer.writerow(row) - continue + for row in rows: + tstart = convert_timestamp_to_epoch(row.get("StartTimestamp")) + 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, \ - avg_signal, strongest_signal, unlinked, \ - cisco_avg_reported_clients, cisco_max_reported_clients, num_bssids, \ - average_signal, max_ssid_signal, num_channels_ssid, \ - ssid_summary, packet_count = analyze_pcap(args.pcapng, tstart, tend, ap_bssid, ap_channel) + if not tstart or not tend: + enriched_rows.append(row) + continue - row.update({ - 'ClientsOnAP': clients_ap, - 'ClientsOnChannel': clients_chan, - 'APsOnChannel': aps_chan, - 'AvgAPSignal': avg_signal, - '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 - }) + result = analyze_pcap(args.pcapng, tstart, tend, ap_bssid, ap_channel) + ( + clients_ap, clients_chan, aps_chan, + avg_signal, strongest_signal, unlinked, + cisco_avg_reported_clients, cisco_max_reported_clients, num_bssids, + average_signal, max_ssid_signal, num_channels_ssid, + ssid_summary, packet_count + ) = result + row.update({ + 'ClientsOnAP': clients_ap, + 'ClientsOnChannel': clients_chan, + 'APsOnChannel': aps_chan, + 'AvgAPSignal': avg_signal, + '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 - if ssid_summary: - ssid_outfile = args.output.replace('.csv+rf.csv', '-ssid-metrics.csv') - with open(ssid_outfile, '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' - ] - ssid_writer = csv.DictWriter(f, fieldnames=fieldnames) - ssid_writer.writeheader() - for row in ssid_summary: - ssid_writer.writerow(row) + write_enriched_csv(args.output, fieldnames, enriched_rows) + + merged_ssid_summary = merge_ssid_summaries(all_ssid_summaries) + write_ssid_sidecar(args.output, merged_ssid_summary) print(f"[+] Enrichment complete: {args.output}") diff --git a/enrichment/__init__.py b/enrichment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/enrichment/constants.py b/enrichment/constants.py new file mode 100644 index 0000000..59f3e83 --- /dev/null +++ b/enrichment/constants.py @@ -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()} diff --git a/enrichment/csv_handler.py b/enrichment/csv_handler.py new file mode 100644 index 0000000..ca2a7d7 --- /dev/null +++ b/enrichment/csv_handler.py @@ -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}") + diff --git a/enrichment/filters.py b/enrichment/filters.py new file mode 100644 index 0000000..187962c --- /dev/null +++ b/enrichment/filters.py @@ -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 diff --git a/enrichment/merge_ssid_summaries.py b/enrichment/merge_ssid_summaries.py new file mode 100644 index 0000000..13b27e7 --- /dev/null +++ b/enrichment/merge_ssid_summaries.py @@ -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 diff --git a/enrichment/metrics_clients.py b/enrichment/metrics_clients.py new file mode 100644 index 0000000..56b0410 --- /dev/null +++ b/enrichment/metrics_clients.py @@ -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) \ No newline at end of file diff --git a/enrichment/metrics_signals.py b/enrichment/metrics_signals.py new file mode 100644 index 0000000..0a269aa --- /dev/null +++ b/enrichment/metrics_signals.py @@ -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) diff --git a/enrichment/metrics_ssid.py b/enrichment/metrics_ssid.py new file mode 100644 index 0000000..f02b722 --- /dev/null +++ b/enrichment/metrics_ssid.py @@ -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 = '' + 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 + ) diff --git a/enrichment/utils.py b/enrichment/utils.py new file mode 100644 index 0000000..86aba36 --- /dev/null +++ b/enrichment/utils.py @@ -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 + \ No newline at end of file diff --git a/runtest.sh b/runtest.sh index 9392fa3..e36a182 100755 --- a/runtest.sh +++ b/runtest.sh @@ -118,6 +118,7 @@ for ((COUNTER = 1; COUNTER <= NUM_TESTS; COUNTER++)); do log " Speed test took $SECONDS seconds" [[ -n "$speed_results" ]] && break 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" done