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.
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/PKG-INFO +1 -1
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/cli.py +12 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/core/database/schema.py +2 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/core/database/storage.py +50 -16
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/core/engine.py +19 -3
- detecti_cli-2.1.0/detecti/modules/securitytrails.py +267 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/api/graph_builder.py +63 -13
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/js/graph.js +53 -21
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/pyproject.toml +1 -1
- detecti_cli-2.0.12/detecti/modules/securitytrails.py +0 -125
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/.env.example +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/.github/workflows/publish.yml +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/.gitignore +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/README.md +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/__init__.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/__main__.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/__init__.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/banner +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/config.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/core/__init__.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/core/database/__init__.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/core/database/config_db.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/core/models.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/data/config.sqlite +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/data/dbs/.gitkeep +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/data/dbs/example.com.sqlite +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/modules/__init__.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/modules/base.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/modules/censys.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/modules/crtsh.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/modules/exploitdb.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/modules/masscan.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/modules/nuclei.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/modules/nvd.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/modules/reverse_whois.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/modules/shodan.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/reporters/__init__.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/reporters/csv_reporter.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/reporters/html_reporter.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/reporters/json_reporter.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/reporters/markdown_reporter.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/utils/__init__.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/utils/http.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/utils/logger.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/utils/setup.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/utils/updater.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/__init__.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/api/__init__.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/api/auth.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/api/routes.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/process_manager.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/server.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/android-chrome-192x192.png +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/android-chrome-512x512.png +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/apple-touch-icon.png +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/css/__init__.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/css/dashboard.css +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/favicon-16x16.png +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/favicon-32x32.png +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/favicon.ico +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/img/DetecTI_Security_Logo.png +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/img/detecti-ico.png +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/index.html +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/js/__init__.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/js/api.js +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/js/cytoscape-cose-bilkent.js +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/js/cytoscape-dagre.js +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/js/cytoscape.min.js +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/js/dagre.min.js +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/js/lucide.min.js +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/login.html +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/detecti/web/static/site.webmanifest +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/requirements.txt +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/tests/__init__.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/tests/conftest.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/tests/test_engine_cli.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/tests/test_http.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/tests/test_masscan.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/tests/test_models.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/tests/test_modules.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/tests/test_nuclei.py +0 -0
- {detecti_cli-2.0.12 → detecti_cli-2.1.0}/tests/test_reporters.py +0 -0
|
@@ -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
|
|
552
|
-
VALUES (?, ?)
|
|
553
|
-
|
|
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
|
|
570
|
-
VALUES (?, ?)
|
|
571
|
-
|
|
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
|
|
581
|
-
VALUES (?, ?)
|
|
582
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|