detecti-cli 2.0.0__py3-none-any.whl

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 (64) hide show
  1. detecti/__init__.py +0 -0
  2. detecti/cli.py +649 -0
  3. detecti/config.py +188 -0
  4. detecti/core/__init__.py +1 -0
  5. detecti/core/database/__init__.py +5 -0
  6. detecti/core/database/config_db.py +73 -0
  7. detecti/core/database/schema.py +136 -0
  8. detecti/core/database/storage.py +1388 -0
  9. detecti/core/engine.py +1032 -0
  10. detecti/core/models.py +278 -0
  11. detecti/data/config.sqlite +0 -0
  12. detecti/data/dbs/.gitkeep +2 -0
  13. detecti/data/dbs/example.com.sqlite +0 -0
  14. detecti/modules/__init__.py +29 -0
  15. detecti/modules/base.py +57 -0
  16. detecti/modules/censys.py +813 -0
  17. detecti/modules/crtsh.py +98 -0
  18. detecti/modules/exploitdb.py +138 -0
  19. detecti/modules/masscan.py +561 -0
  20. detecti/modules/nuclei.py +449 -0
  21. detecti/modules/nvd.py +300 -0
  22. detecti/modules/reverse_whois.py +225 -0
  23. detecti/modules/shodan.py +412 -0
  24. detecti/reporters/__init__.py +7 -0
  25. detecti/reporters/csv_reporter.py +74 -0
  26. detecti/reporters/html_reporter.py +356 -0
  27. detecti/reporters/json_reporter.py +26 -0
  28. detecti/reporters/markdown_reporter.py +203 -0
  29. detecti/utils/__init__.py +1 -0
  30. detecti/utils/http.py +294 -0
  31. detecti/utils/logger.py +378 -0
  32. detecti/utils/setup.py +453 -0
  33. detecti/web/__init__.py +6 -0
  34. detecti/web/api/__init__.py +1 -0
  35. detecti/web/api/auth.py +109 -0
  36. detecti/web/api/graph_builder.py +901 -0
  37. detecti/web/api/routes.py +1602 -0
  38. detecti/web/process_manager.py +283 -0
  39. detecti/web/server.py +183 -0
  40. detecti/web/static/android-chrome-192x192.png +0 -0
  41. detecti/web/static/android-chrome-512x512.png +0 -0
  42. detecti/web/static/apple-touch-icon.png +0 -0
  43. detecti/web/static/css/__init__.py +1 -0
  44. detecti/web/static/css/dashboard.css +3802 -0
  45. detecti/web/static/favicon-16x16.png +0 -0
  46. detecti/web/static/favicon-32x32.png +0 -0
  47. detecti/web/static/favicon.ico +0 -0
  48. detecti/web/static/img/DetecTI_Security_Logo.png +0 -0
  49. detecti/web/static/img/detecti-ico.png +0 -0
  50. detecti/web/static/index.html +677 -0
  51. detecti/web/static/js/__init__.py +1 -0
  52. detecti/web/static/js/api.js +177 -0
  53. detecti/web/static/js/cytoscape-cose-bilkent.js +458 -0
  54. detecti/web/static/js/cytoscape-dagre.js +397 -0
  55. detecti/web/static/js/cytoscape.min.js +31 -0
  56. detecti/web/static/js/dagre.min.js +3809 -0
  57. detecti/web/static/js/graph.js +7439 -0
  58. detecti/web/static/js/lucide.min.js +12 -0
  59. detecti/web/static/login.html +290 -0
  60. detecti/web/static/site.webmanifest +1 -0
  61. detecti_cli-2.0.0.dist-info/METADATA +554 -0
  62. detecti_cli-2.0.0.dist-info/RECORD +64 -0
  63. detecti_cli-2.0.0.dist-info/WHEEL +4 -0
  64. detecti_cli-2.0.0.dist-info/entry_points.txt +3 -0
detecti/core/engine.py ADDED
@@ -0,0 +1,1032 @@
1
+ """Core Asynchronous Orchestration and Intelligence Correlation Engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import ipaddress
7
+ import logging
8
+ import time
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any, Callable, Dict, List, Optional, Set
12
+
13
+ import httpx
14
+ import tldextract
15
+ from detecti.config import settings
16
+
17
+ from detecti.core.models import (
18
+ Finding,
19
+ FindingType,
20
+ HostInfoData,
21
+ HostResult,
22
+ PortData,
23
+ ScanResult,
24
+ SeverityLevel,
25
+ VulnerabilityData,
26
+ )
27
+ from detecti.modules.base import BaseModule
28
+ from detecti.modules.censys import CensysModule
29
+ from detecti.modules.crtsh import CrtshModule
30
+ from detecti.modules.exploitdb import ExploitDBModule
31
+ from detecti.modules.nvd import NVDModule
32
+ from detecti.modules.reverse_whois import ReverseWhoisModule
33
+ from detecti.modules.shodan import ShodanModule
34
+ from detecti.utils.http import AsyncHTTPClient, http_client
35
+
36
+ logger = logging.getLogger("detecti.engine")
37
+
38
+
39
+ def _clean_source(src: str) -> str:
40
+ """Normalize source names (e.g. 'Censys Platform v3' -> 'Censys', 'Shodan DNS' -> 'Shodan')."""
41
+ if not src:
42
+ return "Unknown"
43
+ lower = src.lower()
44
+ if "shodan" in lower:
45
+ return "Shodan"
46
+ if "censys" in lower:
47
+ return "Censys"
48
+ if "crtsh" in lower or "crt.sh" in lower:
49
+ return "crt.sh"
50
+ if "whois" in lower:
51
+ return "Reverse WHOIS"
52
+ return src
53
+
54
+
55
+ class ThreatTrackEngine:
56
+ """Async execution runner orchestrating modules, target classification, and correlation."""
57
+
58
+ MODULE_REGISTRY: Dict[str, type[BaseModule]] = {
59
+ "shodan": ShodanModule,
60
+ "censys": CensysModule,
61
+ "crtsh": CrtshModule,
62
+ "reverse_whois": ReverseWhoisModule,
63
+ "nvd": NVDModule,
64
+ "exploitdb": ExploitDBModule,
65
+ }
66
+
67
+ def __init__(
68
+ self,
69
+ client: Optional['AsyncHTTPClient'] = None,
70
+ progress_callback: Optional[Callable[[str, str], None]] = None,
71
+ db_manager = None,
72
+ ):
73
+ self.http_client = client or http_client
74
+ self.progress_callback = progress_callback
75
+ self.db_manager = db_manager
76
+ self.current_input_target: Optional[str] = None
77
+ self.modules: Dict[str, BaseModule] = {
78
+ name: cls(client=self.http_client, progress_callback=self._notify)
79
+ for name, cls in self.MODULE_REGISTRY.items()
80
+ }
81
+
82
+ def _notify(self, module_name: str, message: str) -> None:
83
+ if self.progress_callback:
84
+ self.progress_callback(module_name, message)
85
+ logger.info(f"[{module_name}] {message}")
86
+ if self.db_manager:
87
+ level = "error" if "error" in message.lower() or "fail" in message.lower() else "info"
88
+ self.db_manager.add_scan_log(level=level, message=f"[{module_name}] {message}", input_target=self.current_input_target)
89
+
90
+ def parse_target_metadata(self, target: str) -> Dict[str, Any]:
91
+ """Extract canonical target type, cleaned host/domain, port, and root domain supporting full URLs and subdomains."""
92
+ raw = target.strip()
93
+
94
+ KNOWN_FILE_EXTENSIONS = {
95
+ ".txt", ".list", ".csv", ".targets", ".ips", ".log", ".json", ".yaml", ".yml", ".conf", ".cfg"
96
+ }
97
+ target_path = Path(raw)
98
+ is_file_like = (
99
+ target_path.suffix.lower() in KNOWN_FILE_EXTENSIONS
100
+ or raw.startswith(("./", "../", "/", "~/"))
101
+ or "\\" in raw
102
+ or target_path.is_file()
103
+ )
104
+ if is_file_like and not raw.startswith("http"):
105
+ if not target_path.is_file():
106
+ raise FileNotFoundError(f"Target file not found: {raw}")
107
+ return {"type": "file", "clean_target": raw, "root_domain": None, "subdomain": None, "port": None}
108
+
109
+ if raw.upper().startswith("CVE-"):
110
+ return {"type": "cve", "clean_target": raw.upper(), "root_domain": None, "subdomain": None, "port": None}
111
+
112
+ if "@" in raw and " " not in raw and "://" not in raw:
113
+ domain_part = raw.split("@", 1)[1] if "@" in raw else None
114
+ return {"type": "email", "clean_target": raw, "root_domain": domain_part, "subdomain": None, "port": None}
115
+
116
+ clean = raw
117
+ if clean.startswith("host:"):
118
+ clean = clean[5:].strip()
119
+ elif clean.startswith("domain:"):
120
+ clean = clean[7:].strip()
121
+
122
+ port: Optional[int] = None
123
+ if "://" in clean or "/" in clean or (":" in clean and " " not in clean):
124
+ # Check if valid CIDR network
125
+ try:
126
+ ipaddress.ip_network(clean, strict=False)
127
+ return {"type": "cidr", "clean_target": clean, "root_domain": None, "subdomain": None, "port": None}
128
+ except ValueError:
129
+ pass
130
+
131
+ # Parse as URL / Host:Port
132
+ url_candidate = clean if "://" in clean else f"http://{clean}"
133
+ try:
134
+ parsed = httpx.URL(url_candidate)
135
+ extracted_host = parsed.host
136
+ if parsed.port:
137
+ port = parsed.port
138
+ clean = extracted_host or clean
139
+ except Exception:
140
+ pass
141
+
142
+ if "/" in clean:
143
+ clean = clean.split("/")[0]
144
+ if ":" in clean:
145
+ parts = clean.split(":")
146
+ clean = parts[0]
147
+ try:
148
+ port = int(parts[1])
149
+ except ValueError:
150
+ pass
151
+
152
+ clean = clean.strip().lower()
153
+
154
+ # Check IP
155
+ try:
156
+ ipaddress.ip_address(clean)
157
+ return {"type": "ip", "clean_target": clean, "root_domain": None, "subdomain": None, "port": port}
158
+ except ValueError:
159
+ pass
160
+
161
+ # Check Domain / Subdomain with tldextract
162
+ import tldextract
163
+ ext = tldextract.extract(clean)
164
+ if ext.domain and ext.suffix and " " not in clean:
165
+ root_domain = ext.registered_domain or f"{ext.domain}.{ext.suffix}"
166
+ subdomain = ext.subdomain if ext.subdomain else None
167
+ return {
168
+ "type": "domain",
169
+ "clean_target": clean,
170
+ "root_domain": root_domain,
171
+ "subdomain": subdomain,
172
+ "is_subdomain": bool(subdomain),
173
+ "port": port,
174
+ }
175
+
176
+ # Check Shodan Search Queries / Dorks
177
+ SHODAN_DORK_KEYWORDS = (
178
+ "org:", "product:", "port:", "city:", "country:", "ssl:", "os:",
179
+ "title:", "html:", "asn:", "net:", "has_vuln:", "vuln:", "tag:",
180
+ "http.title:", "http.html:", "http.status:", "cloud.provider:",
181
+ "host:", "domain:", "query:", "search:"
182
+ )
183
+ raw_lower = raw.lower()
184
+ if any(kw in raw_lower for kw in SHODAN_DORK_KEYWORDS) or (" " in raw and ":" in raw):
185
+ query_val = raw
186
+ if query_val.lower().startswith("query:") or query_val.lower().startswith("search:"):
187
+ query_val = query_val.split(":", 1)[1].strip()
188
+ return {"type": "query", "clean_target": query_val, "root_domain": None, "subdomain": None, "port": None}
189
+
190
+ # If it is not an IP, CIDR, Domain with valid TLD, CVE, Email, existing File, or valid Query Dork -> Invalid Target
191
+ raise ValueError(
192
+ f"Invalid target or file not found: '{raw}'. "
193
+ f"Target must be a valid IP, CIDR, Domain, URL, CVE, existing File, or Shodan Query filter (e.g., org:'Target', port:443)."
194
+ )
195
+
196
+ def classify_target(self, target: str) -> str:
197
+ """Identify target classification (ip, cidr, domain, cve, query, file, email, invalid)."""
198
+ try:
199
+ meta = self.parse_target_metadata(target)
200
+ return meta.get("type", "invalid")
201
+ except Exception:
202
+ return "invalid"
203
+
204
+ async def verify_environment_apis(self, enabled_modules: Optional[List[str]] = None) -> Dict[str, Dict[str, Any]]:
205
+ """Verify API keys present in the environment/config and perform non-blocking pre-flight checks."""
206
+ active_mod_names = (
207
+ [m for m in enabled_modules if m in self.modules]
208
+ if enabled_modules and "all" not in enabled_modules
209
+ else list(self.modules.keys())
210
+ )
211
+
212
+ status_report: Dict[str, Dict[str, Any]] = {}
213
+
214
+ # 1. Shodan
215
+ if "shodan" in active_mod_names:
216
+ shodan_mod: ShodanModule = self.modules["shodan"] # type: ignore
217
+ if shodan_mod.is_configured():
218
+ is_valid, status_msg = await shodan_mod.validate_credentials_detailed()
219
+ status_report["shodan"] = {
220
+ "name": "Shodan",
221
+ "configured": True,
222
+ "valid": is_valid,
223
+ "status": status_msg if not is_valid else "Active & Valid",
224
+ "tier": "Standard API",
225
+ }
226
+ else:
227
+ status_report["shodan"] = {
228
+ "name": "Shodan",
229
+ "configured": False,
230
+ "valid": False,
231
+ "status": "Not Configured (Required for Shodan recon)",
232
+ "tier": "None",
233
+ }
234
+
235
+ # 2. Censys
236
+ if "censys" in active_mod_names:
237
+ censys_mod: CensysModule = self.modules["censys"] # type: ignore
238
+ if censys_mod.is_configured():
239
+ is_valid, status_msg = await censys_mod.validate_credentials_detailed()
240
+ status_report["censys"] = {
241
+ "name": "Censys",
242
+ "configured": True,
243
+ "valid": is_valid,
244
+ "status": status_msg if not is_valid else "Active & Valid",
245
+ "tier": "PAT Token" if censys_mod.pat_token else "Legacy API",
246
+ }
247
+ else:
248
+ status_report["censys"] = {
249
+ "name": "Censys",
250
+ "configured": False,
251
+ "valid": False,
252
+ "status": "Not Configured / Placeholder (Bypassed)",
253
+ "tier": "None",
254
+ }
255
+
256
+ # 3. NVD
257
+ if "nvd" in active_mod_names:
258
+ nvd_mod: NVDModule = self.modules["nvd"] # type: ignore
259
+ if nvd_mod.is_configured():
260
+ status_report["nvd"] = {
261
+ "name": "NVD (NIST)",
262
+ "configured": True,
263
+ "valid": True,
264
+ "status": "Active (High-speed 0.6s rate limit)",
265
+ "tier": "API Key",
266
+ }
267
+ else:
268
+ status_report["nvd"] = {
269
+ "name": "NVD (NIST)",
270
+ "configured": False,
271
+ "valid": True,
272
+ "status": "Active (Public mode, 6.0s rate limit)",
273
+ "tier": "Free / Public",
274
+ }
275
+
276
+ # 4. WhoisFreaks / Reverse WHOIS
277
+ if "reverse_whois" in active_mod_names:
278
+ whois_mod: ReverseWhoisModule = self.modules["reverse_whois"] # type: ignore
279
+ if whois_mod.is_configured():
280
+ status_report["reverse_whois"] = {
281
+ "name": "WhoisFreaks",
282
+ "configured": True,
283
+ "valid": True,
284
+ "status": "Active (WhoisFreaks API)",
285
+ "tier": "Paid API",
286
+ }
287
+ else:
288
+ status_report["reverse_whois"] = {
289
+ "name": "WhoisFreaks",
290
+ "configured": False,
291
+ "valid": True,
292
+ "status": "Active (HackerTarget / RDAP Fallback)",
293
+ "tier": "Free OSINT",
294
+ }
295
+
296
+ # 5. ExploitDB / GitHub Token
297
+ if "exploitdb" in active_mod_names:
298
+ from config import is_placeholder_key
299
+ has_gh = bool(settings.github_token and not is_placeholder_key(settings.github_token))
300
+ status_report["exploitdb"] = {
301
+ "name": "ExploitDB / GitHub PoCs",
302
+ "configured": has_gh,
303
+ "valid": True,
304
+ "status": "Active (Local ExploitDB + Authenticated GitHub PoCs)" if has_gh else "Active (Local ExploitDB + Public PoC API)",
305
+ "tier": "GitHub Token" if has_gh else "Public PoC API",
306
+ }
307
+
308
+ return status_report
309
+
310
+ async def scan(
311
+ self,
312
+ target: str,
313
+ enabled_modules: Optional[List[str]] = None,
314
+ cvss_filter: Optional[str] = None,
315
+ skip_preflight: bool = False,
316
+ ) -> ScanResult:
317
+ self.current_input_target = target
318
+ start_time = time.monotonic()
319
+ meta = self.parse_target_metadata(target)
320
+ target_type = meta["type"]
321
+ clean_target = meta["clean_target"]
322
+ root_domain = meta.get("root_domain")
323
+ target_port = meta.get("port")
324
+
325
+ # Handle file input containing multiple targets
326
+ if target_type == "file":
327
+ return await self._scan_file(target, enabled_modules, cvss_filter)
328
+
329
+ active_mod_names = (
330
+ [m for m in enabled_modules if m in self.modules]
331
+ if enabled_modules and "all" not in enabled_modules
332
+ else list(self.modules.keys())
333
+ )
334
+
335
+ # Pre-flight API verification layer: validate all APIs present in environment/config (if not skipped)
336
+ if not skip_preflight:
337
+ self._notify("engine", "Verifying environment API credentials and endpoints...")
338
+ api_statuses = await self.verify_environment_apis(active_mod_names)
339
+ for mod, info in api_statuses.items():
340
+ if info.get("configured") and not info.get("valid"):
341
+ self._notify(mod, f"{info.get('name')}: {info.get('status')}")
342
+
343
+ result = ScanResult(
344
+ target=clean_target,
345
+ target_type=target_type,
346
+ started_at=datetime.now(timezone.utc),
347
+ modules_run=active_mod_names,
348
+ )
349
+
350
+ context: Dict[str, Any] = {
351
+ "target": clean_target,
352
+ "raw_target": target,
353
+ "target_type": target_type,
354
+ "root_domain": root_domain,
355
+ "port": target_port,
356
+ "cves": set(),
357
+ "warnings": [],
358
+ }
359
+ raw_recon_findings: List[Finding] = []
360
+
361
+ # Direct DNS Resolution for Domain / Subdomain / URL targets
362
+ if target_type == "domain":
363
+ try:
364
+ addr_info = await asyncio.get_event_loop().getaddrinfo(clean_target, None)
365
+ resolved_ips = {res[4][0] for res in addr_info if res and len(res) > 4 and res[4]}
366
+ for rip in resolved_ips:
367
+ raw_recon_findings.append(
368
+ Finding(
369
+ type=FindingType.HOST_INFO,
370
+ target=clean_target,
371
+ value=rip,
372
+ source="DNS Resolution",
373
+ host_ip=rip,
374
+ host_info=HostInfoData(
375
+ ip=rip,
376
+ hostnames=[clean_target],
377
+ domains=[root_domain] if root_domain else [clean_target],
378
+ ),
379
+ )
380
+ )
381
+ except Exception as exc:
382
+ logger.debug(f"Direct DNS resolution for {clean_target}: {exc}")
383
+
384
+ # ----------------------------------------------------
385
+ # Stage 1: Recon & Discovery (Shodan Primary Query, Censys, crt.sh, Reverse WHOIS)
386
+ # ----------------------------------------------------
387
+ recon_tasks = []
388
+ is_direct_ip = (target_type == "ip")
389
+ has_shodan = "shodan" in active_mod_names and self.modules["shodan"].is_configured()
390
+ has_censys = "censys" in active_mod_names and self.modules["censys"].is_configured()
391
+
392
+ if target_type == "cve":
393
+ context["cves"].add(clean_target.upper())
394
+ else:
395
+ # 1. Shodan Search / Host Lookup
396
+ if has_shodan:
397
+ self._notify("shodan", f"Querying Shodan for {clean_target}...")
398
+ recon_tasks.append(self.modules["shodan"].run(clean_target, context))
399
+
400
+ # 2. Censys Direct Host Lookup (Executed in Stage 1 for direct IP targets, or as fallback if Shodan is not configured)
401
+ if has_censys and (is_direct_ip or not has_shodan):
402
+ self._notify("censys", f"Querying Censys for {clean_target}...")
403
+ recon_tasks.append(self.modules["censys"].run(clean_target, context))
404
+
405
+ # 3. Certificate Transparency (Uses root domain to capture full subdomain hierarchy)
406
+ if target_type in ("domain", "email") and "crtsh" in active_mod_names:
407
+ query_dom = root_domain or clean_target
408
+ self._notify("crtsh", f"Querying Certificate Transparency for {query_dom}...")
409
+ recon_tasks.append(self.modules["crtsh"].run(query_dom, context))
410
+
411
+ # 4. Reverse WHOIS
412
+ if target_type in ("domain", "ip", "email") and "reverse_whois" in active_mod_names:
413
+ query_whois = root_domain or clean_target
414
+ self._notify("reverse_whois", f"Performing Reverse WHOIS lookup for {query_whois}...")
415
+ recon_tasks.append(self.modules["reverse_whois"].run(query_whois, context))
416
+
417
+ if recon_tasks:
418
+ recon_results = await asyncio.gather(*recon_tasks, return_exceptions=True)
419
+ for res in recon_results:
420
+ if isinstance(res, list):
421
+ raw_recon_findings.extend(res)
422
+ elif isinstance(res, Exception):
423
+ logger.error(f"Error during recon stage: {res}")
424
+
425
+ # ----------------------------------------------------
426
+ # Scope Governance: Determine Organization / ASN Scope Filters
427
+ # ----------------------------------------------------
428
+ target_org = None
429
+ target_asn = None
430
+ clean_lower = clean_target.lower()
431
+ if "org:" in clean_lower:
432
+ import re
433
+ m = re.search(r'org:\s*["\']?([^"\']+)["\']?', clean_target, re.IGNORECASE)
434
+ if m:
435
+ target_org = m.group(1).strip()
436
+ if "asn:" in clean_lower:
437
+ import re
438
+ m = re.search(r'asn:\s*["\']?([^"\']+)["\']?', clean_target, re.IGNORECASE)
439
+ if m:
440
+ target_asn = m.group(1).strip().upper()
441
+
442
+ initial_org_ips = set()
443
+ if target_org or target_asn:
444
+ for f in raw_recon_findings:
445
+ if f.host_ip and "shodan" in f.source.lower():
446
+ initial_org_ips.add(f.host_ip)
447
+
448
+ # ----------------------------------------------------
449
+ # Stage 1.2: Subdomain & Domain DNS Resolution & Recursive IP Mapping
450
+ # (Resolves all subdomains and associated domains discovered via crt.sh/WHOIS/Shodan to their active A/AAAA IPs)
451
+ # ----------------------------------------------------
452
+ discovered_subdomains: Set[str] = set()
453
+ subdomain_finding_refs: Dict[str, List[Finding]] = {}
454
+ for f in raw_recon_findings:
455
+ if f.type in (FindingType.SUBDOMAIN, FindingType.ASSOCIATED_DOMAIN) and f.value:
456
+ sub_val = f.value.strip().lower()
457
+ if sub_val.startswith("*."):
458
+ sub_val = sub_val[2:]
459
+ if sub_val and "." in sub_val and " " not in sub_val and not sub_val.startswith("@"):
460
+ # Scope enforcement for domain targets: Only resolve and expand subdomains belonging to the target domain hierarchy
461
+ if target_type == "domain" and root_domain:
462
+ if not (sub_val == root_domain or sub_val.endswith(f".{root_domain}")):
463
+ continue
464
+ discovered_subdomains.add(sub_val)
465
+ subdomain_finding_refs.setdefault(sub_val, []).append(f)
466
+
467
+ if discovered_subdomains and target_type != "cve":
468
+ self._notify("engine", f"Resolving DNS A-records for {len(discovered_subdomains)} discovered domains & subdomains...")
469
+
470
+ dns_semaphore = asyncio.Semaphore(50)
471
+ loop = asyncio.get_event_loop()
472
+
473
+ async def _resolve_subdomain(sub: str) -> tuple[str, Set[str]]:
474
+ async with dns_semaphore:
475
+ try:
476
+ addr_info = await loop.getaddrinfo(sub, None)
477
+ ips = {res[4][0] for res in addr_info if res and len(res) > 4 and res[4]}
478
+ return sub, ips
479
+ except Exception:
480
+ return sub, set()
481
+
482
+ resolve_results = await asyncio.gather(*[_resolve_subdomain(s) for s in discovered_subdomains])
483
+
484
+ # Authoritative IP Target Gating: If target is a direct IP, only accept domains whose live DNS resolves back to target IP!
485
+ is_ip_scan = (target_type == "ip")
486
+ target_ip_clean = clean_target if is_ip_scan else None
487
+
488
+ for sub, ips in resolve_results:
489
+ if ips:
490
+ # In an IP scan, if the resolved IPs do NOT include the target IP, this domain has migrated elsewhere
491
+ if is_ip_scan and target_ip_clean and target_ip_clean not in ips:
492
+ # Drop foreign domain findings to keep attack surface strict and prevent third-party noise
493
+ for sub_finding in subdomain_finding_refs.get(sub, []):
494
+ if sub_finding in raw_recon_findings:
495
+ raw_recon_findings.remove(sub_finding)
496
+ continue
497
+
498
+ for ip in ips:
499
+ # Scope enforcement: If target is an Organization or ASN, ignore foreign resolved IPs!
500
+ if (target_org or target_asn) and ip not in initial_org_ips:
501
+ continue
502
+
503
+ # In IP scan, only inject host info for the target IP itself
504
+ if is_ip_scan and target_ip_clean and ip != target_ip_clean:
505
+ continue
506
+
507
+ raw_recon_findings.append(
508
+ Finding(
509
+ type=FindingType.HOST_INFO,
510
+ target=sub,
511
+ value=ip,
512
+ source="DNS Resolution",
513
+ host_ip=ip,
514
+ host_info=HostInfoData(
515
+ ip=ip,
516
+ hostnames=[sub],
517
+ domains=[root_domain] if root_domain else [sub],
518
+ ),
519
+ )
520
+ )
521
+ for sub_finding in subdomain_finding_refs.get(sub, []):
522
+ if not sub_finding.host_ip:
523
+ sub_finding.host_ip = ip
524
+
525
+ # ----------------------------------------------------
526
+ # Stage 1.3: Recursive Threat Intelligence Feedback Loop (Shodan & Censys)
527
+ # (Feeds all resolved subdomain IPs back into Shodan & Censys for full port, service, banner & CVE discovery)
528
+ # ----------------------------------------------------
529
+
530
+ target_scopes: Set[str] = set()
531
+ if root_domain:
532
+ target_scopes.add(root_domain.lower())
533
+ if target_type == "domain":
534
+ target_scopes.add(clean_target.lower())
535
+
536
+ if (has_shodan or has_censys) and target_type != "cve":
537
+ all_known_ips = set()
538
+ for f in raw_recon_findings:
539
+ hip = f.host_ip or (f.host_info.ip if f.host_info else None)
540
+ if hip:
541
+ if target_org or target_asn:
542
+ if hip in initial_org_ips:
543
+ all_known_ips.add(hip)
544
+ else:
545
+ all_known_ips.add(hip)
546
+
547
+ # 1. Shodan Recursive Host Enrichment
548
+ if has_shodan and all_known_ips:
549
+ already_queried_shodan_ips = {
550
+ f.host_ip for f in raw_recon_findings
551
+ if f.host_ip and "shodan" in f.source.lower() and f.type == FindingType.HOST_INFO
552
+ }
553
+ shodan_enrich_ips = [ip for ip in all_known_ips if ip not in already_queried_shodan_ips]
554
+
555
+ if shodan_enrich_ips:
556
+ self._notify("shodan", f"Retrofeeding {len(shodan_enrich_ips)} resolved subdomain IPs to Shodan for port & CVE profiling...")
557
+ shodan_mod: ShodanModule = self.modules["shodan"] # type: ignore
558
+ shodan_tasks = [shodan_mod.get_host_info(ip) for ip in shodan_enrich_ips]
559
+ shodan_results = await asyncio.gather(*shodan_tasks, return_exceptions=True)
560
+ for res in shodan_results:
561
+ if isinstance(res, list):
562
+ for f in res:
563
+ if target_org and f.host_info and f.host_info.org:
564
+ if target_org.lower() not in f.host_info.org.lower():
565
+ continue
566
+ if target_asn and f.host_info and f.host_info.asn:
567
+ if target_asn.lower() not in f.host_info.asn.lower():
568
+ continue
569
+ if f.host_info and f.host_info.hostnames and target_scopes:
570
+ f.host_info.hostnames = [h for h in f.host_info.hostnames if any(h.lower() == s or h.lower().endswith(f".{s}") for s in target_scopes)]
571
+ if f.host_info and f.host_info.domains and target_scopes:
572
+ f.host_info.domains = [d for d in f.host_info.domains if any(d.lower() == s or d.lower().endswith(f".{s}") for s in target_scopes)]
573
+ raw_recon_findings.append(f)
574
+ elif isinstance(res, Exception):
575
+ logger.debug(f"Shodan recursive enrichment exception: {res}")
576
+
577
+ # 2. Censys Recursive Host Enrichment
578
+ if has_censys and all_known_ips:
579
+ already_queried_censys_ips = {
580
+ f.host_ip for f in raw_recon_findings
581
+ if f.host_ip and "censys" in f.source.lower() and f.type == FindingType.HOST_INFO
582
+ }
583
+ censys_enrich_ips = [ip for ip in all_known_ips if ip not in already_queried_censys_ips]
584
+
585
+ if censys_enrich_ips:
586
+ self._notify("censys", f"Enriching {len(censys_enrich_ips)} discovered host IPs with Censys port & service dossiers...")
587
+ censys_mod: CensysModule = self.modules["censys"] # type: ignore
588
+ censys_tasks = [censys_mod.get_host_info(ip) for ip in censys_enrich_ips]
589
+ censys_results = await asyncio.gather(*censys_tasks, return_exceptions=True)
590
+ for res in censys_results:
591
+ if isinstance(res, list):
592
+ for f in res:
593
+ if target_org and f.host_info and f.host_info.org:
594
+ if target_org.lower() not in f.host_info.org.lower():
595
+ continue
596
+ if target_asn and f.host_info and f.host_info.asn:
597
+ if target_asn.lower() not in f.host_info.asn.lower():
598
+ continue
599
+ if f.host_info and f.host_info.hostnames and target_scopes:
600
+ f.host_info.hostnames = [h for h in f.host_info.hostnames if any(h.lower() == s or h.lower().endswith(f".{s}") for s in target_scopes)]
601
+ if f.host_info and f.host_info.domains and target_scopes:
602
+ f.host_info.domains = [d for d in f.host_info.domains if any(d.lower() == s or d.lower().endswith(f".{s}") for s in target_scopes)]
603
+ raw_recon_findings.append(f)
604
+ elif isinstance(res, Exception):
605
+ logger.debug(f"Censys recursive enrichment exception: {res}")
606
+
607
+ # ----------------------------------------------------
608
+ # Group Discoveries per Host
609
+ # ----------------------------------------------------
610
+ hosts_map: Dict[str, HostResult] = {}
611
+ host_cves_map: Dict[str, Set[str]] = {}
612
+ all_unique_cves: Set[str] = set(context["cves"])
613
+ domain_findings: List[Finding] = []
614
+
615
+ for f in raw_recon_findings:
616
+ clean_src = _clean_source(f.source)
617
+
618
+ if f.type in (FindingType.SUBDOMAIN, FindingType.ASSOCIATED_DOMAIN):
619
+ if (target_org or target_asn) and (not f.host_ip or f.host_ip not in initial_org_ips):
620
+ continue
621
+ domain_findings.append(f)
622
+ if not f.host_ip:
623
+ continue
624
+
625
+ host_ip = f.host_ip or (f.host_info.ip if f.host_info else None)
626
+ if not host_ip and f.type == FindingType.VULNERABILITY and target_type == "cve":
627
+ # Standalone CVE scan
628
+ all_unique_cves.add(f.value.upper())
629
+ continue
630
+
631
+ # Scope enforcement for Organization and ASN targets
632
+ if (target_org or target_asn) and host_ip:
633
+ if host_ip not in initial_org_ips:
634
+ if target_org and f.host_info and f.host_info.org and target_org.lower() not in f.host_info.org.lower():
635
+ continue
636
+ if target_asn and f.host_info and f.host_info.asn and target_asn.lower() not in f.host_info.asn.lower():
637
+ continue
638
+
639
+ if host_ip:
640
+ if host_ip not in hosts_map:
641
+ hosts_map[host_ip] = HostResult(ip=host_ip)
642
+ host_cves_map[host_ip] = set()
643
+
644
+ host_obj = hosts_map[host_ip]
645
+ if clean_src and clean_src not in host_obj.sources:
646
+ host_obj.sources.append(clean_src)
647
+
648
+ if f.type == FindingType.HOST_INFO and f.host_info:
649
+ hi = f.host_info
650
+ in_scope_hnames = [h for h in hi.hostnames if not target_scopes or any(h.lower() == s or h.lower().endswith(f".{s}") for s in target_scopes)]
651
+ in_scope_doms = [d for d in hi.domains if not target_scopes or any(d.lower() == s or d.lower().endswith(f".{s}") for s in target_scopes)]
652
+ host_obj.hostnames = sorted(list(set(host_obj.hostnames + in_scope_hnames)))
653
+ host_obj.domains = sorted(list(set(host_obj.domains + in_scope_doms)))
654
+ host_obj.org = hi.org or host_obj.org
655
+ host_obj.isp = hi.isp or host_obj.isp
656
+ host_obj.asn = hi.asn or host_obj.asn
657
+ host_obj.os = hi.os or host_obj.os
658
+ host_obj.country_name = hi.country_name or host_obj.country_name
659
+ host_obj.country_code = hi.country_code or host_obj.country_code
660
+ host_obj.city = hi.city or host_obj.city
661
+ host_obj.region_code = hi.region_code or host_obj.region_code
662
+ host_obj.postal_code = hi.postal_code or host_obj.postal_code
663
+ host_obj.latitude = hi.latitude if hi.latitude is not None else host_obj.latitude
664
+ host_obj.longitude = hi.longitude if hi.longitude is not None else host_obj.longitude
665
+ if hi.vulns:
666
+ for v in hi.vulns:
667
+ if v.upper().startswith("CVE-"):
668
+ host_cves_map[host_ip].add(v.upper())
669
+ all_unique_cves.add(v.upper())
670
+
671
+ elif f.type == FindingType.OPEN_PORT and f.port_info:
672
+ pi = f.port_info
673
+ # Check for existing port on this host
674
+ matching_port = next(
675
+ (p for p in host_obj.ports if p.port == pi.port and p.transport.lower() == pi.transport.lower()),
676
+ None,
677
+ )
678
+ if matching_port:
679
+ # Complement and enrich existing port without duplicating
680
+ if clean_src and clean_src not in matching_port.sources:
681
+ matching_port.sources.append(clean_src)
682
+ for s in pi.sources:
683
+ clean_s = _clean_source(s)
684
+ if clean_s and clean_s not in matching_port.sources:
685
+ matching_port.sources.append(clean_s)
686
+
687
+ matching_port.product = matching_port.product or pi.product
688
+ matching_port.version = matching_port.version or pi.version
689
+ matching_port.service = matching_port.service or pi.service
690
+ matching_port.banner = matching_port.banner or pi.banner
691
+ matching_port.url = matching_port.url or pi.url
692
+ matching_port.ssl = matching_port.ssl or pi.ssl
693
+ else:
694
+ if clean_src and clean_src not in pi.sources:
695
+ pi.sources.append(clean_src)
696
+ host_obj.ports.append(pi)
697
+
698
+ elif f.type == FindingType.VULNERABILITY:
699
+ cve = f.value.upper()
700
+ if cve.startswith("CVE-"):
701
+ host_cves_map[host_ip].add(cve)
702
+ all_unique_cves.add(cve)
703
+
704
+ elif f.type == FindingType.SUBDOMAIN and f.value:
705
+ if f.value not in host_obj.hostnames:
706
+ host_obj.hostnames.append(f.value)
707
+
708
+ elif f.type == FindingType.ASSOCIATED_DOMAIN and f.value:
709
+ if f.value not in host_obj.domains:
710
+ host_obj.domains.append(f.value)
711
+
712
+ # ----------------------------------------------------
713
+ # Ensure Target Anchor in Hosts Map & Domain Discoveries
714
+ # (Guarantees target nodes exist on DetecTIHound graph for active recon staging)
715
+ # ----------------------------------------------------
716
+ clean_target = target
717
+ if clean_target.startswith("host:"):
718
+ clean_target = clean_target[5:]
719
+ elif clean_target.startswith("domain:"):
720
+ clean_target = clean_target[7:]
721
+
722
+ if target_type == "ip" and clean_target not in hosts_map:
723
+ hosts_map[clean_target] = HostResult(
724
+ ip=clean_target,
725
+ sources=["Target (Awaiting Active Recon)"],
726
+ )
727
+ host_cves_map[clean_target] = set()
728
+
729
+ if target_type == "domain" and not domain_findings and not any(clean_target in h.domains or clean_target in h.hostnames for h in hosts_map.values()):
730
+ domain_findings.append(
731
+ Finding(
732
+ type=FindingType.SUBDOMAIN,
733
+ target=clean_target,
734
+ value=clean_target,
735
+ source="Target",
736
+ )
737
+ )
738
+
739
+ # ----------------------------------------------------
740
+ # Stage 1.4: Automatic IP Enrichment Fallback (BGP / RDAP / IP WHOIS)
741
+ # (Enriches hosts lacking ASN/Org metadata when threat intel APIs return 0 results)
742
+ # ----------------------------------------------------
743
+ ips_needing_enrichment = [
744
+ ip for ip, h in hosts_map.items()
745
+ if not h.asn or not h.org
746
+ ]
747
+
748
+ if ips_needing_enrichment and target_type != "cve":
749
+ self._notify("engine", f"Enriching network metadata (ASN/Org/Geo) for {len(ips_needing_enrichment)} unresolved IP(s)...")
750
+
751
+ enrich_semaphore = asyncio.Semaphore(15)
752
+ async def _enrich_single_ip(ip_addr: str):
753
+ async with enrich_semaphore:
754
+ # Primary: ip-api.com
755
+ try:
756
+ async with httpx.AsyncClient(timeout=3.5, follow_redirects=True) as client:
757
+ url = f"http://ip-api.com/json/{ip_addr}?fields=status,country,countryCode,city,regionName,org,as,asname,lat,lon,query"
758
+ resp = await client.get(url, headers={"User-Agent": "DetecTI/1.0"})
759
+ if resp.status_code == 200:
760
+ data = resp.json()
761
+ if data.get("status") == "success":
762
+ return ip_addr, data
763
+ except Exception as e:
764
+ logger.debug(f"IP enrichment primary failed for {ip_addr}: {e}")
765
+
766
+ # Secondary: RIPE Stat Network Info API
767
+ try:
768
+ async with httpx.AsyncClient(timeout=3.5, follow_redirects=True) as client:
769
+ url = f"https://stat.ripe.net/data/network-info/data.json?resource={ip_addr}"
770
+ resp = await client.get(url, headers={"User-Agent": "DetecTI/1.0"})
771
+ if resp.status_code == 200:
772
+ r_data = resp.json()
773
+ if r_data and "data" in r_data:
774
+ asns = r_data["data"].get("asns", [])
775
+ asn_str = f"AS{asns[0]}" if asns else None
776
+ return ip_addr, {"as": asn_str, "org": asn_str}
777
+ except Exception as e:
778
+ logger.debug(f"IP enrichment secondary failed for {ip_addr}: {e}")
779
+
780
+ return ip_addr, None
781
+
782
+ enrich_results = await asyncio.gather(*[_enrich_single_ip(ip) for ip in ips_needing_enrichment])
783
+ for ip_addr, meta in enrich_results:
784
+ if meta and ip_addr in hosts_map:
785
+ h_obj = hosts_map[ip_addr]
786
+ if not h_obj.asn and meta.get("as"):
787
+ import re
788
+ as_raw = meta.get("as", "")
789
+ as_match = re.search(r"(AS\d+)", as_raw, re.IGNORECASE)
790
+ h_obj.asn = as_match.group(1).upper() if as_match else as_raw
791
+ if not h_obj.org:
792
+ h_obj.org = meta.get("org") or meta.get("asname")
793
+ if not h_obj.country_name and meta.get("country"):
794
+ h_obj.country_name = meta.get("country")
795
+ if not h_obj.country_code and meta.get("countryCode"):
796
+ h_obj.country_code = meta.get("countryCode")
797
+ if not h_obj.city and meta.get("city"):
798
+ h_obj.city = meta.get("city")
799
+ if not h_obj.region_code and meta.get("regionName"):
800
+ h_obj.region_code = meta.get("regionName")
801
+ if h_obj.latitude is None and meta.get("lat") is not None:
802
+ h_obj.latitude = meta.get("lat")
803
+ if h_obj.longitude is None and meta.get("lon") is not None:
804
+ h_obj.longitude = meta.get("lon")
805
+ if "BGP/RDAP Enrichment" not in h_obj.sources:
806
+ h_obj.sources.append("BGP/RDAP Enrichment")
807
+
808
+ # ----------------------------------------------------
809
+ # Stage 2: Threat Intelligence & Vulnerability Enrichment (NVD, EPSS, CISA KEV)
810
+ # ----------------------------------------------------
811
+ enriched_vulns: Dict[str, VulnerabilityData] = {}
812
+ if all_unique_cves and "nvd" in active_mod_names:
813
+ self._notify("nvd", f"Enriching {len(all_unique_cves)} CVEs with NVD, EPSS & CISA KEV...")
814
+ nvd_mod: NVDModule = self.modules["nvd"] # type: ignore
815
+ await nvd_mod._ensure_cisa_kev_loaded()
816
+
817
+ nvd_tasks = [nvd_mod.enrich_cve(cve) for cve in all_unique_cves]
818
+ nvd_results = await asyncio.gather(*nvd_tasks, return_exceptions=True)
819
+
820
+ for cve, vdata in zip(all_unique_cves, nvd_results):
821
+ if isinstance(vdata, Exception):
822
+ logger.warning(f"Failed to enrich {cve}: {vdata}")
823
+ elif isinstance(vdata, VulnerabilityData):
824
+ enriched_vulns[cve] = vdata
825
+
826
+ # ----------------------------------------------------
827
+ # Stage 3: Exploit & PoC Intelligence (ExploitDB + GitHub)
828
+ # ----------------------------------------------------
829
+ if all_unique_cves and "exploitdb" in active_mod_names:
830
+ self._notify("exploitdb", f"Hunting exploits & GitHub PoCs for {len(all_unique_cves)} CVEs...")
831
+ xdb_mod: ExploitDBModule = self.modules["exploitdb"] # type: ignore
832
+ xdb_tasks = [xdb_mod.get_exploits_for_cve(cve) for cve in all_unique_cves]
833
+ xdb_results = await asyncio.gather(*xdb_tasks, return_exceptions=True)
834
+
835
+ for cve, exps in zip(all_unique_cves, xdb_results):
836
+ if isinstance(exps, list) and cve in enriched_vulns:
837
+ enriched_vulns[cve].exploits = exps
838
+
839
+ # ----------------------------------------------------
840
+ # Stage 4: Attach Enriched Vulnerabilities to Specific Hosts
841
+ # ----------------------------------------------------
842
+ for host_ip, host_obj in hosts_map.items():
843
+ cves_for_this_host = host_cves_map.get(host_ip, set())
844
+ host_vulns: List[VulnerabilityData] = []
845
+
846
+ for cve in sorted(cves_for_this_host):
847
+ if cve in enriched_vulns:
848
+ vdata = enriched_vulns[cve]
849
+ # Apply CVSS filter if requested
850
+ if cvss_filter:
851
+ if vdata.cvss_severity != cvss_filter.upper():
852
+ continue
853
+ host_vulns.append(vdata)
854
+
855
+ # Sort host vulns by CVSS score descending
856
+ host_vulns.sort(key=lambda x: (x.cvss_score or 0.0), reverse=True)
857
+ host_obj.vulnerabilities = host_vulns
858
+
859
+ # ----------------------------------------------------
860
+ # Stage 5: Compile Final Findings & Output
861
+ # ----------------------------------------------------
862
+ final_findings: List[Finding] = []
863
+ final_findings.extend(domain_findings)
864
+
865
+ for host_ip, host_obj in hosts_map.items():
866
+ host_sources_str = ", ".join(host_obj.sources) if host_obj.sources else "Recon"
867
+
868
+ # Host finding
869
+ final_findings.append(
870
+ Finding(
871
+ type=FindingType.HOST_INFO,
872
+ target=target,
873
+ value=host_ip,
874
+ source=host_sources_str,
875
+ host_ip=host_ip,
876
+ host_info=HostInfoData(
877
+ ip=host_ip,
878
+ hostnames=host_obj.hostnames,
879
+ domains=host_obj.domains,
880
+ org=host_obj.org,
881
+ isp=host_obj.isp,
882
+ asn=host_obj.asn,
883
+ os=host_obj.os,
884
+ country_name=host_obj.country_name,
885
+ city=host_obj.city,
886
+ region_code=host_obj.region_code,
887
+ postal_code=host_obj.postal_code,
888
+ latitude=host_obj.latitude,
889
+ longitude=host_obj.longitude,
890
+ ports=[p.port for p in host_obj.ports],
891
+ vulns=[v.cve_id for v in host_obj.vulnerabilities],
892
+ ),
893
+ )
894
+ )
895
+
896
+ # Port findings with combined sources
897
+ for p in host_obj.ports:
898
+ port_sources_str = ", ".join(dict.fromkeys(p.sources)) if p.sources else host_sources_str
899
+ final_findings.append(
900
+ Finding(
901
+ type=FindingType.OPEN_PORT,
902
+ target=target,
903
+ value=f"{host_ip}:{p.port}",
904
+ source=port_sources_str,
905
+ host_ip=host_ip,
906
+ port_info=p,
907
+ )
908
+ )
909
+
910
+ # Vulnerability findings attached to this host
911
+ for v in host_obj.vulnerabilities:
912
+ final_findings.append(
913
+ Finding(
914
+ type=FindingType.VULNERABILITY,
915
+ target=target,
916
+ value=v.cve_id,
917
+ source="NVD+EPSS+CISA",
918
+ host_ip=host_ip,
919
+ vulnerability=v,
920
+ )
921
+ )
922
+ for exp in v.exploits:
923
+ final_findings.append(
924
+ Finding(
925
+ type=FindingType.EXPLOIT,
926
+ target=target,
927
+ value=f"{v.cve_id} - {exp.title}",
928
+ source=exp.source,
929
+ host_ip=host_ip,
930
+ exploit=exp,
931
+ )
932
+ )
933
+
934
+ # Standalone CVE scan without hosts
935
+ if not hosts_map and target_type == "cve":
936
+ for cve, vdata in enriched_vulns.items():
937
+ if cvss_filter and vdata.cvss_severity != cvss_filter.upper():
938
+ continue
939
+ final_findings.append(
940
+ Finding(
941
+ type=FindingType.VULNERABILITY,
942
+ target=target,
943
+ value=cve,
944
+ source="NVD+EPSS+CISA",
945
+ vulnerability=vdata,
946
+ )
947
+ )
948
+ for exp in vdata.exploits:
949
+ final_findings.append(
950
+ Finding(
951
+ type=FindingType.EXPLOIT,
952
+ target=target,
953
+ value=f"{cve} - {exp.title}",
954
+ source=exp.source,
955
+ exploit=exp,
956
+ )
957
+ )
958
+
959
+ result.hosts = list(hosts_map.values())
960
+ result.findings = final_findings
961
+ result.warnings = list(dict.fromkeys(context.get("warnings", [])))
962
+ result.completed_at = datetime.now(timezone.utc)
963
+ result.elapsed_seconds = time.monotonic() - start_time
964
+ result.calculate_summary()
965
+
966
+ self._notify("engine", f"Scan completed in {result.elapsed_seconds:.2f}s with {len(result.hosts)} hosts and {len(result.findings)} findings.")
967
+ return result
968
+
969
+ async def _scan_file(
970
+ self,
971
+ file_path: str,
972
+ enabled_modules: Optional[List[str]],
973
+ cvss_filter: Optional[str],
974
+ ) -> ScanResult:
975
+ """Process file containing targets line-by-line with pre-flight check executed once and live progress."""
976
+ path = Path(file_path)
977
+ if not path.is_file():
978
+ raise FileNotFoundError(f"Input file not found: {file_path}")
979
+
980
+ lines = [line.strip() for line in path.read_text().splitlines() if line.strip() and not line.startswith("#")]
981
+ total = len(lines)
982
+ self._notify("engine", f"Loaded {total} targets from {file_path}")
983
+
984
+ active_mod_names = (
985
+ [m for m in enabled_modules if m in self.modules]
986
+ if enabled_modules and "all" not in enabled_modules
987
+ else list(self.modules.keys())
988
+ )
989
+
990
+ # Pre-flight API verification layer: validate all APIs once for the batch
991
+ self._notify("engine", "Verifying environment API credentials and endpoints...")
992
+ api_statuses = await self.verify_environment_apis(active_mod_names)
993
+ for mod, info in api_statuses.items():
994
+ if info.get("configured") and not info.get("valid"):
995
+ self._notify(mod, f"{info.get('name')}: {info.get('status')}")
996
+
997
+ combined_result = ScanResult(
998
+ target=file_path,
999
+ target_type="file",
1000
+ started_at=datetime.now(timezone.utc),
1001
+ modules_run=enabled_modules or list(self.MODULE_REGISTRY.keys()),
1002
+ )
1003
+
1004
+ all_findings: List[Finding] = []
1005
+ all_hosts: List[HostResult] = []
1006
+ all_warnings: List[str] = []
1007
+
1008
+ for idx, line in enumerate(lines, 1):
1009
+ self._notify("engine", f"Processing target [{idx}/{total}]: {line}")
1010
+ sub_res = await self.scan(
1011
+ line,
1012
+ enabled_modules=enabled_modules,
1013
+ cvss_filter=cvss_filter,
1014
+ skip_preflight=True,
1015
+ )
1016
+ all_findings.extend(sub_res.findings)
1017
+ all_hosts.extend(sub_res.hosts)
1018
+ all_warnings.extend(sub_res.warnings)
1019
+
1020
+ combined_result.hosts = all_hosts
1021
+ combined_result.findings = all_findings
1022
+ combined_result.warnings = list(dict.fromkeys(all_warnings))
1023
+ combined_result.completed_at = datetime.now(timezone.utc)
1024
+ combined_result.elapsed_seconds = (combined_result.completed_at - combined_result.started_at).total_seconds()
1025
+ combined_result.calculate_summary()
1026
+ self._notify("engine", f"Batch scan finished: processed {total} targets with {len(all_findings)} findings.")
1027
+ return combined_result
1028
+
1029
+
1030
+ # Alias for backward compatibility and clean branding
1031
+ DetectIEngine = ThreatTrackEngine
1032
+