-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathd_mnemosyne.py
More file actions
2006 lines (1738 loc) · 73.6 KB
/
Copy pathd_mnemosyne.py
File metadata and controls
2006 lines (1738 loc) · 73.6 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
#!/usr/bin/env python3
# Please Read Below
'''
d_mnemosyne.py is a .onion Hidden Service Recon & Safety Scanner
Pre visit analysis of Tor hidden services. Fetches only raw HTTP/HTML
over a SOCKS5 Tor circuit — no browser, no JavaScript execution, no
images, no cookies. This is a means of assessing a .onion address
before any direct interaction, to inform you of site status and possible risks.
Checks performed (all passive / static analysis):
• Reachability and HTTP response metadata
• Security headers (CSP, HSTS, X-Frame-Options, etc.)
• Redirect chain analysis (clearnet redirect is red flag)
• Script tag enumeration and inline JS detection
• External resource leakage (images, fonts, iframes loading off .onion)
• Form detection (fields, action endpoints, methods)
• Fingerprinting vectors in static HTML (canvas, WebRTC hints, WebGL)
• Well-known file probing: canary.txt, pgp.txt, security.txt, robots.txt
• PGP canary verification via gpg (if canary and key both present)
• Server header and technology fingerprinting
• Clearnet reference detection (links to non-.onion domains)
• Weighted passive risk score
Usage:
python d_mnemosyne.py [--save] [--debug]
python d_mnemosyne.py --batch <url_list.txt>
--batch <file> Batch mode:
Read .onion URLs from a text file (one per line).
Runs a sequential scan of all targets over a single Tor session.
More URLs means it will take longer.
Output is a grouped summary report. JSON (--save) produces a
single consolidated file instead of per-target files.
Generate the input file with: python d_erebus.py --save-j
Dependencies:
pip install requests[socks] stem beautifulsoup4 python-gnupg
Tor binary must be installed (tor or tor-browser-bundle)
gpg binary required for canary PGP verification
Tor daemon:
This tool will attempt to launch a managed Tor process via stem.
If Tor is already running on 127.0.0.1:9050, it will use that instead.
Install Tor: https://www.torproject.org/download/
Pretty safe but still use with caution.
For authorized research and investigative use only.
'''
from __future__ import annotations
import os
import re
import sys
import json
import time
import shutil
import socket
import tempfile
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
from typing import Any
from urllib.parse import urlparse, urljoin
import hashlib
import requests # type: ignore
from bs4 import BeautifulSoup # type: ignore
# Optional: stem for managed Tor process
try:
import stem # pyright: ignore[reportMissingTypeStubs]
import stem.process # type: ignore
import stem.control # type: ignore
from stem import Signal # type: ignore
STEM_AVAILABLE = True
except ImportError:
STEM_AVAILABLE = False # type: ignore
# Optional: python-gnupg for canary verification
try:
import gnupg # type: ignore
GNUPG_AVAILABLE = True
except ImportError:
GNUPG_AVAILABLE = False # type: ignore
# Constants
BANNER = '''
██▄ ▄██ ▄▄ ▄▄ ▄▄▄▄▄ ▄▄ ▄▄ ▄▄▄ ▄▄▄▄ ▄▄ ▄▄ ▄▄ ▄▄ ▄▄▄▄▄
██ ▀▀ ██ ███▄██ ██▄▄ ██▀▄▀██ ██▀██ ███▄▄ ▀███▀ ███▄██ ██▄▄
██ ██ ██ ▀██ ██▄▄▄ ██ ██ ▀███▀ ▄▄██▀ █ ██ ▀██ ██▄▄▄
Μνημοσυνη
₲гⱥꮑꮏ ₥є гєⱥđұ гєꮥøʉгꞓє
.oni̸on H̵i̸ddĕn Servi̙ce Re̺connaí̦ssance
'''
DIVIDER = '─' * 62
TOR_SOCKS_HOST = '127.0.0.1'
TOR_SOCKS_PORT = 9050
TOR_CONTROL_PORT = 9051
# Tor circuit rotation: wait this many seconds after NEWNYM before next request
NEWNYM_DELAY = 1
# Request settings — conservative for .onion latency
REQUEST_TIMEOUT = 40 # .onion services can be very slow
CRAWL_DELAY = 2 # seconds between page fetches
# Security headers to check for
SECURITY_HEADERS: list[str] = [
'Content-Security-Policy',
'Strict-Transport-Security',
'X-Frame-Options',
'X-Content-Type-Options',
'Referrer-Policy',
'Permissions-Policy',
'X-XSS-Protection',
]
# Well-known files to probe
WELL_KNOWN_PATHS: list[tuple[str, str]] = [
('/canary.txt', 'Warrant canary'),
('/pgp.txt', 'PGP public key'),
('/.well-known/pgp-key.txt', 'PGP public key (well-known)'),
('/security.txt', 'Security policy'),
('/.well-known/security.txt', 'Security policy (well-known)'),
('/robots.txt', 'Robots exclusion file'),
('/sitemap.xml', 'Sitemap'),
]
# PGP block markers
PGP_END_MARKERS: tuple[str, ...] = (
'-----END PGP PUBLIC KEY BLOCK-----',
'-----END PGP SIGNED MESSAGE-----',
'-----END PGP SIGNATURE-----',
)
# Fingerprinting vector patterns in static HTML
FINGERPRINT_PATTERNS: list[tuple[str, str]] = [
(r'<canvas', 'Canvas element (potential fingerprinting vector)'),
(r'navigator\.webdriver', 'WebDriver detection attempt'),
(r'navigator\.plugins', 'Plugin enumeration (fingerprinting)'),
(r'navigator\.languages', 'Language fingerprinting'),
(r'screen\.width|screen\.height|screen\.colorDepth', 'Screen dimension fingerprinting'),
(r'webgl|WebGLRenderingContext', 'WebGL (GPU fingerprinting vector)'),
(r'AudioContext|webkitAudioContext', 'AudioContext (audio fingerprinting)'),
(r'RTCPeerConnection|webkitRTCPeerConnection', 'WebRTC (potential IP leak)'),
(r'window\.crypto\.getRandomValues', 'Crypto API access'),
(r'Intl\.DateTimeFormat|Date\.prototype\.toLocaleString', 'Timezone fingerprinting'),
(r'navigator\.getBattery|BatteryManager', 'Battery API (fingerprinting)'),
(r'document\.cookie', 'Cookie access in script'),
(r'localStorage|sessionStorage', 'Local/session storage access'),
(r'indexedDB', 'IndexedDB access'),
(r'SharedWorker|ServiceWorker', 'Worker threads'),
]
# ANSI styling
R = '\033[91m'
G = '\033[92m'
Y = '\033[93m'
B = '\033[94m'
C = '\033[96m'
W = '\033[97m'
DIM = '\033[2m'
BOLD = '\033[1m'
RESET = '\033[0m'
# Dataclasses
@dataclass
class TorStatus:
managed: bool # True if we launched the process, False if using existing
running: bool
socks_port: int
control_available: bool
tor_version: str
@dataclass
class ReachabilityResult:
reachable: bool
status_code: int | None
latency_ms: float | None
redirect_chain: list[str]
final_url: str
clearnet_redirect: bool # Any redirect to non-.onion = major red flag
content_type: str
content_length: str
error: str
@dataclass
class ReputationResult:
checked: bool
blacklisted: bool
error: str
@dataclass
class HeaderAnalysis:
raw_headers: dict[str, str]
server: str
powered_by: str
security_headers_present: list[str]
security_headers_missing: list[str]
interesting_headers: dict[str, str] # Non-standard headers worth noting
csp_value: str
csp_issues: list[str]
@dataclass
class WellKnownFile:
path: str
description: str
found: bool
status_code: int | None
content: str # Raw content, truncated
content_length: int
@dataclass
class CanaryVerification:
canary_found: bool
pgp_key_found: bool
verification_attempted: bool
signature_valid: bool | None
key_fingerprint: str
signing_fingerprint: str
fingerprint_match: bool
timestamp: str
uid: str
error: str
@dataclass
class ScriptAnalysis:
total_script_tags: int
inline_scripts: int # <script>...</script> with content
external_scripts: list[str] # src= URLs
external_scripts_onion: list[str] # src= pointing to .onion
external_scripts_clearnet: list[str] # src= pointing to clearnet — very bad
inline_event_handlers: int # onclick=, onload=, etc.
javascript_hrefs: int # javascript: in href
fingerprint_vectors: list[str] # matched patterns from FINGERPRINT_PATTERNS
noscript_tags: int
@dataclass
class ExternalResourceLeak:
tag: str
attribute: str
url: str
is_clearnet: bool
@dataclass
class ResourceAnalysis:
total_external_resources: int
clearnet_leaks: list[ExternalResourceLeak] # Loading from clearnet = deanonymization risk
onion_resources: list[ExternalResourceLeak]
iframes: list[str]
clearnet_links: list[str] # <a href> pointing to clearnet
onion_links: list[str] # <a href> pointing to .onion addresses
@dataclass
class FormAnalysis:
total_forms: int
forms: list[dict[str, Any]] # Each: {action, method, fields: [...]}
has_login_form: bool
has_search_form: bool
has_file_upload: bool
actions_clearnet: list[str] # Form action pointing to clearnet
@dataclass
class TechFingerprint:
web_server: str
cms_hints: list[str]
framework_hints: list[str]
generator_meta: str
language_hints: list[str]
other: list[str]
@dataclass
class RiskScore:
score: int
level: str # LOW / MEDIUM / HIGH / CRITICAL
flags: list[str]
@dataclass
class OnionReport:
target: str
scan_time: str
tor: TorStatus
reachability: ReachabilityResult
reputation: ReputationResult | None
headers: HeaderAnalysis | None
well_known: list[WellKnownFile]
canary: CanaryVerification | None
scripts: ScriptAnalysis | None
resources: ResourceAnalysis | None
forms: FormAnalysis | None
tech: TechFingerprint | None
risk: RiskScore | None
page_title: str
raw_html_length: int
# Helpers
def print_divider() -> None:
print(DIVIDER)
def print_section(title: str) -> None:
print(f'\n{B}{BOLD}[ {title} ]{RESET}')
print_divider()
def print_field(label: str, value: str, color: str = W) -> None:
print(f' {DIM}{label:<32}{RESET}{color}{value}{RESET}')
def print_flag(label: str, present: bool, good_when_present: bool = True) -> None:
if present:
color = G if good_when_present else R
status = 'Present'
else:
color = R if good_when_present else G
status = 'Missing'
print_field(label, status, color)
def is_onion(url: str) -> bool:
parsed = urlparse(url)
host = parsed.netloc or parsed.path
return host.endswith('.onion') or '.onion' in host
def is_clearnet(url: str) -> bool:
if not url or url.startswith('#') or url.startswith('javascript:'):
return False
parsed = urlparse(url)
if not parsed.netloc:
return False
return not parsed.netloc.endswith('.onion')
def validate_onion_address(address: str) -> str:
'''Normalize and validate a .onion address. Returns cleaned URL.'''
address = address.strip()
if not address.startswith('http://') and not address.startswith('https://'):
address = 'http://' + address
parsed = urlparse(address)
host = parsed.netloc
if not host.endswith('.onion'):
sys.exit(f'\n{R}[ERROR] "{host}" is not a .onion address.{RESET}\n')
return address
def truncate(text: str, max_len: int = 200) -> str:
if len(text) <= max_len:
return text
return text[:max_len] + f'... [{len(text) - max_len} chars truncated]'
# Tor management
def check_tor_running() -> bool:
'''Check if a Tor SOCKS proxy is already listening on the expected port.'''
try:
s = socket.create_connection((TOR_SOCKS_HOST, TOR_SOCKS_PORT), timeout=3)
s.close()
return True
except Exception:
return False
def launch_tor_process() -> tuple[Any, TorStatus]:
'''
Use stem to launch a managed Tor process.
Returns (process_handle, TorStatus).
process_handle is None if we are using an existing daemon.
'''
if not STEM_AVAILABLE:
sys.exit(
f'\n{R}[ERROR] stem is not installed. Install with: pip install stem{RESET}\n'
f' Or start Tor manually: tor --SocksPort 9050\n'
)
tor_binary = shutil.which('tor')
if not tor_binary:
sys.exit(
f'\n{R}[ERROR] Tor binary not found on PATH.{RESET}\n'
f' Install Tor: https://www.torproject.org/download/\n'
f' On Debian/Ubuntu: sudo apt install tor\n'
f' On macOS: brew install tor\n'
)
if check_tor_running():
print(f' {G}Existing Tor daemon detected on port {TOR_SOCKS_PORT} — using it.{RESET}')
tor_version = 'unknown (existing daemon)'
try:
with stem.control.Controller.from_port(port=TOR_CONTROL_PORT) as ctrl: # type:ignore
ctrl.authenticate() # type: ignore
tor_version = ctrl.get_version().version_str # type: ignore
except Exception:
pass
return None, TorStatus(
managed=False,
running=True,
socks_port=TOR_SOCKS_PORT,
control_available=False,
tor_version=tor_version, # type: ignore
)
print(f' {DIM}Launching Tor via stem ({tor_binary})...{RESET}')
try:
tor_process = stem.process.launch_tor_with_config( # type: ignore
config={
'SocksPort': str(TOR_SOCKS_PORT),
'ControlPort': str(TOR_CONTROL_PORT),
'DataDirectory': tempfile.mkdtemp(prefix='c_onion_tor_'),
'Log': 'notice stderr',
'CookieAuthentication': '1',
},
init_msg_handler=lambda _: None, # type: ignore Suppress Tor startup noise
timeout=90
)
tor_version = 'unknown'
try:
with stem.control.Controller.from_port(port=TOR_CONTROL_PORT) as ctrl: # type: ignore
ctrl.authenticate() # type: ignore
while True:
bootstrap: str = str(ctrl.get_info('status/bootstrap-phase')) # type: ignore
if 'PROGRESS=100' in str(bootstrap):
break
time.sleep(1)
except Exception:
pass
return tor_process, TorStatus( # type: ignore
managed=True,
running=True,
socks_port=TOR_SOCKS_PORT,
control_available=True,
tor_version=tor_version, # type: ignore
)
except Exception as e:
sys.exit(f'\n{R}[ERROR] Failed to launch Tor: {e}{RESET}\n')
def rotate_circuit() -> bool:
'''
Request a new Tor circuit via stem control port.
Returns True if successful. Used between target scans.
'''
if not STEM_AVAILABLE:
return False
try:
with stem.control.Controller.from_port(port=TOR_CONTROL_PORT) as ctrl: # type: ignore
ctrl.authenticate() # type: ignore (chroot_path=data_dir) match auth pattern maybe? v.0.1.9 working without
ctrl.signal(Signal.NEWNYM) # type: ignore
time.sleep(NEWNYM_DELAY)
return True
except Exception as e:
if '--debug' in sys.argv:
print(f' {DIM}Circuit rotation error: {e}{RESET}')
return False
def build_tor_session() -> requests.Session:
'''
Build a requests.Session configured to route through Tor SOCKS5 proxy.
No cookies, no redirect following by default.
'''
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
session = requests.Session()
proxies = {
'http': f'socks5h://{TOR_SOCKS_HOST}:{TOR_SOCKS_PORT}',
'https': f'socks5h://{TOR_SOCKS_HOST}:{TOR_SOCKS_PORT}',
}
session.proxies.update(proxies)
session.cookies.clear()
# socks5h: hostname resolution happens inside Tor (critical for .onion)
return session
def tor_get(
session: requests.Session,
url: str,
*,
allow_redirects: bool = False,
stream: bool = False,
) -> requests.Response:
'''
Perform a GET over Tor with hardened headers and timeouts.
Explicitly avoids sending Accept-Encoding to prevent compressed binary responses.
'''
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; rv:109.0) Gecko/20100101 Firefox/115.0',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
# No Accept-Encoding: avoid compressed responses that BS4 cannot parse
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
}
return session.get(
url,
headers=headers,
timeout=REQUEST_TIMEOUT,
allow_redirects=allow_redirects,
stream=stream,
verify=False, # Don't verify the SSL cert
)
# Reachability check
def check_reachability(session: requests.Session, base_url: str) -> ReachabilityResult:
'''
Follow the redirect chain manually to detect clearnet redirects.
A .onion site redirecting to a clearnet domain is a critical red flag.
'''
redirect_chain: list[str] = []
current_url = base_url
final_url = base_url
clearnet_redirect = False
latency_ms: float | None = None
status_code: int | None = None
content_type = 'N/A'
content_length = 'N/A'
error = ''
try:
start = time.monotonic()
response = tor_get(session, current_url, allow_redirects=False)
latency_ms = round((time.monotonic() - start) * 1000, 1)
status_code = response.status_code
content_type = response.headers.get('Content-Type', 'N/A')
content_length = response.headers.get('Content-Length', 'N/A')
# Manually follow redirects to inspect each hop
max_redirects = 15 # Can be incremented accordingly
hops = 0
while response.is_redirect and hops < max_redirects:
location = response.headers.get('Location', '')
if not location:
break
redirect_chain.append(location)
if is_clearnet(location):
clearnet_redirect = True
current_url = urljoin(current_url, location)
response = tor_get(session, current_url, allow_redirects=False)
status_code = response.status_code
content_type = response.headers.get('Content-Type', content_type)
content_length = response.headers.get('Content-Length', content_length)
hops += 1
final_url = current_url
return ReachabilityResult(
reachable=True,
status_code=status_code,
latency_ms=latency_ms,
redirect_chain=redirect_chain,
final_url=final_url,
clearnet_redirect=clearnet_redirect,
content_type=content_type,
content_length=content_length,
error='',
)
except requests.exceptions.ConnectTimeout:
error = 'Connection timed out — service may be offline or circuit is slow'
except requests.exceptions.ConnectionError as e:
error = f'Connection error: {e}'
except Exception as e:
error = f'Unexpected error: {e}'
return ReachabilityResult(
reachable=False,
status_code=None,
latency_ms=latency_ms,
redirect_chain=redirect_chain,
final_url=final_url,
clearnet_redirect=clearnet_redirect,
content_type='N/A',
content_length='N/A',
error=error,
)
def fetch_ahmia_blacklist(session: requests.Session) -> set[str]:
'''
Fetch and cache the full Ahmia blacklist at startup over Tor.
Routed through Tor so no clearnet requests are associated with this tool.
'''
url = 'https://ahmia.fi/blacklist/banned/'
try:
response = session.get(url, timeout=REQUEST_TIMEOUT, verify=False,
headers={'User-Agent': 'Mozilla/5.0'})
response.raise_for_status()
return {line.strip().lower() for line in response.text.splitlines() if line.strip()}
except Exception as e:
if '--debug' in sys.argv:
print(f' {DIM}Ahmia fetch error: {e}{RESET}')
return set()
def check_ahmia_blacklist(onion_host: str, blacklist: set[str]) -> ReputationResult:
try:
host = onion_host.lower().split('/')[0]
host_hash = hashlib.md5(host.encode('utf-8')).hexdigest() # Ahmia stores MD5 hashes of the .onion address, not plaintext
blacklisted = host_hash in blacklist
checked = len(blacklist) > 0
return ReputationResult(
checked=checked,
blacklisted=blacklisted,
error='' if checked else 'Blacklist could not be fetched',
)
except Exception as e:
return ReputationResult(
checked=False,
blacklisted=False,
error=str(e),
)
# Header analysis
def analyze_headers(response: requests.Response) -> HeaderAnalysis:
raw: dict[str, str] = dict(response.headers)
raw_lower = {k.lower(): v for k, v in raw.items()}
server = raw_lower.get('server', 'Not disclosed')
powered_by = raw_lower.get('x-powered-by', 'Not disclosed')
csp_value = raw_lower.get('content-security-policy', '')
present: list[str] = []
missing: list[str] = []
for h in SECURITY_HEADERS:
if h.lower() in raw_lower:
present.append(h)
else:
missing.append(h)
# Pick out non-standard headers that reveal information
interesting: dict[str, str] = {}
standard_boring = {
'content-type', 'content-length', 'date', 'connection',
'transfer-encoding', 'cache-control', 'expires', 'pragma',
'content-security-policy', 'strict-transport-security',
'x-frame-options', 'x-content-type-options', 'referrer-policy',
'permissions-policy', 'x-xss-protection', 'server', 'x-powered-by',
'set-cookie', 'vary', 'etag', 'last-modified', 'accept-ranges',
}
for k, v in raw_lower.items():
if k not in standard_boring and (k.startswith('x-') or k in ('via', 'alt-svc')):
interesting[k] = v
# CSP quality check
csp_issues: list[str] = []
if csp_value:
csp_lower = csp_value.lower()
if 'unsafe-inline' in csp_lower:
csp_issues.append("'unsafe-inline' allows inline scripts/styles — defeats XSS protection")
if 'unsafe-eval' in csp_lower:
csp_issues.append("'unsafe-eval' allows eval() — significant XSS risk")
if re.search(r'script-src\s+\*|default-src\s+\*', csp_lower):
csp_issues.append('Wildcard (*) in script/default-src — any origin allowed')
if 'http:' in csp_lower:
csp_issues.append("'http:' scheme allowed — mixed content risk")
else:
csp_issues.append('CSP header absent')
return HeaderAnalysis(
raw_headers=raw,
server=server,
powered_by=powered_by,
security_headers_present=present,
security_headers_missing=missing,
interesting_headers=interesting,
csp_value=csp_value,
csp_issues=csp_issues,
)
# Well-known file probing
def probe_well_known(session: requests.Session, base_url: str) -> list[WellKnownFile]:
'''
Probe well-known paths. Fetches content for canary and PGP key paths.
Uses HEAD for everything else unless content is needed.
'''
results: list[WellKnownFile] = []
content_paths = {'/canary.txt', '/pgp.txt', '/.well-known/pgp-key.txt',
'/security.txt', '/.well-known/security.txt', '/robots.txt'}
for path, description in WELL_KNOWN_PATHS:
url = base_url.rstrip('/') + path
try:
if path in content_paths:
response = tor_get(session, url, allow_redirects=True)
content = response.text[:4096] if response.status_code == 200 else ''
status = response.status_code
content_length = len(response.content)
else:
response = session.head(
url,
timeout=REQUEST_TIMEOUT,
proxies={
'http': f'socks5h://{TOR_SOCKS_HOST}:{TOR_SOCKS_PORT}',
'https': f'socks5h://{TOR_SOCKS_HOST}:{TOR_SOCKS_PORT}',
},
allow_redirects=True,
)
status = response.status_code
content = ''
content_length = int(response.headers.get('Content-Length', 0))
results.append(WellKnownFile(
path=path,
description=description,
found=status == 200,
status_code=status,
content=content,
content_length=content_length,
))
except Exception:
results.append(WellKnownFile(
path=path,
description=description,
found=False,
status_code=None,
content='',
content_length=0,
))
return results
# PGP canary verification (reuses pgp_verify.py logic)
def verify_canary(well_known: list[WellKnownFile], *, debug: bool) -> CanaryVerification | None:
'''
If both canary.txt and a PGP key are available, attempt verification.
Isolates key import to a temporary keyring — same pattern as pgp_verify.py.
Returns None if prerequisites are not met.
'''
if not GNUPG_AVAILABLE:
return None
gpg_binary = shutil.which('gpg') or shutil.which('gpg2')
if not gpg_binary:
return None
# Find canary content
canary_content = ''
for wk in well_known:
if wk.path == '/canary.txt' and wk.found and wk.content:
canary_content = wk.content
break
if not canary_content:
return None
# Check if canary is clearsigned
if '-----BEGIN PGP SIGNED MESSAGE-----' not in canary_content:
return CanaryVerification(
canary_found=True,
pgp_key_found=False,
verification_attempted=False,
signature_valid=None,
key_fingerprint='',
signing_fingerprint='',
fingerprint_match=False,
timestamp='',
uid='',
error='Canary found but is not a PGP clearsigned message — cannot verify',
)
# Find PGP key
pgp_key_content = ''
for key_path in ['/pgp.txt', '/.well-known/pgp-key.txt']:
for wk in well_known:
if wk.path == key_path and wk.found and wk.content:
if '-----BEGIN PGP PUBLIC KEY BLOCK-----' in wk.content:
pgp_key_content = wk.content
break
if pgp_key_content:
break
if not pgp_key_content:
return CanaryVerification(
canary_found=True,
pgp_key_found=False,
verification_attempted=False,
signature_valid=None,
key_fingerprint='',
signing_fingerprint='',
fingerprint_match=False,
timestamp='',
uid='',
error='Canary is PGP-signed but no public key found at /pgp.txt or /.well-known/pgp-key.txt',
)
# Verify in isolated temporary keyring
try:
with tempfile.TemporaryDirectory() as tmpdir:
os.chmod(tmpdir, 0o700)
gpg: Any = gnupg.GPG(gnupghome=tmpdir) # type: ignore
gpg.encoding = 'utf-8'
import_result: Any = gpg.import_keys(pgp_key_content)
if not import_result.count:
return CanaryVerification(
canary_found=True,
pgp_key_found=True,
verification_attempted=True,
signature_valid=False,
key_fingerprint='',
signing_fingerprint='',
fingerprint_match=False,
timestamp='',
uid='',
error='Failed to import public key — key block may be malformed',
)
fingerprints: list[str] = import_result.fingerprints or []
imported_fp = fingerprints[0] if fingerprints else ''
asc_path = os.path.join(tmpdir, 'canary.asc')
with open(asc_path, 'w', encoding='utf-8') as fh:
fh.write(canary_content)
with open(asc_path, 'rb') as fh:
result: Any = gpg.verify_file(fh)
valid = bool(result)
sig_fp = str(getattr(result, 'fingerprint', '') or '')
timestamp_raw = str(getattr(result, 'timestamp', '') or getattr(result, 'sig_timestamp', '') or '')
uid = str(getattr(result, 'username', '') or '')
# Format timestamp
timestamp = ''
if timestamp_raw:
try:
dt = datetime.fromtimestamp(int(timestamp_raw), tz=timezone.utc)
timestamp = dt.strftime('%Y-%m-%d %H:%M:%S UTC')
except Exception as e:
if '--debug' in sys.argv:
print(f' {DIM}Timestamp formatting error: {e}{RESET}')
timestamp = timestamp_raw
fp_match = bool(sig_fp and imported_fp and sig_fp.upper() == imported_fp.upper())
return CanaryVerification(
canary_found=True,
pgp_key_found=True,
verification_attempted=True,
signature_valid=valid,
key_fingerprint=imported_fp.upper(),
signing_fingerprint=sig_fp.upper(),
fingerprint_match=fp_match,
timestamp=timestamp,
uid=uid,
error='' if valid else (str(getattr(result, 'status', '')) or 'Verification failed'),
)
except Exception as e:
return CanaryVerification(
canary_found=True,
pgp_key_found=True,
verification_attempted=True,
signature_valid=False,
key_fingerprint='',
signing_fingerprint='',
fingerprint_match=False,
timestamp='',
uid='',
error=f'GPG error: {e}',
)
# Static HTML analysis
def analyze_scripts(soup: BeautifulSoup, html: str) -> ScriptAnalysis:
'''Enumerate all script tags and inline JS patterns. No execution.'''
script_tags = soup.find_all('script')
inline_scripts = 0
external_scripts: list[str] = []
external_scripts_onion: list[str] = []
external_scripts_clearnet: list[str] = []
for tag in script_tags:
src = tag.get('src', '')
if src:
external_scripts.append(str(src))
if is_onion(str(src)):
external_scripts_onion.append(str(src))
elif is_clearnet(str(src)):
external_scripts_clearnet.append(str(src)) # Very bad
elif tag.string and tag.string.strip():
inline_scripts += 1
# Inline event handlers on any element
inline_handlers = 0
event_attrs = [
'onclick', 'onload', 'onmouseover', 'onsubmit', 'onchange',
'onerror', 'onfocus', 'onblur', 'onkeyup', 'onkeydown',
]
for tag in soup.find_all(True):
for attr in event_attrs:
if tag.get(attr):
inline_handlers += 1
# Javascript: hrefs
js_hrefs = sum(
1 for tag in soup.find_all('a', href=True)
if str(tag.get('href', '')).strip().lower().startswith('javascript:')
)
# Fingerprinting vector detection in raw HTML
fingerprint_vectors: list[str] = []
for pattern, description in FINGERPRINT_PATTERNS:
if re.search(pattern, html, re.IGNORECASE):
fingerprint_vectors.append(description)
noscript_tags = len(soup.find_all('noscript'))
return ScriptAnalysis(
total_script_tags=len(script_tags),
inline_scripts=inline_scripts,
external_scripts=external_scripts,
external_scripts_onion=external_scripts_onion,
external_scripts_clearnet=external_scripts_clearnet,
inline_event_handlers=inline_handlers,
javascript_hrefs=js_hrefs,
fingerprint_vectors=fingerprint_vectors,
noscript_tags=noscript_tags,
)
def analyze_resources(soup: BeautifulSoup) -> ResourceAnalysis:
'''
Enumerate external resource references. Clearnet resources loaded by a
.onion page will contact clearnet servers, deanonymizing the visitor.
'''
# Tag → attribute pairs that load external resources
resource_checks: list[tuple[str, str]] = [
('img', 'src'),
('link', 'href'),
('script', 'src'),
('iframe', 'src'),
('audio', 'src'),
('video', 'src'),
('source', 'src'),
('embed', 'src'),
('object', 'data'),
]
clearnet_leaks: list[ExternalResourceLeak] = []
onion_resources: list[ExternalResourceLeak] = []
iframes: list[str] = []
clearnet_links: list[str] = []
onion_links: list[str] = []
total = 0
for tag_name, attr in resource_checks:
for tag in soup.find_all(tag_name):
url = str(tag.get(attr, '') or '')
if not url or url.startswith('data:') or url.startswith('#'):
continue
parsed = urlparse(url)
if not parsed.netloc: # Relative URLs — skip
continue
total += 1
leak = ExternalResourceLeak(
tag=tag_name,
attribute=attr,
url=url,
is_clearnet=is_clearnet(url),
)
if is_clearnet(url):
clearnet_leaks.append(leak)
elif is_onion(url):
onion_resources.append(leak)
if tag_name == 'iframe':
iframes.append(url)
# Link analysis
for tag in soup.find_all('a', href=True):
href = str(tag.get('href', '') or '')
if not href or href.startswith('#') or href.startswith('javascript:'):
continue
parsed = urlparse(href)
if not parsed.netloc:
continue
if is_clearnet(href):
clearnet_links.append(href)
elif is_onion(href):
onion_links.append(href)
return ResourceAnalysis(
total_external_resources=total,
clearnet_leaks=clearnet_leaks,
onion_resources=onion_resources,
iframes=iframes,
clearnet_links=list(dict.fromkeys(clearnet_links)), # dedupe, preserve order
onion_links=list(dict.fromkeys(onion_links)),
)
def analyze_forms(soup: BeautifulSoup) -> FormAnalysis:
'''Parse all forms: fields, methods, action endpoints.'''
form_tags = soup.find_all('form')
forms: list[dict[str, Any]] = []
has_login = False
has_search = False
has_upload = False
actions_clearnet: list[str] = []
for form in form_tags:
action = str(form.get('action', '') or '')
method = str(form.get('method', 'GET')).upper()