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
@@ -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