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
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Shodan.io Infrastructure Mapping and Vulnerability Collection Module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import ipaddress
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any, Dict, List, Optional, Set
|
|
9
|
+
from detecti.config import settings
|
|
10
|
+
from detecti.core.models import (
|
|
11
|
+
Finding,
|
|
12
|
+
FindingType,
|
|
13
|
+
HostInfoData,
|
|
14
|
+
PortData,
|
|
15
|
+
VulnerabilityData,
|
|
16
|
+
)
|
|
17
|
+
from detecti.modules.base import BaseModule
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("detecti.shodan")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ShodanModule(BaseModule):
|
|
23
|
+
"""Refactored Shodan collector supporting host lookups, CIDR ranges, DNS, and search queries."""
|
|
24
|
+
|
|
25
|
+
name: str = "shodan"
|
|
26
|
+
description: str = "Shodan.io internet-wide scanner & asset intelligence"
|
|
27
|
+
category: str = "recon"
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
client: Optional[AsyncHTTPClient] = None,
|
|
32
|
+
progress_callback: Optional[Any] = None,
|
|
33
|
+
):
|
|
34
|
+
super().__init__(client=client, progress_callback=progress_callback)
|
|
35
|
+
self._auth_failed = False
|
|
36
|
+
self._validated_result: Optional[tuple[bool, str]] = None
|
|
37
|
+
|
|
38
|
+
def is_configured(self) -> bool:
|
|
39
|
+
"""Check if valid Shodan API key is set."""
|
|
40
|
+
if self._auth_failed:
|
|
41
|
+
return False
|
|
42
|
+
from config import is_placeholder_key
|
|
43
|
+
return bool(settings.shodan_api_key and not is_placeholder_key(settings.shodan_api_key))
|
|
44
|
+
|
|
45
|
+
async def validate_credentials(self) -> bool:
|
|
46
|
+
"""Perform a fast pre-flight authentication verification check against Shodan API."""
|
|
47
|
+
is_valid, _ = await self.validate_credentials_detailed()
|
|
48
|
+
return is_valid
|
|
49
|
+
|
|
50
|
+
async def validate_credentials_detailed(self, force: bool = False) -> tuple[bool, str]:
|
|
51
|
+
"""Perform a fast pre-flight authentication check returning validity and human-readable status."""
|
|
52
|
+
if not force and self._validated_result is not None:
|
|
53
|
+
return self._validated_result
|
|
54
|
+
|
|
55
|
+
if not self.is_configured():
|
|
56
|
+
return False, "Not Configured"
|
|
57
|
+
url = "https://api.shodan.io/api-info"
|
|
58
|
+
params = {"key": settings.shodan_api_key}
|
|
59
|
+
try:
|
|
60
|
+
resp = await self.http_client.get(
|
|
61
|
+
url=url,
|
|
62
|
+
params=params,
|
|
63
|
+
timeout=4.0,
|
|
64
|
+
max_retries=1,
|
|
65
|
+
max_retry_delay=2.0,
|
|
66
|
+
raise_for_status=False,
|
|
67
|
+
)
|
|
68
|
+
if resp.status_code == 200:
|
|
69
|
+
res = (True, "Active & Valid")
|
|
70
|
+
elif resp.status_code in (401, 403):
|
|
71
|
+
self._auth_failed = True
|
|
72
|
+
logger.debug("Shodan API key validation failed (HTTP 401/403).")
|
|
73
|
+
res = (False, "Invalid Key (HTTP 401/403)")
|
|
74
|
+
elif resp.status_code == 429:
|
|
75
|
+
logger.warning("Shodan API rate limit reached during pre-flight check.")
|
|
76
|
+
res = (False, "Rate Limited / Throttled (HTTP 429)")
|
|
77
|
+
else:
|
|
78
|
+
logger.debug(f"Shodan API key pre-check returned HTTP {resp.status_code}.")
|
|
79
|
+
res = (False, f"API Error (HTTP {resp.status_code})")
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
logger.debug(f"Shodan API key pre-check encountered network exception: {exc}")
|
|
82
|
+
res = (False, f"Network / Timeout Error ({type(exc).__name__})")
|
|
83
|
+
|
|
84
|
+
self._validated_result = res
|
|
85
|
+
return res
|
|
86
|
+
|
|
87
|
+
async def run(
|
|
88
|
+
self,
|
|
89
|
+
target: str,
|
|
90
|
+
context: Optional[Dict[str, Any]] = None,
|
|
91
|
+
) -> List[Finding]:
|
|
92
|
+
"""Execute Shodan queries based on target input type."""
|
|
93
|
+
if self._auth_failed or not self.is_configured():
|
|
94
|
+
logger.warning("Shodan API key is not configured or invalid. Skipping Shodan module.")
|
|
95
|
+
return []
|
|
96
|
+
|
|
97
|
+
target = target.strip()
|
|
98
|
+
findings: List[Finding] = []
|
|
99
|
+
|
|
100
|
+
# 1. Target Cleaning and Normalization
|
|
101
|
+
clean_target = target
|
|
102
|
+
if clean_target.startswith("host:"):
|
|
103
|
+
clean_target = clean_target[5:].strip()
|
|
104
|
+
elif clean_target.startswith("domain:"):
|
|
105
|
+
clean_target = clean_target[7:].strip()
|
|
106
|
+
|
|
107
|
+
if clean_target.startswith("http://") or clean_target.startswith("https://"):
|
|
108
|
+
clean_target = clean_target.split("//")[1].split("/")[0]
|
|
109
|
+
if ":" in clean_target and " " not in clean_target and not clean_target.startswith("net:"):
|
|
110
|
+
clean_target = clean_target.split(":")[0]
|
|
111
|
+
if "/" in clean_target and not clean_target.startswith("net:"):
|
|
112
|
+
try:
|
|
113
|
+
ipaddress.ip_network(clean_target, strict=False)
|
|
114
|
+
except ValueError:
|
|
115
|
+
clean_target = clean_target.split("/")[0]
|
|
116
|
+
|
|
117
|
+
# 2. CIDR Network Check (e.g. 192.168.1.0/24 or host:192.168.1.0/24)
|
|
118
|
+
if "/" in clean_target:
|
|
119
|
+
try:
|
|
120
|
+
network = ipaddress.ip_network(clean_target, strict=False)
|
|
121
|
+
# First attempt Shodan net: filter search to find all indexed hosts in subnet
|
|
122
|
+
net_findings = await self.search_query(f"net:{clean_target}")
|
|
123
|
+
if net_findings:
|
|
124
|
+
return net_findings
|
|
125
|
+
|
|
126
|
+
# Fallback for small subnets (<= /28) if net query returned no results
|
|
127
|
+
if network.num_addresses <= 16:
|
|
128
|
+
hosts = list(network.hosts())
|
|
129
|
+
host_tasks = [self.get_host_info(str(host_ip), context=context) for host_ip in hosts]
|
|
130
|
+
if host_tasks:
|
|
131
|
+
results = await asyncio.gather(*host_tasks, return_exceptions=True)
|
|
132
|
+
for res in results:
|
|
133
|
+
if isinstance(res, list):
|
|
134
|
+
findings.extend(res)
|
|
135
|
+
return findings
|
|
136
|
+
except ValueError:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
# 3. Direct IP Address Check
|
|
140
|
+
try:
|
|
141
|
+
ipaddress.ip_address(clean_target)
|
|
142
|
+
return await self.get_host_info(clean_target, context=context)
|
|
143
|
+
except ValueError:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
# 4. Domain & Subdomain Check (DNS domain info & hostname query)
|
|
147
|
+
import tldextract
|
|
148
|
+
ext = tldextract.extract(clean_target)
|
|
149
|
+
if ext.domain and ext.suffix and " " not in clean_target:
|
|
150
|
+
root_domain = ext.registered_domain or f"{ext.domain}.{ext.suffix}"
|
|
151
|
+
dns_findings = await self.get_domain_info(root_domain)
|
|
152
|
+
findings.extend(dns_findings)
|
|
153
|
+
|
|
154
|
+
# If targeting a specific subdomain, query hostname in Shodan
|
|
155
|
+
if clean_target != root_domain:
|
|
156
|
+
try:
|
|
157
|
+
host_findings = await self.search_query(f"hostname:{clean_target}", max_pages=1)
|
|
158
|
+
findings.extend(host_findings)
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
logger.debug(f"Shodan hostname search error for {clean_target}: {exc}")
|
|
161
|
+
|
|
162
|
+
return findings
|
|
163
|
+
|
|
164
|
+
# 5. Search Query Check
|
|
165
|
+
query = target[5:] if target.startswith("host:") else target
|
|
166
|
+
return await self.search_query(query)
|
|
167
|
+
|
|
168
|
+
async def get_host_info(self, ip: str, context: Optional[Dict[str, Any]] = None) -> List[Finding]:
|
|
169
|
+
"""Fetch host info and ports from Shodan for a specific IP."""
|
|
170
|
+
url = f"https://api.shodan.io/shodan/host/{ip}"
|
|
171
|
+
params = {"key": settings.shodan_api_key, "minify": "false"}
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
resp = await self.http_client.get(url=url, params=params, timeout=20.0, raise_for_status=False)
|
|
175
|
+
if resp.status_code == 200:
|
|
176
|
+
data = resp.json()
|
|
177
|
+
else:
|
|
178
|
+
try:
|
|
179
|
+
err_payload = resp.json()
|
|
180
|
+
err_msg = err_payload.get("error") if isinstance(err_payload, dict) else str(err_payload)
|
|
181
|
+
except Exception:
|
|
182
|
+
err_msg = resp.text.strip() or f"HTTP {resp.status_code}"
|
|
183
|
+
|
|
184
|
+
log_entry = f"Shodan [{ip}]: {err_msg}"
|
|
185
|
+
if resp.status_code == 404:
|
|
186
|
+
logger.info(log_entry)
|
|
187
|
+
elif resp.status_code == 429:
|
|
188
|
+
logger.warning(log_entry)
|
|
189
|
+
else:
|
|
190
|
+
logger.debug(log_entry)
|
|
191
|
+
|
|
192
|
+
self.notify(f"[{ip}] Shodan: {err_msg}")
|
|
193
|
+
if context is not None and "warnings" in context and isinstance(context["warnings"], list):
|
|
194
|
+
context["warnings"].append(log_entry)
|
|
195
|
+
return []
|
|
196
|
+
except Exception as exc:
|
|
197
|
+
logger.debug(f"Shodan host query exception for {ip}: {exc}")
|
|
198
|
+
return []
|
|
199
|
+
|
|
200
|
+
if not data:
|
|
201
|
+
return []
|
|
202
|
+
|
|
203
|
+
findings: List[Finding] = []
|
|
204
|
+
|
|
205
|
+
hostnames = data.get("hostnames", [])
|
|
206
|
+
domains = data.get("domains", [])
|
|
207
|
+
ports = data.get("ports", [])
|
|
208
|
+
vulns = data.get("vulns", [])
|
|
209
|
+
|
|
210
|
+
host_info = HostInfoData(
|
|
211
|
+
ip=data.get("ip_str", ip),
|
|
212
|
+
hostnames=hostnames,
|
|
213
|
+
domains=domains,
|
|
214
|
+
org=data.get("org"),
|
|
215
|
+
isp=data.get("isp"),
|
|
216
|
+
asn=data.get("asn"),
|
|
217
|
+
os=data.get("os"),
|
|
218
|
+
country_name=data.get("country_name"),
|
|
219
|
+
country_code=data.get("country_code"),
|
|
220
|
+
city=data.get("city"),
|
|
221
|
+
region_code=data.get("region_code"),
|
|
222
|
+
postal_code=data.get("postal_code"),
|
|
223
|
+
latitude=data.get("latitude"),
|
|
224
|
+
longitude=data.get("longitude"),
|
|
225
|
+
ports=ports,
|
|
226
|
+
vulns=vulns,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# 1. Host Info Finding
|
|
230
|
+
findings.append(
|
|
231
|
+
Finding(
|
|
232
|
+
type=FindingType.HOST_INFO,
|
|
233
|
+
target=ip,
|
|
234
|
+
value=ip,
|
|
235
|
+
source="Shodan",
|
|
236
|
+
host_ip=ip,
|
|
237
|
+
host_info=host_info,
|
|
238
|
+
metadata={"host_data": {k: data.get(k) for k in ["org", "isp", "os", "country_name"]}},
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# 2. Port & Service Findings
|
|
243
|
+
for item in data.get("data", []):
|
|
244
|
+
port_num = item.get("port")
|
|
245
|
+
if port_num is None:
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
product = item.get("product")
|
|
249
|
+
version = item.get("version")
|
|
250
|
+
transport = item.get("transport", "tcp")
|
|
251
|
+
is_ssl = "ssl" in item
|
|
252
|
+
is_http = "http" in item or port_num in (80, 443, 8080, 8443)
|
|
253
|
+
|
|
254
|
+
web_url = None
|
|
255
|
+
if is_http:
|
|
256
|
+
scheme = "https" if is_ssl or port_num in (443, 8443) else "http"
|
|
257
|
+
web_url = f"{scheme}://{ip}:{port_num}"
|
|
258
|
+
|
|
259
|
+
port_obj = PortData(
|
|
260
|
+
port=port_num,
|
|
261
|
+
transport=transport,
|
|
262
|
+
service=item.get("_shodan", {}).get("module") or ("http" if is_http else None),
|
|
263
|
+
product=product,
|
|
264
|
+
version=version,
|
|
265
|
+
banner=item.get("data", "").strip() if item.get("data") else None,
|
|
266
|
+
url=web_url,
|
|
267
|
+
ssl=is_ssl,
|
|
268
|
+
sources=["Shodan"],
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
findings.append(
|
|
272
|
+
Finding(
|
|
273
|
+
type=FindingType.OPEN_PORT,
|
|
274
|
+
target=ip,
|
|
275
|
+
value=f"{ip}:{port_num}",
|
|
276
|
+
source="Shodan",
|
|
277
|
+
host_ip=ip,
|
|
278
|
+
port_info=port_obj,
|
|
279
|
+
metadata={"transport": transport, "product": product, "version": version},
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# 3. Associated Domains & Hostnames
|
|
284
|
+
for domain in domains:
|
|
285
|
+
findings.append(
|
|
286
|
+
Finding(
|
|
287
|
+
type=FindingType.ASSOCIATED_DOMAIN,
|
|
288
|
+
target=ip,
|
|
289
|
+
value=domain,
|
|
290
|
+
source="Shodan (Host Domains)",
|
|
291
|
+
host_ip=ip,
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
for hname in hostnames:
|
|
295
|
+
findings.append(
|
|
296
|
+
Finding(
|
|
297
|
+
type=FindingType.SUBDOMAIN,
|
|
298
|
+
target=ip,
|
|
299
|
+
value=hname,
|
|
300
|
+
source="Shodan (Hostnames)",
|
|
301
|
+
host_ip=ip,
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# 4. Vulnerability (CVE) Findings
|
|
306
|
+
for cve in vulns:
|
|
307
|
+
findings.append(
|
|
308
|
+
Finding(
|
|
309
|
+
type=FindingType.VULNERABILITY,
|
|
310
|
+
target=ip,
|
|
311
|
+
value=cve,
|
|
312
|
+
source="Shodan",
|
|
313
|
+
host_ip=ip,
|
|
314
|
+
vulnerability=VulnerabilityData(cve_id=cve),
|
|
315
|
+
metadata={"ip": ip},
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
return findings
|
|
320
|
+
|
|
321
|
+
async def get_domain_info(self, domain: str) -> List[Finding]:
|
|
322
|
+
"""Fetch DNS domain info and subdomains from Shodan."""
|
|
323
|
+
clean_domain = domain.strip().lower()
|
|
324
|
+
if clean_domain.startswith("http://") or clean_domain.startswith("https://"):
|
|
325
|
+
clean_domain = clean_domain.split("//")[1].split("/")[0]
|
|
326
|
+
if ":" in clean_domain:
|
|
327
|
+
clean_domain = clean_domain.split(":")[0]
|
|
328
|
+
if "/" in clean_domain:
|
|
329
|
+
clean_domain = clean_domain.split("/")[0]
|
|
330
|
+
|
|
331
|
+
import tldextract
|
|
332
|
+
ext = tldextract.extract(clean_domain)
|
|
333
|
+
search_domain = ext.registered_domain if (ext.domain and ext.suffix) else clean_domain
|
|
334
|
+
|
|
335
|
+
url = f"https://api.shodan.io/dns/domain/{search_domain}"
|
|
336
|
+
params = {"key": settings.shodan_api_key, "history": "true"}
|
|
337
|
+
|
|
338
|
+
data = await self.http_client.get_json(url=url, params=params, timeout=20.0)
|
|
339
|
+
if not data:
|
|
340
|
+
return []
|
|
341
|
+
|
|
342
|
+
findings: List[Finding] = []
|
|
343
|
+
dns_records = data.get("data", [])
|
|
344
|
+
|
|
345
|
+
for record in dns_records:
|
|
346
|
+
sub = record.get("subdomain")
|
|
347
|
+
rec_type = record.get("type")
|
|
348
|
+
value = record.get("value")
|
|
349
|
+
full_domain = f"{sub}.{domain}" if sub else domain
|
|
350
|
+
|
|
351
|
+
findings.append(
|
|
352
|
+
Finding(
|
|
353
|
+
type=FindingType.SUBDOMAIN,
|
|
354
|
+
target=domain,
|
|
355
|
+
value=full_domain,
|
|
356
|
+
source="Shodan DNS",
|
|
357
|
+
metadata={"dns_type": rec_type, "value": value},
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
# If the record resolves to an IP address (A or AAAA), query host info
|
|
362
|
+
if value and rec_type in ("A", "AAAA"):
|
|
363
|
+
try:
|
|
364
|
+
ipaddress.ip_address(value)
|
|
365
|
+
host_findings = await self.get_host_info(value)
|
|
366
|
+
findings.extend(host_findings)
|
|
367
|
+
except ValueError:
|
|
368
|
+
pass
|
|
369
|
+
|
|
370
|
+
return findings
|
|
371
|
+
|
|
372
|
+
async def search_query(self, query: str, max_pages: Optional[int] = None) -> List[Finding]:
|
|
373
|
+
"""Search Shodan using query syntax and resolve host dossiers for all matches across all pages."""
|
|
374
|
+
findings: List[Finding] = []
|
|
375
|
+
url = "https://api.shodan.io/shodan/host/search"
|
|
376
|
+
page = 1
|
|
377
|
+
seen_ips: Set[str] = set()
|
|
378
|
+
|
|
379
|
+
while True:
|
|
380
|
+
if max_pages and page > max_pages:
|
|
381
|
+
break
|
|
382
|
+
|
|
383
|
+
params = {
|
|
384
|
+
"key": settings.shodan_api_key,
|
|
385
|
+
"query": query,
|
|
386
|
+
"page": page,
|
|
387
|
+
}
|
|
388
|
+
data = await self.http_client.get_json(url=url, params=params, timeout=25.0)
|
|
389
|
+
if not data or not isinstance(data, dict):
|
|
390
|
+
break
|
|
391
|
+
|
|
392
|
+
matches = data.get("matches", [])
|
|
393
|
+
if not matches:
|
|
394
|
+
break
|
|
395
|
+
|
|
396
|
+
page_ips = [m.get("ip_str") for m in matches if m.get("ip_str") and m.get("ip_str") not in seen_ips]
|
|
397
|
+
for ip in page_ips:
|
|
398
|
+
seen_ips.add(ip)
|
|
399
|
+
|
|
400
|
+
if page_ips:
|
|
401
|
+
host_tasks = [self.get_host_info(ip) for ip in page_ips]
|
|
402
|
+
host_results = await asyncio.gather(*host_tasks, return_exceptions=True)
|
|
403
|
+
for h_res in host_results:
|
|
404
|
+
if isinstance(h_res, list):
|
|
405
|
+
findings.extend(h_res)
|
|
406
|
+
|
|
407
|
+
total = data.get("total", 0)
|
|
408
|
+
if page * 100 >= total:
|
|
409
|
+
break
|
|
410
|
+
page += 1
|
|
411
|
+
|
|
412
|
+
return findings
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Reporters and export handlers for ThreatTrack."""
|
|
2
|
+
from .json_reporter import JSONReporter
|
|
3
|
+
from .markdown_reporter import MarkdownReporter
|
|
4
|
+
from .html_reporter import HTMLReporter
|
|
5
|
+
from .csv_reporter import CSVReporter
|
|
6
|
+
|
|
7
|
+
__all__ = ["JSONReporter", "MarkdownReporter", "HTMLReporter", "CSVReporter"]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""CSV Reporter for tabular export."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import io
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from detecti.core.models import ScanResult
|
|
7
|
+
|
|
8
|
+
class CSVReporter:
|
|
9
|
+
"""Exports ScanResult to CSV."""
|
|
10
|
+
|
|
11
|
+
@staticmethod
|
|
12
|
+
def generate(result: ScanResult) -> str:
|
|
13
|
+
output = io.StringIO()
|
|
14
|
+
writer = csv.writer(output)
|
|
15
|
+
|
|
16
|
+
# Header
|
|
17
|
+
writer.writerow([
|
|
18
|
+
"Target", "Type", "IP", "Hostnames", "Organization", "ASN",
|
|
19
|
+
"Port", "Protocol", "Service", "Product", "Version",
|
|
20
|
+
"CVE", "Severity", "CVSS", "EPSS", "CISA KEV",
|
|
21
|
+
"Vulnerability Name", "Sources"
|
|
22
|
+
])
|
|
23
|
+
|
|
24
|
+
target = result.target
|
|
25
|
+
|
|
26
|
+
# Process each host
|
|
27
|
+
for host in result.hosts:
|
|
28
|
+
ip = host.ip
|
|
29
|
+
hostnames = ", ".join(host.hostnames) if host.hostnames else ""
|
|
30
|
+
org = host.org or ""
|
|
31
|
+
asn = host.asn or ""
|
|
32
|
+
|
|
33
|
+
# Emit host baseline if no ports and no vulns
|
|
34
|
+
if not host.ports and not host.vulnerabilities:
|
|
35
|
+
writer.writerow([
|
|
36
|
+
target, "Host", ip, hostnames, org, asn,
|
|
37
|
+
"", "", "", "", "",
|
|
38
|
+
"", "", "", "", "",
|
|
39
|
+
"", host.source
|
|
40
|
+
])
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
# Emit ports
|
|
44
|
+
for port in host.ports:
|
|
45
|
+
writer.writerow([
|
|
46
|
+
target, "Port", ip, hostnames, org, asn,
|
|
47
|
+
port.port, port.transport, port.service or "", port.product or "", port.version or "",
|
|
48
|
+
"", "", "", "", "",
|
|
49
|
+
"", port.source
|
|
50
|
+
])
|
|
51
|
+
|
|
52
|
+
# Emit vulnerabilities
|
|
53
|
+
for vuln in host.vulnerabilities:
|
|
54
|
+
cisa_kev = "Yes" if vuln.in_cisa_kev else "No"
|
|
55
|
+
vuln_name = vuln.cwe_name or vuln.description or ""
|
|
56
|
+
# VulnerabilityData doesn't have 'source', we can use the host's source or just leave empty
|
|
57
|
+
writer.writerow([
|
|
58
|
+
target, "Vulnerability", ip, hostnames, org, asn,
|
|
59
|
+
"", "", "", "", "",
|
|
60
|
+
vuln.cve_id, vuln.cvss_severity.value if vuln.cvss_severity else "",
|
|
61
|
+
vuln.cvss_score or "", vuln.epss_score or "", cisa_kev,
|
|
62
|
+
vuln_name, "NVD/ThreatTrack"
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
return output.getvalue()
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def save(cls, result: ScanResult, output_path: Path | str) -> Path:
|
|
69
|
+
"""Save formatted CSV report to disk."""
|
|
70
|
+
path = Path(output_path)
|
|
71
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
content = cls.generate(result)
|
|
73
|
+
path.write_text(content, encoding="utf-8")
|
|
74
|
+
return path
|