Repository navigation
Expand file tree
/
Copy pathassistant_engine.py
More file actions
1306 lines (1144 loc) · 62 KB
/
Copy pathassistant_engine.py
File metadata and controls
1306 lines (1144 loc) · 62 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
WARDRIVE Intelligence Assistant (WIA) — offline analysis interpretation engine.
Architecture
────────────
WIAEngine
└─ BaseAssistantProvider (ABC)
├─ RuleBasedProvider (default, fully offline, no deps)
└─ LocalLLMProvider (stub — hook for future llama.cpp / ollama)
The engine:
1. Reads wardrive_master.csv produced by core/analyze.py.
2. Builds typed AccessPointEvidence objects.
3. Passes them through the active provider.
4. Returns a list of AssistantCard objects ready for UI rendering.
No GUI imports. No network calls in default mode.
Thread-safe: each call is stateless. All mutable state lives in parameters.
Persona
───────
Seasoned cyberpunk operator straight out of a 90s hacker movie.
Duke Nukem energy meets Neo's operator. Technically precise, occasionally
sarcastic, never misleading, and absolutely never offensive or exploitative.
"""
from __future__ import annotations
import csv
import gc
import io
import math
import random
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Dict, List, Optional, Tuple
# ─────────────────────────────────────────────────────────────────────────────
# Enums
# ─────────────────────────────────────────────────────────────────────────────
class ConfidenceTier(str, Enum):
HIGH = "HIGH CONFIDENCE"
MODERATE = "MODERATE CONFIDENCE"
LOW = "LOW CONFIDENCE"
SPECULATIVE = "SPECULATIVE"
class CardSeverity(str, Enum):
"""Visual severity for UI rendering."""
BOOT = "BOOT" # system message, neutral cyan
INFO = "INFO" # informational, grey-blue
NOTE = "NOTE" # observation, teal
INSIGHT = "INSIGHT" # positive / interesting, green
WARN = "WARN" # worth attention, amber
ANOMALY = "ANOMALY" # something unusual detected, orange
SCORE = "SCORE" # quality score card
class WIAEvent(str, Enum):
"""External events the engine can react to."""
LOGS_ADDED = "logs_added"
PCAPS_ADDED = "pcaps_added"
ANALYSIS_START = "analysis_start"
ANALYSIS_DONE = "analysis_done"
PROJECT_SELECTED = "project_selected"
INGEST_DONE = "ingest_done"
# ─────────────────────────────────────────────────────────────────────────────
# Data objects
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class AccessPointEvidence:
mac: str
ssid: str
auth: str
channel: str
best_rssi: Optional[int]
avg_rssi: Optional[float]
sightings: int
used_for_centroid: int
confidence_radius_m: Optional[float]
stability: int
location_quality: str
risk_score: int
seen_in_pcap: bool
handshake_seen: bool
eapol_frames: int
active_days: int
multi_file: bool
multi_day: bool
vendor: str
first_seen: str
last_seen: str
@dataclass
class AssistantCard:
"""
One atomic observation produced by the assistant.
All sections are plain text — HTML rendering happens in the UI layer.
"""
title: str
fact: str
severity: CardSeverity = CardSeverity.INFO
confidence: ConfidenceTier = ConfidenceTier.MODERATE
interpretation: str = ""
educational_note: str = ""
mascot_flavor: str = ""
recommendation: str = ""
timestamp: str = field(default_factory=lambda: datetime.now().strftime("%H:%M:%S"))
@dataclass
class CaptureQuality:
score: int # 0–100
grade: str # A / B / C / D / F
summary: str
breakdown: List[Tuple[str, int, int]] # [(label, earned, max), ...]
# ─────────────────────────────────────────────────────────────────────────────
# Educational content library
# ─────────────────────────────────────────────────────────────────────────────
_EDUCATION: Dict[str, str] = {
"rssi": (
"RSSI (Received Signal Strength Indicator) is measured in dBm — a negative number. "
"Closer to zero = stronger signal. -30 dBm is excellent; -90 dBm is barely detectable. "
"Stronger signals during capture improve centroid accuracy because the device was physically nearby."
),
"centroid": (
"A centroid is the calculated center-point of all GPS positions where this device was observed. "
"More observations, especially from different physical angles, shrink the confidence radius and "
"increase certainty about where the device actually is."
),
"confidence_radius": (
"The confidence radius (in metres) is the root-mean-squared distance between each GPS sighting "
"and the centroid. Smaller = more tightly clustered observations = more trustworthy location. "
"Sparse data, GPS drift, and signal multipath all increase this radius."
),
"randomized_mac": (
"Modern devices (phones, laptops) randomize their MAC address when probing for networks to prevent "
"passive tracking. A locally administered MAC has bit 1 of the first byte set. These entries "
"appear in captures but cannot be reliably attributed to a single physical device across sessions."
),
"bssid": (
"A BSSID (Basic Service Set Identifier) is a 48-bit MAC address that uniquely identifies one "
"radio interface of an access point. An AP with multiple radios (2.4 GHz + 5 GHz) has multiple BSSIDs. "
"The first three bytes identify the hardware vendor (OUI)."
),
"ssid": (
"An SSID (Service Set Identifier) is the human-readable network name broadcast in beacon frames. "
"A hidden SSID means the AP transmits blank or zero-length SSID in beacons, but it can still be "
"captured from probe responses or association frames."
),
"eapol": (
"EAPOL (Extensible Authentication Protocol over LAN) frames are exchanged during the WPA/WPA2 "
"4-way handshake — the authentication ceremony between a client and an AP. Capturing a complete "
"handshake means offline passphrase testing becomes theoretically possible. "
"WPA3-SAE uses a different (non-EAPOL) handshake that resists this technique."
),
"overlap": (
"Overlap is the percentage of BSSIDs seen in PCAP captures that also appear in GPS wardrive logs. "
"High overlap means radio evidence and location evidence are telling the same story. "
"Low overlap usually means you drove a different route than you sniffed, or one data source is sparse."
),
"channel": (
"802.11 operates on numbered radio channels. 2.4 GHz channels 1, 6, and 11 don't overlap "
"(in the US). 5 GHz has many more non-overlapping channels. Channels 36–165 are 5 GHz. "
"High channel diversity in a capture means the radio was actively scanning, not parked on one band."
),
"stability_score": (
"The stability score (0–100) combines centroid radius tightness with observation count. "
"A score of 80+ means the estimated location is likely within 25–30 metres. "
"Scores below 40 should be treated as approximate — useful for area-level attribution only."
),
"wep": (
"WEP (Wired Equivalent Privacy) is a cryptographic protocol from 1999. It was formally deprecated "
"by the IEEE in 2004 and is trivially breakable with modern tools. Any network still using WEP "
"has a serious configuration problem. No modern device should be using it."
),
"probe": (
"Probe requests are sent by client devices when actively searching for known networks. "
"They may reveal SSIDs the client has connected to before. Modern OS implementations often "
"send wildcard probes (no SSID) and randomize their MAC to limit exposure."
),
"gps_drift": (
"GPS accuracy degrades near buildings (urban canyon), under dense tree cover, and at low "
"satellite fix counts. Reported accuracy in meters (AccuracyMeters column) reflects the "
"receiver's self-estimated uncertainty. WIA uses this to weight each sighting — "
"high-accuracy fixes contribute more to the centroid than low-accuracy ones."
),
}
# ─────────────────────────────────────────────────────────────────────────────
# Persona flavor pools
# ─────────────────────────────────────────────────────────────────────────────
_FLAVOR_DONE: List[str] = [
"Boom. Data's talking. Listen close.",
"The air doesn't lie, operator. The packets never do.",
"Clean run. Your evidence vault just got heavier.",
"Another dataset, another chapter. This one's got style.",
"Not bad. Not bad at all. Now don't get cocky.",
"Evidence stacked. Story told. Welcome to the future.",
"I've seen worse datasets. I've also seen better. This one's... respectable.",
"That's a wrap. Go read your reports like the professional you almost are.",
]
_FLAVOR_HIGH_OVERLAP: List[str] = [
"GPS and radio evidence shaking hands. I love it when a plan comes together.",
"High overlap. Your wardrive and your sniffer were best friends today.",
"PCAP confirms GPS confirms PCAP. Circular validation, beautiful.",
]
_FLAVOR_LOW_OVERLAP: List[str] = [
"GPS says one thing, packets say another. You drove a different route than you sniffed. Classic.",
"Low overlap. Either your paths diverged or one data source is half-asleep.",
"That gap between log MACs and PCAP BSSIDs? That's opportunity. Or it's just Tuesday.",
]
_FLAVOR_RANDOMIZED: List[str] = [
"Randomized MACs. The device thinks it's invisible. It's not, but points for trying.",
"Locally administered addresses spotted. Privacy-conscious hardware — or just a modern phone.",
"These MACs aren't real permanent identifiers. Treat them as ghosts.",
]
_FLAVOR_SPARSE: List[str] = [
"Only one sighting? The universe gave you one frame. Use it wisely.",
"Sparse data is honest data. Don't extrapolate what you don't know.",
"Single-sighting APs are breadcrumbs, not a meal.",
]
_FLAVOR_WEP: List[str] = [
"WEP in 2026. That's like a deadbolt made of cheese. Educational observation only.",
"WEP spotted. Someone didn't get the memo. Several memos. All the memos.",
"WEP: encryption theatre. Worth noting for documentation purposes.",
]
_FLAVOR_EAPOL: List[str] = [
"EAPOL frames? The handshake is in the evidence. Note it, don't exploit it.",
"4-way handshake captured. This is evidence of authentication activity — document accordingly.",
"Handshake material in the vault. This has forensic value.",
]
_FLAVOR_QUALITY_HIGH: List[str] = [
"This capture is chef's kiss. You actually know how to collect evidence.",
"Top-tier dataset. Whoever drove this route knew what they were doing.",
"High quality. Your future self will thank you. Your present self: well done.",
]
_FLAVOR_QUALITY_MED: List[str] = [
"Solid capture. A few more passes and this becomes excellent.",
"Good data. Not perfect. Perfect doesn't exist. This is close enough.",
"Respectable. I've worked with worse. Actually I prefer this.",
]
_FLAVOR_QUALITY_LOW: List[str] = [
"This data has potential. It just needs more... everything.",
"Sparse capture. Do another pass — the evidence will thank you.",
"Low quality score doesn't mean bad data, it means thin data. Rescan.",
]
def _pick(pool: List[str], rng: Optional[random.Random] = None) -> str:
r = rng or random
return r.choice(pool) if pool else ""
# ─────────────────────────────────────────────────────────────────────────────
# CSV reader → AccessPointEvidence list
# ─────────────────────────────────────────────────────────────────────────────
_NO_DATA_VALS = {"", "N/A", "—", "n/a", "none", "null"}
def _safe_int(v: str, default: Optional[int] = None) -> Optional[int]:
try:
return int(float(v))
except (ValueError, TypeError):
return default
def _safe_float(v: str, default: Optional[float] = None) -> Optional[float]:
try:
f = float(v)
return f if math.isfinite(f) else default
except (ValueError, TypeError):
return default
def _safe_bool(v: str) -> bool:
return str(v).strip().lower() in ("yes", "true", "1")
def _is_randomized_mac(mac: str) -> bool:
"""Detect locally administered MAC (bit 1 of first byte set)."""
try:
first_byte = int(mac.replace(":", "").replace("-", "")[0:2], 16)
return bool(first_byte & 0x02)
except (ValueError, IndexError):
return False
def load_evidence_from_csv(csv_path: str) -> List[AccessPointEvidence]:
"""Read wardrive_master.csv and return a list of AccessPointEvidence objects."""
results: List[AccessPointEvidence] = []
try:
# Read entire file to string first — closes the file descriptor
# before any CSV iteration begins. Python 3.14's incremental GC
# fires inside csv.__next__ when the reader is live in a background
# thread and causes a fatal access violation. Parsing from an
# in-memory StringIO with GC disabled for the duration eliminates
# both hazards.
with open(csv_path, encoding="utf-8", errors="replace") as f:
raw_text = f.read()
_gc_was_on = gc.isenabled()
gc.disable()
try:
raw_rows = list(csv.DictReader(io.StringIO(raw_text)))
finally:
if _gc_was_on:
gc.enable()
for row in raw_rows:
mac = row.get("MAC", "").strip()
if not mac:
continue
ssid = row.get("TopSSID", "").strip()
auth = row.get("AuthMode", "").strip()
if auth in _NO_DATA_VALS:
auth = ""
conf_raw = row.get("ConfidenceRadiusM", "")
conf_m = None if conf_raw in _NO_DATA_VALS else _safe_float(conf_raw)
best_rssi = _safe_int(row.get("BestRSSI", ""))
avg_rssi = _safe_float(row.get("AvgRSSI", ""))
results.append(AccessPointEvidence(
mac=mac,
ssid=ssid if ssid not in _NO_DATA_VALS else "",
auth=auth,
channel=row.get("Channel", "").strip(),
best_rssi=best_rssi,
avg_rssi=avg_rssi,
sightings=_safe_int(row.get("Sightings", ""), 0) or 0,
used_for_centroid=_safe_int(row.get("UsedForCentroid", ""), 0) or 0,
confidence_radius_m=conf_m,
stability=_safe_int(row.get("Stability", ""), 0) or 0,
location_quality=row.get("LocationQuality", "").strip(),
risk_score=_safe_int(row.get("RiskScore", ""), 0) or 0,
seen_in_pcap=_safe_bool(row.get("SeenInPCAP", "")),
handshake_seen=_safe_bool(row.get("HandshakeSeen", "")),
eapol_frames=_safe_int(row.get("EAPOLFrames", ""), 0) or 0,
active_days=_safe_int(row.get("ActiveDays", ""), 0) or 0,
multi_file=_safe_bool(row.get("RepeatedAcrossFiles", "")),
multi_day=_safe_bool(row.get("MultiDaySeen", "")),
vendor=row.get("Vendor", row.get("OUI", "")).strip(),
first_seen=row.get("FirstSeen", "").strip(),
last_seen=row.get("LastSeen", "").strip(),
))
except FileNotFoundError:
pass
except Exception:
pass
return results
# ─────────────────────────────────────────────────────────────────────────────
# Provider interface
# ─────────────────────────────────────────────────────────────────────────────
class BaseAssistantProvider(ABC):
"""
Contract for all assistant providers.
Implementations must be stateless across calls — each call to interpret()
receives the full evidence list and stats and returns fresh cards.
"""
@abstractmethod
def interpret(
self,
evidence: List[AccessPointEvidence],
stats: Dict,
) -> List[AssistantCard]:
"""Generate cards from evidence + run-level stats."""
...
@abstractmethod
def on_event(
self,
event: WIAEvent,
context: Dict,
) -> Optional[AssistantCard]:
"""React to a workflow event. Returns one card or None."""
...
@abstractmethod
def quality_score(
self,
evidence: List[AccessPointEvidence],
stats: Dict,
) -> CaptureQuality:
...
# ─────────────────────────────────────────────────────────────────────────────
# Rule-based provider (fully offline, zero dependencies)
# ─────────────────────────────────────────────────────────────────────────────
class RuleBasedProvider(BaseAssistantProvider):
"""
Deterministic rule engine.
Each _analyze_* method targets one observation category and returns 0-N cards.
Rules are explicit, auditable, and never claim more certainty than the data supports.
"""
# Thresholds (tunable)
OVERLAP_HIGH_PCT = 60.0
OVERLAP_LOW_PCT = 20.0
SPARSE_SIGHTINGS = 3
MOBILE_RADIUS_M = 500.0
LARGE_RADIUS_M = 200.0
STRONG_RSSI = -55
DENSE_CLUSTER_N = 20
CHANNEL_CONGESTION_THRESHOLD = 0.5 # fraction of APs on same channel = congested
RANDOMIZED_FRACTION_WARN = 0.15 # 15%+ randomized MACs = notable
def __init__(self, seed: Optional[int] = None) -> None:
self._rng = random.Random(seed)
# ── Public interface ──────────────────────────────────────────────────────
def interpret(
self,
evidence: List[AccessPointEvidence],
stats: Dict,
) -> List[AssistantCard]:
if not evidence:
return [AssistantCard(
title="No Evidence Loaded",
fact="The CSV contained no parseable AP records.",
severity=CardSeverity.WARN,
confidence=ConfidenceTier.HIGH,
interpretation="Either the analysis produced no results, or the CSV format was unexpected.",
educational_note=_EDUCATION["centroid"],
mascot_flavor="Nothing here. That's either a very clean environment or a very short drive.",
)]
cards: List[AssistantCard] = []
cards.extend(self._analyze_overlap(evidence, stats))
cards.extend(self._analyze_confidence(evidence))
cards.extend(self._analyze_mobility(evidence))
cards.extend(self._analyze_randomized_macs(evidence))
cards.extend(self._analyze_sparse_data(evidence))
cards.extend(self._analyze_signal_profile(evidence))
cards.extend(self._analyze_channel_congestion(evidence))
cards.extend(self._analyze_vendor_density(evidence))
cards.extend(self._analyze_suspicious_names(evidence))
cards.extend(self._analyze_eapol(evidence))
cards.extend(self._analyze_wep(evidence))
cards.extend(self._analyze_pcap_only(evidence))
return cards
def on_event(self, event: WIAEvent, context: Dict) -> Optional[AssistantCard]:
if event == WIAEvent.LOGS_ADDED:
n = context.get("count", 0)
return AssistantCard(
title="Wardrive Logs Loaded",
fact=f"{n} log file(s) queued for analysis.",
severity=CardSeverity.INFO,
confidence=ConfidenceTier.HIGH,
interpretation="GPS position data is now available. Add PCAPs for radio evidence correlation.",
educational_note=_EDUCATION["rssi"],
mascot_flavor="GPS evidence locked. I can feel the coordinates from here.",
)
if event == WIAEvent.PCAPS_ADDED:
n = context.get("count", 0)
return AssistantCard(
title="PCAP Files Loaded",
fact=f"{n} PCAP capture file(s) queued.",
severity=CardSeverity.INFO,
confidence=ConfidenceTier.HIGH,
interpretation="Radio frame evidence is available. Combine with wardrive logs for full correlation.",
educational_note=_EDUCATION["bssid"],
mascot_flavor="Packets in the vault. The air was talking. We recorded it.",
)
if event == WIAEvent.ANALYSIS_START:
logs = context.get("logs", 0)
pcaps = context.get("pcaps", 0)
return AssistantCard(
title="Analysis Initializing",
fact=f"Engine starting with {logs} log(s) and {pcaps} PCAP(s).",
severity=CardSeverity.BOOT,
confidence=ConfidenceTier.HIGH,
interpretation="Centroid computation, PCAP correlation, and report generation are queued.",
mascot_flavor="Boot sequence live. Next stop: evidence.",
)
if event == WIAEvent.INGEST_DONE:
imported = context.get("imported", 0)
dupes = context.get("duplicates", 0)
return AssistantCard(
title="Evidence Ingested",
fact=f"{imported} file(s) attached to project vault. {dupes} duplicate(s) skipped.",
severity=CardSeverity.INFO,
confidence=ConfidenceTier.HIGH,
interpretation="Evidence is now attached. Run analysis to generate reports.",
mascot_flavor="Vault updated. The evidence doesn't lie — it just waits.",
)
if event == WIAEvent.PROJECT_SELECTED:
return AssistantCard(
title="Project Vault Loaded",
fact=f"Project: {context.get('path', 'unknown')}",
severity=CardSeverity.BOOT,
confidence=ConfidenceTier.HIGH,
interpretation="All prior evidence and runs for this project are accessible.",
mascot_flavor="Project locked. I know where we keep the bodies. The digital ones.",
)
return None
def quality_score(
self,
evidence: List[AccessPointEvidence],
stats: Dict,
) -> CaptureQuality:
breakdown: List[Tuple[str, int, int]] = []
# 1. Overlap (40 pts): how much GPS + PCAP evidence agrees
overlap_pct = float(stats.get("overlap_pct", 0) or 0)
overlap_earned = min(40, int(overlap_pct * 0.4))
breakdown.append(("GPS ↔ PCAP overlap", overlap_earned, 40))
# 2. GPS fix quality (25 pts)
total = len(evidence)
high_q = sum(1 for e in evidence if e.location_quality == "High")
med_q = sum(1 for e in evidence if e.location_quality == "Medium")
gps_earned = 0
if total > 0:
gps_frac = (high_q + med_q * 0.5) / total
gps_earned = min(25, int(gps_frac * 25))
breakdown.append(("High/Med GPS quality", gps_earned, 25))
# 3. Sample density (20 pts): avg sightings per AP
avg_sightings = float(stats.get("avg_sightings", 0) or 0)
density_earned = min(20, int(min(avg_sightings / 5.0, 1.0) * 20))
breakdown.append(("Avg sightings per AP", density_earned, 20))
# 4. Channel diversity (15 pts)
channels = [e.channel for e in evidence if e.channel and e.channel not in _NO_DATA_VALS]
unique_ch = len(set(channels))
ch_earned = min(15, unique_ch * 2)
breakdown.append(("Channel diversity", ch_earned, 15))
score = sum(e for _, e, _ in breakdown)
score = max(0, min(100, score))
if score >= 80:
grade = "A"
summary = "Excellent capture. Strong overlap, good GPS fix quality, and solid sample density."
flavor = _pick(_FLAVOR_QUALITY_HIGH, self._rng)
elif score >= 60:
grade = "B"
summary = "Good capture. A few more passes or better GPS conditions would push this to excellent."
flavor = _pick(_FLAVOR_QUALITY_MED, self._rng)
elif score >= 40:
grade = "C"
summary = "Fair capture. Evidence is present but thin. Consider rescanning with both logs and PCAPs active."
flavor = _pick(_FLAVOR_QUALITY_LOW, self._rng)
elif score >= 20:
grade = "D"
summary = "Sparse capture. Centroids will be uncertain. More evidence from multiple directions is needed."
flavor = _pick(_FLAVOR_QUALITY_LOW, self._rng)
else:
grade = "F"
summary = "Minimal evidence. This dataset cannot support confident location claims."
flavor = "This is the start of a dataset, not the end."
breakdown.append(("FLAVOR", 0, 0)) # placeholder row for rendering
_ = flavor # consumed below
return CaptureQuality(
score=score,
grade=grade,
summary=f"{summary} {flavor}",
breakdown=breakdown[:-1], # remove placeholder
)
# ── Analysis methods ─────────────────────────────────────────────────────
def _analyze_overlap(
self, evidence: List[AccessPointEvidence], stats: Dict
) -> List[AssistantCard]:
overlap_pct = float(stats.get("overlap_pct", 0) or 0)
pcap_unique = int(stats.get("pcap_unique", 0) or 0)
log_unique = int(stats.get("log_unique", 0) or 0)
overlap_n = int(stats.get("overlap_n", 0) or 0)
if pcap_unique == 0 and log_unique == 0:
return []
if pcap_unique == 0:
return [AssistantCard(
title="No PCAP Evidence",
fact=f"GPS logs found {log_unique} unique MACs. No PCAP captures were analysed.",
severity=CardSeverity.WARN,
confidence=ConfidenceTier.HIGH,
interpretation="Location data is available but radio frame evidence is absent. "
"PCAP correlation is not possible without capture files.",
educational_note=_EDUCATION["overlap"],
recommendation="Capture PCAPs during your next wardrive and attach them to this project.",
mascot_flavor="GPS without packets is half the story. Come back with a radio.",
)]
if log_unique == 0:
return [AssistantCard(
title="No GPS Log Evidence",
fact=f"PCAP captures found {pcap_unique} unique BSSIDs. No wardrive GPS logs were analysed.",
severity=CardSeverity.WARN,
confidence=ConfidenceTier.HIGH,
interpretation="Radio frame evidence is present but cannot be placed on a map. "
"Centroid computation requires GPS sightings.",
educational_note=_EDUCATION["centroid"],
recommendation="Attach wardrive GPS logs (WiGLE-format CSV) to enable location correlation.",
mascot_flavor="Radio without GPS is noise without a map. One out of two. Not great.",
)]
cards: List[AssistantCard] = []
if overlap_pct >= self.OVERLAP_HIGH_PCT:
cards.append(AssistantCard(
title="Strong Evidence Correlation",
fact=f"{overlap_n} of {pcap_unique} PCAP BSSIDs ({overlap_pct:.0f}%) matched GPS wardrive logs.",
severity=CardSeverity.INSIGHT,
confidence=ConfidenceTier.HIGH,
interpretation=(
"High overlap indicates your capture path closely matched your wardrive route. "
"Location claims for these APs are supported by both GPS and radio evidence — "
"this is the strongest possible evidence combination."
),
educational_note=_EDUCATION["overlap"],
mascot_flavor=_pick(_FLAVOR_HIGH_OVERLAP, self._rng),
))
elif overlap_pct < self.OVERLAP_LOW_PCT and pcap_unique > 5:
gap = log_unique - overlap_n
cards.append(AssistantCard(
title="Low GPS / PCAP Overlap",
fact=(
f"Only {overlap_n} of {pcap_unique} PCAP BSSIDs ({overlap_pct:.0f}%) "
f"matched GPS logs. {gap} GPS MACs have no PCAP counterpart."
),
severity=CardSeverity.WARN,
confidence=ConfidenceTier.MODERATE,
interpretation=(
"Low overlap typically means the capture path diverged from the wardrive route, "
"or one data source covers a different time period. "
"PCAP-only BSSIDs have no confirmed location. GPS-only MACs have no radio fingerprint."
),
educational_note=_EDUCATION["overlap"],
recommendation=(
"Synchronise your wardrive log capture with your PCAP capture — "
"both should run simultaneously on the same physical route."
),
mascot_flavor=_pick(_FLAVOR_LOW_OVERLAP, self._rng),
))
else:
# Moderate overlap — just an informational note
cards.append(AssistantCard(
title="Evidence Correlation",
fact=f"{overlap_n} of {pcap_unique} PCAP BSSIDs ({overlap_pct:.0f}%) confirmed in GPS logs.",
severity=CardSeverity.NOTE,
confidence=ConfidenceTier.MODERATE,
interpretation=(
"Partial overlap. Location evidence supports a subset of radio observations. "
"APs seen in both sources have stronger location confidence."
),
educational_note=_EDUCATION["overlap"],
mascot_flavor="Partial match. The truth is in the middle, as usual.",
))
return cards
def _analyze_confidence(self, evidence: List[AccessPointEvidence]) -> List[AssistantCard]:
has_loc = [e for e in evidence if e.confidence_radius_m is not None]
if not has_loc:
return []
high_conf = [e for e in has_loc if e.confidence_radius_m is not None and e.confidence_radius_m <= 50 and e.sightings >= 4]
uncertain = [e for e in has_loc if e.confidence_radius_m is not None and e.confidence_radius_m > self.LARGE_RADIUS_M]
cards: List[AssistantCard] = []
if high_conf:
top = sorted(high_conf, key=lambda e: e.confidence_radius_m or 999)[:3]
examples = ", ".join(
f"{e.ssid or e.mac} ({e.confidence_radius_m:.0f} m)" for e in top
)
cards.append(AssistantCard(
title=f"High-Confidence Locations ({len(high_conf)} APs)",
fact=(
f"{len(high_conf)} access point(s) have a confidence radius ≤ 50 m "
f"with ≥ 4 GPS fixes. Examples: {examples}."
),
severity=CardSeverity.INSIGHT,
confidence=ConfidenceTier.HIGH,
interpretation=(
"These APs have tightly clustered GPS sightings. "
"The centroid estimate is likely within 25–50 metres of the actual device. "
"This is suitable for sector-level or building-level attribution."
),
educational_note=_EDUCATION["confidence_radius"],
mascot_flavor="Tight radius. You collected clean data. I'm almost impressed.",
))
if uncertain:
worst = sorted(uncertain, key=lambda e: -(e.confidence_radius_m or 0))[:3]
examples = ", ".join(
f"{e.ssid or e.mac} ({e.confidence_radius_m:.0f} m)" for e in worst
)
cards.append(AssistantCard(
title=f"Uncertain Location Estimates ({len(uncertain)} APs)",
fact=(
f"{len(uncertain)} AP(s) have confidence radius > {self.LARGE_RADIUS_M:.0f} m. "
f"Highest uncertainty: {examples}."
),
severity=CardSeverity.WARN,
confidence=ConfidenceTier.MODERATE,
interpretation=(
"Large confidence radius indicates sparse sightings, GPS drift, "
"or signal multipath. Location estimates for these APs should be "
"treated as area-level rather than point-level."
),
educational_note=_EDUCATION["gps_drift"],
recommendation=(
"Rescan the area from different physical positions to triangulate a tighter centroid."
),
mascot_flavor="Wide radius means wide uncertainty. The data is honest about what it doesn't know.",
))
return cards
def _analyze_mobility(self, evidence: List[AccessPointEvidence]) -> List[AssistantCard]:
"""Detect APs with extremely high confidence radius — possible mobile transmitters."""
mobile_candidates = [
e for e in evidence
if e.confidence_radius_m is not None
and e.confidence_radius_m > self.MOBILE_RADIUS_M
and e.sightings >= 3
]
if not mobile_candidates:
return []
top = sorted(mobile_candidates, key=lambda e: -(e.confidence_radius_m or 0))[:5]
lines = [f" {e.ssid or e.mac}: {e.confidence_radius_m:.0f} m radius, {e.sightings} sightings" for e in top]
return [AssistantCard(
title=f"Possible Mobile Transmitters ({len(mobile_candidates)} AP(s))",
fact=(
f"{len(mobile_candidates)} AP(s) show confidence radii exceeding {self.MOBILE_RADIUS_M:.0f} m "
f"across ≥ 3 sightings:\n" + "\n".join(lines)
),
severity=CardSeverity.ANOMALY,
confidence=ConfidenceTier.SPECULATIVE,
interpretation=(
"SPECULATIVE: Extremely high centroid spread with multiple sightings may indicate "
"a mobile transmitter (vehicle hotspot, phone tethering, mobile AP). "
"It could also be GPS drift or a high-power AP visible over a very large area. "
"This is a hypothesis, not a conclusion."
),
educational_note=(
"A stationary AP observed from different directions produces a tight centroid. "
"A moving transmitter or one visible over a kilometre-wide area produces a spread centroid. "
"Distinguish between these by examining the sighting timestamps and your own movement path."
),
mascot_flavor=_pick(_FLAVOR_SPARSE, self._rng),
recommendation="Cross-reference sighting timestamps with your GPS track to determine if spread correlates with your movement.",
)]
def _analyze_randomized_macs(self, evidence: List[AccessPointEvidence]) -> List[AssistantCard]:
randomized = [e for e in evidence if _is_randomized_mac(e.mac)]
total = len(evidence)
if not randomized:
return []
frac = len(randomized) / total if total else 0
severity = CardSeverity.NOTE if frac < self.RANDOMIZED_FRACTION_WARN else CardSeverity.WARN
return [AssistantCard(
title=f"Randomized MAC Addresses ({len(randomized)} of {total})",
fact=(
f"{len(randomized)} entries ({frac:.0%}) appear to use locally administered MAC addresses, "
f"consistent with MAC randomization."
),
severity=severity,
confidence=ConfidenceTier.MODERATE,
interpretation=(
"Locally administered MACs cannot be reliably attributed to a single device across capture "
"sessions. Each randomized address should be treated as a unique, transient identifier. "
"These are likely client devices (phones, laptops) in probe request or soft-AP mode, "
"not infrastructure access points."
),
educational_note=_EDUCATION["randomized_mac"],
mascot_flavor=_pick(_FLAVOR_RANDOMIZED, self._rng),
recommendation=(
"Do not attempt cross-session correlation on locally administered MACs. "
"Treat them as probe evidence, not persistent device identity."
),
)]
def _analyze_sparse_data(self, evidence: List[AccessPointEvidence]) -> List[AssistantCard]:
single_sight = [e for e in evidence if e.sightings == 1]
sparse = [e for e in evidence if 1 < e.sightings <= self.SPARSE_SIGHTINGS]
total = len(evidence)
if not single_sight and not sparse:
return []
cards: List[AssistantCard] = []
if single_sight:
pct = len(single_sight) / total * 100 if total else 0
cards.append(AssistantCard(
title=f"Single-Sighting APs ({len(single_sight)}, {pct:.0f}%)",
fact=(
f"{len(single_sight)} AP(s) were observed exactly once. "
"No centroid computation is possible for these entries."
),
severity=CardSeverity.WARN,
confidence=ConfidenceTier.LOW,
interpretation=(
"Single-sighting entries place an AP on a map based on one GPS fix. "
"There is no triangulation — just one data point. "
"These entries are useful as evidence of presence but not for location accuracy."
),
educational_note=_EDUCATION["confidence_radius"],
mascot_flavor=_pick(_FLAVOR_SPARSE, self._rng),
recommendation=(
"Drive the same area again from a different direction or distance. "
"Multiple sightings from varied positions dramatically improve centroid accuracy."
),
))
if len(sparse) > 5:
cards.append(AssistantCard(
title=f"Low-Sighting APs ({len(sparse)} with 2–{self.SPARSE_SIGHTINGS} sightings)",
fact=f"{len(sparse)} AP(s) have only 2–{self.SPARSE_SIGHTINGS} GPS sightings.",
severity=CardSeverity.NOTE,
confidence=ConfidenceTier.LOW,
interpretation=(
"Low sighting counts limit centroid precision. "
"Two or three sightings is better than one, but typically insufficient for "
"sub-100 metre location confidence without exceptional GPS accuracy."
),
educational_note=_EDUCATION["stability_score"],
recommendation="Target 6–10 sightings per AP for meaningful centroid confidence.",
mascot_flavor="Thin data. Not useless — but thin.",
))
return cards
def _analyze_signal_profile(self, evidence: List[AccessPointEvidence]) -> List[AssistantCard]:
strong = [e for e in evidence if e.best_rssi is not None and e.best_rssi >= self.STRONG_RSSI]
if not strong:
return []
very_strong = [e for e in strong if e.best_rssi is not None and e.best_rssi >= -45]
cards: List[AssistantCard] = []
if very_strong:
top = sorted(very_strong, key=lambda e: -(e.best_rssi or -999))[:5]
lines = [f" {e.ssid or e.mac}: {e.best_rssi} dBm" for e in top]
cards.append(AssistantCard(
title=f"Very Strong Signals Observed ({len(very_strong)} APs)",
fact=(
f"{len(very_strong)} AP(s) were captured at ≥ -45 dBm:\n" + "\n".join(lines)
),
severity=CardSeverity.NOTE,
confidence=ConfidenceTier.HIGH,
interpretation=(
"Very strong RSSI (-45 dBm or better) indicates the capture device passed "
"within approximately 5–15 metres of the transmitter. "
"These entries have the most reliable centroid contributions."
),
educational_note=_EDUCATION["rssi"],
mascot_flavor="Strong signal. You were close. The AP noticed. Not really, but poetically.",
))
return cards
def _analyze_channel_congestion(self, evidence: List[AccessPointEvidence]) -> List[AssistantCard]:
channels = [e.channel for e in evidence if e.channel and e.channel not in _NO_DATA_VALS]
if len(channels) < 5:
return []
from collections import Counter
counts = Counter(channels)
total = len(channels)
most_common_ch, most_common_n = counts.most_common(1)[0]
congestion_frac = most_common_n / total
unique_channels = len(counts)
cards: List[AssistantCard] = []
if congestion_frac >= self.CHANNEL_CONGESTION_THRESHOLD:
cards.append(AssistantCard(
title=f"Channel Congestion — Channel {most_common_ch}",
fact=(
f"{most_common_n} of {total} APs ({congestion_frac:.0%}) are on channel {most_common_ch}. "
f"Total unique channels observed: {unique_channels}."
),
severity=CardSeverity.NOTE,
confidence=ConfidenceTier.HIGH,
interpretation=(
f"Heavy concentration on channel {most_common_ch} is common in dense urban areas "
"where many APs default to the same channel. "
"This can cause co-channel interference and reduced throughput for devices on that channel."
),
educational_note=_EDUCATION["channel"],
mascot_flavor=f"Channel {most_common_ch} is having a party. Everyone showed up.",
))
elif unique_channels >= 8:
cards.append(AssistantCard(
title=f"Good Channel Diversity ({unique_channels} unique channels)",
fact=f"{unique_channels} different channels observed across {total} APs.",
severity=CardSeverity.INSIGHT,
confidence=ConfidenceTier.HIGH,
interpretation=(
"High channel diversity indicates the capture covered multiple frequency bands "
"(2.4 GHz and 5 GHz) and a range of AP configurations. "
"This is characteristic of a dense or mixed environment."
),
educational_note=_EDUCATION["channel"],
mascot_flavor="Multiple bands, multiple channels. The spectrum was generous today.",
))
return cards
def _analyze_vendor_density(self, evidence: List[AccessPointEvidence]) -> List[AssistantCard]:
from collections import Counter
vendors = [
e.vendor for e in evidence
if e.vendor and e.vendor not in _NO_DATA_VALS and e.vendor.lower() != "unknown"
]
if len(vendors) < 3:
return []
counts = Counter(vendors)
top_vendor, top_n = counts.most_common(1)[0]
total = len(evidence)
frac = top_n / total
if frac < 0.30:
return []
return [AssistantCard(
title=f"Dominant Vendor — {top_vendor}",
fact=f"{top_n} of {total} APs ({frac:.0%}) are attributed to vendor: {top_vendor}.",
severity=CardSeverity.NOTE,
confidence=ConfidenceTier.MODERATE,
interpretation=(
f"A high concentration of one vendor ({top_vendor}) can indicate "
"a corporate or ISP deployment, a building wired by one installer, "
"or a residential area where one ISP-supplied model dominates."
),
educational_note=_EDUCATION["bssid"],
mascot_flavor=f"{top_vendor} everywhere. They must have had a sale.",
)]
def _analyze_suspicious_names(self, evidence: List[AccessPointEvidence]) -> List[AssistantCard]:
"""Flag default router names and potentially deceptive SSIDs. Educational only."""
DEFAULT_PATTERNS = [
r"^linksys\b", r"^netgear\b", r"^dlink\b", r"^xfinitywifi\b",
r"^default\b", r"^home\b", r"^router\b", r"^wifi\b",
r"^TP-LINK", r"^ASUS_", r"^DIRECT-",
]
HONEYPOT_PATTERNS = [
r"free\s*wifi", r"airport.*wifi", r"hotel.*wifi", r"starbucks",
r"attwifi", r"xfinity", r"optimum\s*wifi",
]
default_flags: List[AccessPointEvidence] = []
honeypot_flags: List[AccessPointEvidence] = []
for e in evidence:
ssid_lower = e.ssid.lower()
if any(re.match(p, ssid_lower, re.IGNORECASE) for p in DEFAULT_PATTERNS):
default_flags.append(e)
elif any(re.search(p, ssid_lower, re.IGNORECASE) for p in HONEYPOT_PATTERNS):
honeypot_flags.append(e)
cards: List[AssistantCard] = []
if default_flags:
cards.append(AssistantCard(
title=f"Default/Generic SSIDs ({len(default_flags)} APs)",