detecti-cli 2.0.12__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/PKG-INFO +1 -1
  2. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/cli.py +12 -0
  3. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/core/database/schema.py +2 -0
  4. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/core/database/storage.py +50 -16
  5. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/core/engine.py +19 -3
  6. detecti_cli-2.1.0/detecti/modules/securitytrails.py +267 -0
  7. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/api/graph_builder.py +63 -13
  8. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/js/graph.js +53 -21
  9. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/pyproject.toml +1 -1
  10. detecti_cli-2.0.12/detecti/modules/securitytrails.py +0 -125
  11. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/.env.example +0 -0
  12. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/.github/workflows/publish.yml +0 -0
  13. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/.gitignore +0 -0
  14. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/README.md +0 -0
  15. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/__init__.py +0 -0
  16. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/__main__.py +0 -0
  17. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/__init__.py +0 -0
  18. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/banner +0 -0
  19. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/config.py +0 -0
  20. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/core/__init__.py +0 -0
  21. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/core/database/__init__.py +0 -0
  22. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/core/database/config_db.py +0 -0
  23. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/core/models.py +0 -0
  24. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/data/config.sqlite +0 -0
  25. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/data/dbs/.gitkeep +0 -0
  26. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/data/dbs/example.com.sqlite +0 -0
  27. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/modules/__init__.py +0 -0
  28. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/modules/base.py +0 -0
  29. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/modules/censys.py +0 -0
  30. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/modules/crtsh.py +0 -0
  31. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/modules/exploitdb.py +0 -0
  32. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/modules/masscan.py +0 -0
  33. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/modules/nuclei.py +0 -0
  34. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/modules/nvd.py +0 -0
  35. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/modules/reverse_whois.py +0 -0
  36. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/modules/shodan.py +0 -0
  37. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/reporters/__init__.py +0 -0
  38. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/reporters/csv_reporter.py +0 -0
  39. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/reporters/html_reporter.py +0 -0
  40. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/reporters/json_reporter.py +0 -0
  41. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/reporters/markdown_reporter.py +0 -0
  42. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/utils/__init__.py +0 -0
  43. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/utils/http.py +0 -0
  44. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/utils/logger.py +0 -0
  45. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/utils/setup.py +0 -0
  46. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/utils/updater.py +0 -0
  47. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/__init__.py +0 -0
  48. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/api/__init__.py +0 -0
  49. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/api/auth.py +0 -0
  50. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/api/routes.py +0 -0
  51. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/process_manager.py +0 -0
  52. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/server.py +0 -0
  53. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/android-chrome-192x192.png +0 -0
  54. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/android-chrome-512x512.png +0 -0
  55. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/apple-touch-icon.png +0 -0
  56. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/css/__init__.py +0 -0
  57. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/css/dashboard.css +0 -0
  58. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/favicon-16x16.png +0 -0
  59. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/favicon-32x32.png +0 -0
  60. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/favicon.ico +0 -0
  61. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/img/DetecTI_Security_Logo.png +0 -0
  62. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/img/detecti-ico.png +0 -0
  63. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/index.html +0 -0
  64. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/js/__init__.py +0 -0
  65. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/js/api.js +0 -0
  66. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/js/cytoscape-cose-bilkent.js +0 -0
  67. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/js/cytoscape-dagre.js +0 -0
  68. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/js/cytoscape.min.js +0 -0
  69. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/js/dagre.min.js +0 -0
  70. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/js/lucide.min.js +0 -0
  71. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/login.html +0 -0
  72. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/site.webmanifest +0 -0
  73. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/requirements.txt +0 -0
  74. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/tests/__init__.py +0 -0
  75. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/tests/conftest.py +0 -0
  76. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/tests/test_engine_cli.py +0 -0
  77. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/tests/test_http.py +0 -0
  78. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/tests/test_masscan.py +0 -0
  79. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/tests/test_models.py +0 -0
  80. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/tests/test_modules.py +0 -0
  81. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/tests/test_nuclei.py +0 -0
  82. {detecti_cli-2.0.12 → detecti_cli-2.1.0}/tests/test_reporters.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: detecti-cli
3
- Version: 2.0.12
3
+ Version: 2.1.0
4
4
  Summary: Modern Attack Surface Management & Threat Intelligence Engine
5
5
  Author-email: "Lucas S. (Ls4ss)" <lucas@lucassouza.io>
6
6
  License: MIT
@@ -264,6 +264,18 @@ def scan_command(
264
264
  try:
265
265
  progress.update(task_id, description=f"[bold cyan]Storing results in SQLite database ({final_db_name})...")
266
266
  db_manager.store_scan_result(result)
267
+
268
+ # Lazy Summary: Sync the CLI Executive Summary with the Database Post-Gating metrics
269
+ stats = db_manager.get_summary_stats()
270
+ if result.summary:
271
+ result.summary.total_hosts_count = stats.get('total_ips', result.summary.total_hosts_count)
272
+ result.summary.subdomains_count = stats.get('total_subdomains', result.summary.subdomains_count)
273
+ # For associated domains in UI we refer to total_domains
274
+ result.summary.associated_domains_count = stats.get('total_domains', result.summary.associated_domains_count)
275
+ result.summary.open_ports_count = stats.get('open_services', result.summary.open_ports_count)
276
+ result.summary.vulnerabilities_count = stats.get('total_vulnerabilities', result.summary.vulnerabilities_count)
277
+ result.summary.cisa_kev_count = stats.get('cisa_kev_count', result.summary.cisa_kev_count)
278
+
267
279
  print_success(f"Scan results stored in database: [bold underline]{db_manager.db_path.resolve() if hasattr(db_manager.db_path, 'resolve') else db_manager.db_path}[/bold underline]")
268
280
  except Exception as e:
269
281
  print_error(f"Failed to store results in database: {e}")
@@ -38,6 +38,7 @@ CREATE TABLE IF NOT EXISTS ip_addresses (
38
38
  CREATE TABLE IF NOT EXISTS subdomain_ips (
39
39
  subdomain_id TEXT NOT NULL,
40
40
  ip_id TEXT NOT NULL,
41
+ resolution_type TEXT DEFAULT 'RESOLVES_TO',
41
42
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
42
43
  PRIMARY KEY (subdomain_id, ip_id),
43
44
  FOREIGN KEY (subdomain_id) REFERENCES subdomains(id),
@@ -48,6 +49,7 @@ CREATE TABLE IF NOT EXISTS subdomain_ips (
48
49
  CREATE TABLE IF NOT EXISTS services (
49
50
  id TEXT PRIMARY KEY,
50
51
  ip_id TEXT NOT NULL,
52
+ resolution_type TEXT DEFAULT 'RESOLVES_TO',
51
53
  port INTEGER NOT NULL,
52
54
  protocol TEXT DEFAULT 'tcp',
53
55
  service_name TEXT,
@@ -89,6 +89,22 @@ class DatabaseManager:
89
89
  except Exception:
90
90
  pass
91
91
 
92
+ # Auto-migrate: ensure metadata column exists in subdomains table for WAF bypass tags
93
+ try:
94
+ sub_cols = [row[1] for row in conn.execute("PRAGMA table_info(subdomains)").fetchall()]
95
+ if "metadata" not in sub_cols:
96
+ conn.execute("ALTER TABLE subdomains ADD COLUMN metadata TEXT")
97
+ except Exception:
98
+ pass
99
+ # Auto-migrate: ensure resolution_type exists in subdomain_ips table
100
+ try:
101
+ sub_ip_cols = [row[1] for row in conn.execute("PRAGMA table_info(subdomain_ips)").fetchall()]
102
+ if "resolution_type" not in sub_ip_cols:
103
+ conn.execute("ALTER TABLE subdomain_ips ADD COLUMN resolution_type TEXT DEFAULT 'RESOLVES_TO'")
104
+ except Exception:
105
+ pass
106
+
107
+
92
108
  # Auto-clean: deduplicate any existing redundant services per (ip_id, port, protocol)
93
109
  self._deduplicate_services(conn)
94
110
 
@@ -224,7 +240,7 @@ class DatabaseManager:
224
240
  subdomain_map = {}
225
241
 
226
242
  # Helper to register any candidate subdomain
227
- def _register_subdomain_candidate(raw_name: str) -> None:
243
+ def _register_subdomain_candidate(raw_name: str, meta: Optional[Dict] = None) -> None:
228
244
  if not raw_name:
229
245
  return
230
246
  cand = raw_name.strip().lower()
@@ -276,12 +292,20 @@ class DatabaseManager:
276
292
  INSERT INTO subdomains (id, domain_id, name)
277
293
  VALUES (?, ?, ?)
278
294
  """, (subdomain_id, domain_id, cand))
295
+
296
+ if meta:
297
+ try:
298
+ meta_json = json.dumps(meta)
299
+ conn.execute("UPDATE subdomains SET metadata = ? WHERE id = ?", (meta_json, subdomain_id))
300
+ except Exception:
301
+ pass
302
+
279
303
  subdomain_map[cand] = subdomain_id
280
304
 
281
305
  # 1. Register subdomains from FindingType.SUBDOMAIN, ASSOCIATED_DOMAIN and targets
282
306
  for finding in findings:
283
307
  if finding.type in (FindingType.SUBDOMAIN, FindingType.ASSOCIATED_DOMAIN) and finding.value:
284
- _register_subdomain_candidate(finding.value)
308
+ _register_subdomain_candidate(finding.value, finding.metadata)
285
309
  if finding.target:
286
310
  _register_subdomain_candidate(finding.target)
287
311
  if finding.type == FindingType.HOST_INFO and finding.host_info:
@@ -295,10 +319,10 @@ class DatabaseManager:
295
319
  for host in hosts:
296
320
  if host.hostnames:
297
321
  for hname in host.hostnames:
298
- _register_subdomain_candidate(hname)
322
+ _register_subdomain_candidate(hname, host.metadata if host.metadata.get("waf_bypassed_domains") and hname in host.metadata["waf_bypassed_domains"] else None)
299
323
  if host.domains:
300
324
  for dname in host.domains:
301
- _register_subdomain_candidate(dname)
325
+ _register_subdomain_candidate(dname, host.metadata if host.metadata.get("waf_bypassed_domains") and dname in host.metadata["waf_bypassed_domains"] else None)
302
326
 
303
327
  return subdomain_map
304
328
 
@@ -547,10 +571,13 @@ class DatabaseManager:
547
571
  if hname_clean.startswith("*."):
548
572
  hname_clean = hname_clean[2:]
549
573
  if hname_clean in subdomain_map:
574
+ res_type = "RESOLVES_TO" if any("dns" in s.lower() and "historical" not in s.lower() for s in getattr(host, "sources", [])) else "IPS_HISTORY"
550
575
  conn.execute("""
551
- INSERT OR IGNORE INTO subdomain_ips (subdomain_id, ip_id)
552
- VALUES (?, ?)
553
- """, (subdomain_map[hname_clean], ip_id))
576
+ INSERT INTO subdomain_ips (subdomain_id, ip_id, resolution_type)
577
+ VALUES (?, ?, ?)
578
+ ON CONFLICT (subdomain_id, ip_id) DO UPDATE SET
579
+ resolution_type = CASE WHEN excluded.resolution_type = 'RESOLVES_TO' THEN 'RESOLVES_TO' ELSE subdomain_ips.resolution_type END
580
+ """, (subdomain_map[hname_clean], ip_id, res_type))
554
581
 
555
582
  # Map specific authoritative DNS resolutions & finding associations (subdomain -> IP)
556
583
  for finding in result.findings:
@@ -565,10 +592,13 @@ class DatabaseManager:
565
592
  if hname_clean.startswith("*."):
566
593
  hname_clean = hname_clean[2:]
567
594
  if hname_clean in subdomain_map:
595
+ res_type = "RESOLVES_TO" if ("dns" in getattr(finding, "source", "").lower() and "historical" not in getattr(finding, "source", "").lower()) else "IPS_HISTORY"
568
596
  conn.execute("""
569
- INSERT OR IGNORE INTO subdomain_ips (subdomain_id, ip_id)
570
- VALUES (?, ?)
571
- """, (subdomain_map[hname_clean], target_ip_id))
597
+ INSERT INTO subdomain_ips (subdomain_id, ip_id, resolution_type)
598
+ VALUES (?, ?, ?)
599
+ ON CONFLICT (subdomain_id, ip_id) DO UPDATE SET
600
+ resolution_type = CASE WHEN excluded.resolution_type = 'RESOLVES_TO' THEN 'RESOLVES_TO' ELSE subdomain_ips.resolution_type END
601
+ """, (subdomain_map[hname_clean], target_ip_id, res_type))
572
602
 
573
603
  # 2. Subdomain finding with explicit host_ip
574
604
  if finding.type == FindingType.SUBDOMAIN and finding.value and finding.host_ip == hip:
@@ -576,10 +606,13 @@ class DatabaseManager:
576
606
  if sub_val.startswith("*."):
577
607
  sub_val = sub_val[2:]
578
608
  if sub_val in subdomain_map:
609
+ res_type = "RESOLVES_TO" if ("dns" in getattr(finding, "source", "").lower() and "historical" not in getattr(finding, "source", "").lower()) else "IPS_HISTORY"
579
610
  conn.execute("""
580
- INSERT OR IGNORE INTO subdomain_ips (subdomain_id, ip_id)
581
- VALUES (?, ?)
582
- """, (subdomain_map[sub_val], target_ip_id))
611
+ INSERT INTO subdomain_ips (subdomain_id, ip_id, resolution_type)
612
+ VALUES (?, ?, ?)
613
+ ON CONFLICT (subdomain_id, ip_id) DO UPDATE SET
614
+ resolution_type = CASE WHEN excluded.resolution_type = 'RESOLVES_TO' THEN 'RESOLVES_TO' ELSE subdomain_ips.resolution_type END
615
+ """, (subdomain_map[sub_val], target_ip_id, res_type))
583
616
 
584
617
  conn.commit()
585
618
 
@@ -927,8 +960,9 @@ class DatabaseManager:
927
960
 
928
961
  # Ensure direct link between FQDN and IP
929
962
  conn.execute("""
930
- INSERT OR IGNORE INTO subdomain_ips (subdomain_id, ip_id)
931
- VALUES (?, ?)
963
+ INSERT INTO subdomain_ips (subdomain_id, ip_id, resolution_type)
964
+ VALUES (?, ?, 'RESOLVES_TO')
965
+ ON CONFLICT (subdomain_id, ip_id) DO UPDATE SET resolution_type = 'RESOLVES_TO'
932
966
  """, (sub_id, cur_ip_id))
933
967
 
934
968
  # 4. If DNS resolution was offline/empty, fallback to any existing database links
@@ -1219,7 +1253,7 @@ class DatabaseManager:
1219
1253
  d_row = conn.execute("SELECT id FROM domains LIMIT 1").fetchone()
1220
1254
  d_id = d_row[0] if d_row else None
1221
1255
  conn.execute("INSERT OR IGNORE INTO subdomains (id, domain_id, name) VALUES (?, ?, ?)", (s_id, d_id, clean_candidate))
1222
- conn.execute("INSERT OR IGNORE INTO subdomain_ips (subdomain_id, ip_id) VALUES (?, ?)", (s_id, ip_id))
1256
+ conn.execute("INSERT INTO subdomain_ips (subdomain_id, ip_id, resolution_type) VALUES (?, ?, 'RESOLVES_TO') ON CONFLICT (subdomain_id, ip_id) DO UPDATE SET resolution_type = 'RESOLVES_TO'", (s_id, ip_id))
1223
1257
  except Exception:
1224
1258
  pass
1225
1259
 
@@ -436,6 +436,12 @@ class ThreatTrackEngine:
436
436
  self._notify("reverse_whois", f"Performing Reverse WHOIS lookup for {query_whois}...")
437
437
  recon_tasks.append(self.modules["reverse_whois"].run(query_whois, context))
438
438
 
439
+ # 5. SecurityTrails
440
+ if target_type in ("domain", "ip") and "securitytrails" in active_mod_names:
441
+ st_target = root_domain or clean_target if target_type == "domain" else clean_target
442
+ self._notify("securitytrails", f"Querying SecurityTrails for {st_target}...")
443
+ recon_tasks.append(self.modules["securitytrails"].run(st_target, context))
444
+
439
445
  if recon_tasks:
440
446
  recon_results = await asyncio.gather(*recon_tasks, return_exceptions=True)
441
447
  for res in recon_results:
@@ -509,12 +515,14 @@ class ThreatTrackEngine:
509
515
 
510
516
  for sub, ips in resolve_results:
511
517
  if ips:
512
- # In an IP scan, if the resolved IPs do NOT include the target IP, this domain has migrated elsewhere
518
+ # In an IP scan, if the resolved IPs do NOT include the target IP, this domain has migrated elsewhere or is behind a WAF
513
519
  if is_ip_scan and target_ip_clean and target_ip_clean not in ips:
514
- # Drop foreign domain findings to keep attack surface strict and prevent third-party noise
520
+ # Tag it as WAF Bypass / Origin Match instead of dropping
515
521
  for sub_finding in subdomain_finding_refs.get(sub, []):
516
522
  if sub_finding in raw_recon_findings:
517
- raw_recon_findings.remove(sub_finding)
523
+ sub_finding.metadata["is_waf_bypass"] = True
524
+ # Anchor the finding's host_ip to the TARGET IP (the origin), NOT the CDN IP!
525
+ sub_finding.host_ip = target_ip_clean
518
526
  continue
519
527
 
520
528
  for ip in ips:
@@ -726,10 +734,18 @@ class ThreatTrackEngine:
726
734
  elif f.type == FindingType.SUBDOMAIN and f.value:
727
735
  if f.value not in host_obj.hostnames:
728
736
  host_obj.hostnames.append(f.value)
737
+ if f.metadata.get("is_waf_bypass"):
738
+ waf_list = host_obj.metadata.setdefault("waf_bypassed_domains", [])
739
+ if f.value not in waf_list:
740
+ waf_list.append(f.value)
729
741
 
730
742
  elif f.type == FindingType.ASSOCIATED_DOMAIN and f.value:
731
743
  if f.value not in host_obj.domains:
732
744
  host_obj.domains.append(f.value)
745
+ if f.metadata.get("is_waf_bypass"):
746
+ waf_list = host_obj.metadata.setdefault("waf_bypassed_domains", [])
747
+ if f.value not in waf_list:
748
+ waf_list.append(f.value)
733
749
 
734
750
  # ----------------------------------------------------
735
751
  # Ensure Target Anchor in Hosts Map & Domain Discoveries
@@ -0,0 +1,267 @@
1
+ """SecurityTrails advanced intelligence module (DSL & Anti-WAF)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from detecti.config import settings
10
+ from detecti.core.models import Finding, FindingType, HostInfoData
11
+ from detecti.modules.base import BaseModule
12
+
13
+ logger = logging.getLogger("detecti.securitytrails")
14
+
15
+
16
+ class SecurityTrailsModule(BaseModule):
17
+ """Smart SecurityTrails module for comprehensive attack surface pivoting."""
18
+
19
+ name: str = "securitytrails"
20
+ description: str = "SecurityTrails API for DSL intelligence, WHOIS pivoting, and Anti-WAF Origin IP discovery"
21
+ category: str = "recon"
22
+
23
+ def is_configured(self) -> bool:
24
+ """Check if valid SecurityTrails API key is set."""
25
+ from detecti.config import is_placeholder_key
26
+ return bool(settings.securitytrails_api_key and not is_placeholder_key(settings.securitytrails_api_key))
27
+
28
+ async def _scroll_dsl_query(self, query: str, max_pages: int = 5) -> List[str]:
29
+ """Fetch domains using the SecurityTrails DSL search list API with pagination."""
30
+ headers = {
31
+ "APIKEY": settings.securitytrails_api_key,
32
+ "Accept": "application/json"
33
+ }
34
+ url = "https://api.securitytrails.com/v1/domains/list"
35
+
36
+ all_domains = []
37
+ page = 1
38
+
39
+ while page <= max_pages:
40
+ payload = {"query": query}
41
+
42
+ try:
43
+ # Note: The DSL API uses URL query params for pagination, e.g., ?page=1
44
+ page_url = f"{url}?page={page}"
45
+ resp = await self.http_client.post(
46
+ url=page_url,
47
+ headers=headers,
48
+ json=payload,
49
+ timeout=15.0,
50
+ max_retries=2,
51
+ )
52
+
53
+ # If 404/403, might be wrong endpoint or permissions. We'll fallback to search/list if domains/list fails.
54
+ if resp.status_code == 404:
55
+ page_url = f"https://api.securitytrails.com/v1/search/list?page={page}"
56
+ resp = await self.http_client.post(
57
+ url=page_url,
58
+ headers=headers,
59
+ json=payload,
60
+ timeout=15.0,
61
+ max_retries=2,
62
+ raise_for_status=True
63
+ )
64
+ else:
65
+ resp.raise_for_status()
66
+
67
+ data = resp.json()
68
+ records = data.get("records", [])
69
+ for rec in records:
70
+ hostname = rec.get("hostname")
71
+ if hostname:
72
+ all_domains.append(hostname)
73
+
74
+ meta = data.get("meta", {})
75
+ total_pages = meta.get("max_page", 1)
76
+
77
+ if page >= total_pages:
78
+ break
79
+
80
+ page += 1
81
+ except Exception as e:
82
+ logger.debug(f"SecurityTrails DSL error on page {page}: {e}")
83
+ if hasattr(e, "response") and e.response is not None and e.response.status_code == 403:
84
+ self.notify("SecurityTrails API: DSL feature requires a commercial subscription (403 Forbidden).")
85
+ elif page == 1:
86
+ self.notify(f"SecurityTrails API error: {e}")
87
+ break
88
+
89
+ return all_domains
90
+
91
+ async def _fetch_historical_dns(self, domain: str) -> List[str]:
92
+ """Fetch historical A records to find potential Origin IPs (WAF Bypass)."""
93
+ headers = {
94
+ "APIKEY": settings.securitytrails_api_key,
95
+ "Accept": "application/json"
96
+ }
97
+ url = f"https://api.securitytrails.com/v1/history/{domain}/dns/a"
98
+ ips = set()
99
+
100
+ try:
101
+ resp = await self.http_client.get(
102
+ url=url,
103
+ headers=headers,
104
+ timeout=10.0,
105
+ max_retries=1
106
+ )
107
+ if resp.status_code == 200:
108
+ data = resp.json()
109
+ records = data.get("records", [])
110
+ for rec in records:
111
+ for val in rec.get("values", []):
112
+ ip = val.get("ip")
113
+ if ip:
114
+ ips.add(ip)
115
+ except Exception as e:
116
+ logger.debug(f"Historical DNS error for {domain}: {e}")
117
+
118
+ return list(ips)
119
+
120
+ async def _fetch_subdomains(self, domain: str) -> List[str]:
121
+ """Fetch subdomains for an apex domain."""
122
+ headers = {
123
+ "APIKEY": settings.securitytrails_api_key,
124
+ "Accept": "application/json"
125
+ }
126
+ url = f"https://api.securitytrails.com/v1/domain/{domain}/subdomains"
127
+ subs = []
128
+
129
+ try:
130
+ resp = await self.http_client.get(
131
+ url=url,
132
+ headers=headers,
133
+ timeout=15.0,
134
+ max_retries=2,
135
+ raise_for_status=True
136
+ )
137
+ data = resp.json()
138
+ subdomains = data.get("subdomains", [])
139
+ for sub in subdomains:
140
+ subs.append(f"{sub}.{domain}")
141
+ except Exception as e:
142
+ logger.debug(f"Subdomain fetch error for {domain}: {e}")
143
+
144
+ return subs
145
+
146
+ async def run(
147
+ self,
148
+ target: str,
149
+ context: Optional[Dict[str, Any]] = None,
150
+ ) -> List[Finding]:
151
+ """Execute SecurityTrails smart queries based on target input type."""
152
+ if not self.is_configured():
153
+ logger.warning("SecurityTrails API key is not configured. Skipping module.")
154
+ return []
155
+
156
+ target = target.strip()
157
+ context = context or {}
158
+ target_type = context.get("target_type", "domain")
159
+ findings: List[Finding] = []
160
+
161
+ # ----------------------------------------------------
162
+ # Routing Logic based on Target Type
163
+ # ----------------------------------------------------
164
+
165
+ if target_type == "ip":
166
+ self.notify(f"Querying SecurityTrails Reverse IP (DSL) for {target}...")
167
+ # Detect if it's a subnet (CIDR) or single IP
168
+ if "/" in target:
169
+ query = f"ipv4 = '{target}'"
170
+ else:
171
+ query = f"ipv4 = '{target}'"
172
+
173
+ domains = await self._scroll_dsl_query(query)
174
+ if domains:
175
+ self.notify(f"Found {len(domains)} domains hosted on {target} via SecurityTrails.")
176
+ for d in domains:
177
+ findings.append(Finding(
178
+ type=FindingType.SUBDOMAIN if context.get("root_domain") and d.endswith(context.get("root_domain", "")) else FindingType.ASSOCIATED_DOMAIN,
179
+ target=target,
180
+ value=d,
181
+ source="SecurityTrails",
182
+ host_ip=target.split("/")[0], # map back to target if it's an IP
183
+ metadata={"description": f"SecurityTrails Reverse IP mapping to {target}"}
184
+ ))
185
+
186
+ elif target_type == "email":
187
+ self.notify(f"Querying Reverse WHOIS for {target} via SecurityTrails...")
188
+ query = f"whois_email = '{target}'"
189
+ domains = await self._scroll_dsl_query(query)
190
+ if domains:
191
+ self.notify(f"Found {len(domains)} domains registered by {target}.")
192
+ for d in domains:
193
+ findings.append(Finding(
194
+ type=FindingType.ASSOCIATED_DOMAIN,
195
+ target=target,
196
+ value=d,
197
+ source="SecurityTrails Reverse WHOIS",
198
+ metadata={"description": f"Domain registered by {target}"}
199
+ ))
200
+
201
+ elif target_type == "org":
202
+ self.notify(f"Querying Corporate Assets for {target} via SecurityTrails...")
203
+ query = f"whois_organization = '{target}' OR whois_name = '{target}'"
204
+ domains = await self._scroll_dsl_query(query)
205
+ if domains:
206
+ self.notify(f"Found {len(domains)} domains owned by organization {target}.")
207
+ for d in domains:
208
+ findings.append(Finding(
209
+ type=FindingType.ASSOCIATED_DOMAIN,
210
+ target=target,
211
+ value=d,
212
+ source="SecurityTrails Org Recon",
213
+ metadata={"description": f"Asset owned by {target}"}
214
+ ))
215
+
216
+ elif target_type == "asn":
217
+ self.notify(f"Querying ASN Footprint for {target} via SecurityTrails...")
218
+ asn_num = target.replace("asn:", "").replace("AS", "").strip()
219
+ query = f"asn = '{asn_num}'"
220
+ domains = await self._scroll_dsl_query(query)
221
+ if domains:
222
+ self.notify(f"Found {len(domains)} domains hosted in AS{asn_num}.")
223
+ for d in domains:
224
+ findings.append(Finding(
225
+ type=FindingType.ASSOCIATED_DOMAIN,
226
+ target=target,
227
+ value=d,
228
+ source="SecurityTrails ASN Mapping",
229
+ metadata={"description": f"Domain hosted within AS{asn_num}"}
230
+ ))
231
+
232
+ elif target_type in ("domain", "subdomain"):
233
+ self.notify(f"Running multi-layered SecurityTrails recon for {target}...")
234
+
235
+ # Fire both Subdomain Enumeration and Historical DNS concurrently
236
+ subs_task = asyncio.create_task(self._fetch_subdomains(target))
237
+ hist_task = asyncio.create_task(self._fetch_historical_dns(target))
238
+
239
+ subs, hist_ips = await asyncio.gather(subs_task, hist_task, return_exceptions=False)
240
+
241
+ if subs:
242
+ self.notify(f"Found {len(subs)} subdomains for {target} via SecurityTrails.")
243
+ for sub in subs:
244
+ findings.append(Finding(
245
+ type=FindingType.SUBDOMAIN,
246
+ target=target,
247
+ value=sub,
248
+ source="SecurityTrails",
249
+ metadata={"description": f"SecurityTrails discovered subdomain {sub}"}
250
+ ))
251
+
252
+ if hist_ips:
253
+ self.notify(f"Found {len(hist_ips)} historical IPs for {target} (Anti-WAF).")
254
+ for ip in hist_ips:
255
+ # Tag this specifically as a Historical IP
256
+ finding = Finding(
257
+ type=FindingType.HOST_INFO,
258
+ target=target,
259
+ value=ip,
260
+ source="SecurityTrails Historical DNS",
261
+ host_ip=ip,
262
+ host_info=HostInfoData(ip=ip, hostnames=[target]),
263
+ metadata={"description": f"Historical Origin IP mapped for {target}", "tags": ["Historical IP", "WAF Bypass Candidate"]}
264
+ )
265
+ findings.append(finding)
266
+
267
+ return findings