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.
- detecti/__init__.py +0 -0
- detecti/cli.py +649 -0
- detecti/config.py +188 -0
- detecti/core/__init__.py +1 -0
- detecti/core/database/__init__.py +5 -0
- detecti/core/database/config_db.py +73 -0
- detecti/core/database/schema.py +136 -0
- detecti/core/database/storage.py +1388 -0
- detecti/core/engine.py +1032 -0
- detecti/core/models.py +278 -0
- detecti/data/config.sqlite +0 -0
- detecti/data/dbs/.gitkeep +2 -0
- detecti/data/dbs/example.com.sqlite +0 -0
- detecti/modules/__init__.py +29 -0
- detecti/modules/base.py +57 -0
- detecti/modules/censys.py +813 -0
- detecti/modules/crtsh.py +98 -0
- detecti/modules/exploitdb.py +138 -0
- detecti/modules/masscan.py +561 -0
- detecti/modules/nuclei.py +449 -0
- detecti/modules/nvd.py +300 -0
- detecti/modules/reverse_whois.py +225 -0
- detecti/modules/shodan.py +412 -0
- detecti/reporters/__init__.py +7 -0
- detecti/reporters/csv_reporter.py +74 -0
- detecti/reporters/html_reporter.py +356 -0
- detecti/reporters/json_reporter.py +26 -0
- detecti/reporters/markdown_reporter.py +203 -0
- detecti/utils/__init__.py +1 -0
- detecti/utils/http.py +294 -0
- detecti/utils/logger.py +378 -0
- detecti/utils/setup.py +453 -0
- detecti/web/__init__.py +6 -0
- detecti/web/api/__init__.py +1 -0
- detecti/web/api/auth.py +109 -0
- detecti/web/api/graph_builder.py +901 -0
- detecti/web/api/routes.py +1602 -0
- detecti/web/process_manager.py +283 -0
- detecti/web/server.py +183 -0
- detecti/web/static/android-chrome-192x192.png +0 -0
- detecti/web/static/android-chrome-512x512.png +0 -0
- detecti/web/static/apple-touch-icon.png +0 -0
- detecti/web/static/css/__init__.py +1 -0
- detecti/web/static/css/dashboard.css +3802 -0
- detecti/web/static/favicon-16x16.png +0 -0
- detecti/web/static/favicon-32x32.png +0 -0
- detecti/web/static/favicon.ico +0 -0
- detecti/web/static/img/DetecTI_Security_Logo.png +0 -0
- detecti/web/static/img/detecti-ico.png +0 -0
- detecti/web/static/index.html +677 -0
- detecti/web/static/js/__init__.py +1 -0
- detecti/web/static/js/api.js +177 -0
- detecti/web/static/js/cytoscape-cose-bilkent.js +458 -0
- detecti/web/static/js/cytoscape-dagre.js +397 -0
- detecti/web/static/js/cytoscape.min.js +31 -0
- detecti/web/static/js/dagre.min.js +3809 -0
- detecti/web/static/js/graph.js +7439 -0
- detecti/web/static/js/lucide.min.js +12 -0
- detecti/web/static/login.html +290 -0
- detecti/web/static/site.webmanifest +1 -0
- detecti_cli-2.0.0.dist-info/METADATA +554 -0
- detecti_cli-2.0.0.dist-info/RECORD +64 -0
- detecti_cli-2.0.0.dist-info/WHEEL +4 -0
- 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
|
+
|