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:
parent
4b9ad6f609
commit
55b0835dd7
11 changed files with 541 additions and 406 deletions
430
enrich.py
430
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 = '<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
|
||||
(
|
||||
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,33 +133,36 @@ 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 + [
|
||||
rows, original_fields = read_csv_input(args.csv)
|
||||
fieldnames = original_fields + [
|
||||
'ClientsOnAP', 'ClientsOnChannel', 'APsOnChannel',
|
||||
'AvgAPSignal', 'StrongestAPSignal', 'UnlinkedDevices',
|
||||
'CiscoAvgReportedClients', 'CiscoMaxReportedClients', 'NumberofBSSIDsOnSSID',
|
||||
'AvgSSIDSignal', 'MaxSSIDSignal', 'NumberofChannelsOnSSID', 'PacketCount'
|
||||
]
|
||||
writer = csv.DictWriter(outfile, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
|
||||
for row in reader:
|
||||
enriched_rows = []
|
||||
ssid_summary = None
|
||||
all_ssid_summaries = []
|
||||
|
||||
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")
|
||||
|
||||
if not tstart or not tend:
|
||||
writer.writerow(row)
|
||||
enriched_rows.append(row)
|
||||
continue
|
||||
|
||||
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)
|
||||
|
||||
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,
|
||||
|
@ -504,21 +178,15 @@ def main():
|
|||
'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}")
|
||||
|
||||
|
|
0
enrichment/__init__.py
Normal file
0
enrichment/__init__.py
Normal file
50
enrichment/constants.py
Normal file
50
enrichment/constants.py
Normal 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
43
enrichment/csv_handler.py
Normal 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
12
enrichment/filters.py
Normal 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
|
56
enrichment/merge_ssid_summaries.py
Normal file
56
enrichment/merge_ssid_summaries.py
Normal 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
|
66
enrichment/metrics_clients.py
Normal file
66
enrichment/metrics_clients.py
Normal 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)
|
126
enrichment/metrics_signals.py
Normal file
126
enrichment/metrics_signals.py
Normal 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)
|
97
enrichment/metrics_ssid.py
Normal file
97
enrichment/metrics_ssid.py
Normal 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
16
enrichment/utils.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue