detecti-cli 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. detecti/__init__.py +0 -0
  2. detecti/cli.py +649 -0
  3. detecti/config.py +188 -0
  4. detecti/core/__init__.py +1 -0
  5. detecti/core/database/__init__.py +5 -0
  6. detecti/core/database/config_db.py +73 -0
  7. detecti/core/database/schema.py +136 -0
  8. detecti/core/database/storage.py +1388 -0
  9. detecti/core/engine.py +1032 -0
  10. detecti/core/models.py +278 -0
  11. detecti/data/config.sqlite +0 -0
  12. detecti/data/dbs/.gitkeep +2 -0
  13. detecti/data/dbs/example.com.sqlite +0 -0
  14. detecti/modules/__init__.py +29 -0
  15. detecti/modules/base.py +57 -0
  16. detecti/modules/censys.py +813 -0
  17. detecti/modules/crtsh.py +98 -0
  18. detecti/modules/exploitdb.py +138 -0
  19. detecti/modules/masscan.py +561 -0
  20. detecti/modules/nuclei.py +449 -0
  21. detecti/modules/nvd.py +300 -0
  22. detecti/modules/reverse_whois.py +225 -0
  23. detecti/modules/shodan.py +412 -0
  24. detecti/reporters/__init__.py +7 -0
  25. detecti/reporters/csv_reporter.py +74 -0
  26. detecti/reporters/html_reporter.py +356 -0
  27. detecti/reporters/json_reporter.py +26 -0
  28. detecti/reporters/markdown_reporter.py +203 -0
  29. detecti/utils/__init__.py +1 -0
  30. detecti/utils/http.py +294 -0
  31. detecti/utils/logger.py +378 -0
  32. detecti/utils/setup.py +453 -0
  33. detecti/web/__init__.py +6 -0
  34. detecti/web/api/__init__.py +1 -0
  35. detecti/web/api/auth.py +109 -0
  36. detecti/web/api/graph_builder.py +901 -0
  37. detecti/web/api/routes.py +1602 -0
  38. detecti/web/process_manager.py +283 -0
  39. detecti/web/server.py +183 -0
  40. detecti/web/static/android-chrome-192x192.png +0 -0
  41. detecti/web/static/android-chrome-512x512.png +0 -0
  42. detecti/web/static/apple-touch-icon.png +0 -0
  43. detecti/web/static/css/__init__.py +1 -0
  44. detecti/web/static/css/dashboard.css +3802 -0
  45. detecti/web/static/favicon-16x16.png +0 -0
  46. detecti/web/static/favicon-32x32.png +0 -0
  47. detecti/web/static/favicon.ico +0 -0
  48. detecti/web/static/img/DetecTI_Security_Logo.png +0 -0
  49. detecti/web/static/img/detecti-ico.png +0 -0
  50. detecti/web/static/index.html +677 -0
  51. detecti/web/static/js/__init__.py +1 -0
  52. detecti/web/static/js/api.js +177 -0
  53. detecti/web/static/js/cytoscape-cose-bilkent.js +458 -0
  54. detecti/web/static/js/cytoscape-dagre.js +397 -0
  55. detecti/web/static/js/cytoscape.min.js +31 -0
  56. detecti/web/static/js/dagre.min.js +3809 -0
  57. detecti/web/static/js/graph.js +7439 -0
  58. detecti/web/static/js/lucide.min.js +12 -0
  59. detecti/web/static/login.html +290 -0
  60. detecti/web/static/site.webmanifest +1 -0
  61. detecti_cli-2.0.0.dist-info/METADATA +554 -0
  62. detecti_cli-2.0.0.dist-info/RECORD +64 -0
  63. detecti_cli-2.0.0.dist-info/WHEEL +4 -0
  64. detecti_cli-2.0.0.dist-info/entry_points.txt +3 -0
detecti/utils/http.py ADDED
@@ -0,0 +1,294 @@
1
+ """Centralized Asynchronous HTTP Client with retries, rate limiting, and backoff."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import random
8
+ import time
9
+ from typing import Any, Dict, Optional
10
+ import httpx
11
+ from detecti.config import settings
12
+
13
+ logger = logging.getLogger("detecti.http")
14
+
15
+
16
+ class AsyncHTTPClient:
17
+ """Production-grade asynchronous HTTP client with rate-limiting and retry logic."""
18
+
19
+ def __init__(
20
+ self,
21
+ timeout: Optional[float] = None,
22
+ max_retries: Optional[int] = None,
23
+ backoff_factor: Optional[float] = None,
24
+ concurrency_limit: Optional[int] = None,
25
+ headers: Optional[Dict[str, str]] = None,
26
+ ):
27
+ self.timeout = timeout or settings.http_timeout
28
+ self.max_retries = max_retries or settings.http_max_retries
29
+ self.backoff_factor = backoff_factor or settings.http_backoff_factor
30
+ self.concurrency_limit = concurrency_limit or settings.http_concurrency_limit
31
+
32
+ default_headers = {
33
+ "User-Agent": settings.user_agent,
34
+ "Accept": "application/json, text/plain, */*",
35
+ }
36
+ if headers:
37
+ default_headers.update(headers)
38
+
39
+ self._headers = default_headers
40
+ self._client: Optional[httpx.AsyncClient] = None
41
+ self._semaphore = asyncio.Semaphore(self.concurrency_limit)
42
+ self._domain_locks: Dict[str, asyncio.Lock] = {}
43
+ self._domain_last_request: Dict[str, float] = {}
44
+
45
+ async def get_client(self) -> httpx.AsyncClient:
46
+ """Get or initialize the underlying httpx.AsyncClient."""
47
+ if self._client is None or self._client.is_closed:
48
+ self._client = httpx.AsyncClient(
49
+ timeout=httpx.Timeout(self.timeout, connect=10.0),
50
+ headers=self._headers,
51
+ follow_redirects=True,
52
+ limits=httpx.Limits(
53
+ max_connections=50,
54
+ max_keepalive_connections=20,
55
+ keepalive_expiry=30.0,
56
+ ),
57
+ )
58
+ return self._client
59
+
60
+ async def close(self) -> None:
61
+ """Close the underlying client session."""
62
+ if self._client and not self._client.is_closed:
63
+ await self._client.aclose()
64
+ self._client = None
65
+
66
+ async def __aenter__(self) -> AsyncHTTPClient:
67
+ await self.get_client()
68
+ return self
69
+
70
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
71
+ await self.close()
72
+
73
+ def _get_domain_lock(self, host: str) -> asyncio.Lock:
74
+ if host not in self._domain_locks:
75
+ self._domain_locks[host] = asyncio.Lock()
76
+ return self._domain_locks[host]
77
+
78
+ async def _enforce_domain_rate_limit(self, url: str) -> None:
79
+ """Enforce specific delays for rate-limited endpoints like NVD or HackerTarget."""
80
+ parsed_url = httpx.URL(url)
81
+ host = parsed_url.host
82
+
83
+ delay = 0.0
84
+ if "services.nvd.nist.gov" in host:
85
+ delay = (
86
+ settings.nvd_delay_with_key
87
+ if settings.nvd_api_key
88
+ else settings.nvd_delay_without_key
89
+ )
90
+ elif "hackertarget.com" in host:
91
+ delay = settings.hackertarget_delay
92
+ elif "shodan.io" in host:
93
+ delay = settings.shodan_delay
94
+
95
+ if delay > 0:
96
+ lock = self._get_domain_lock(host)
97
+ async with lock:
98
+ now = time.monotonic()
99
+ last_time = self._domain_last_request.get(host, 0.0)
100
+ elapsed = now - last_time
101
+ if elapsed < delay:
102
+ wait_time = delay - elapsed
103
+ await asyncio.sleep(wait_time)
104
+ self._domain_last_request[host] = time.monotonic()
105
+
106
+ async def request(
107
+ self,
108
+ method: str,
109
+ url: str,
110
+ headers: Optional[Dict[str, str]] = None,
111
+ params: Optional[Dict[str, Any]] = None,
112
+ json: Optional[Any] = None,
113
+ data: Optional[Any] = None,
114
+ timeout: Optional[float] = None,
115
+ max_retries: Optional[int] = None,
116
+ max_retry_delay: float = 8.0,
117
+ raise_for_status: bool = True,
118
+ ) -> httpx.Response:
119
+ """Execute an HTTP request with automatic retries, backoff, and concurrency control."""
120
+ client = await self.get_client()
121
+ req_timeout = timeout or self.timeout
122
+ effective_retries = max_retries if max_retries is not None else self.max_retries
123
+
124
+ for attempt in range(1, effective_retries + 1):
125
+ await self._enforce_domain_rate_limit(url)
126
+
127
+ try:
128
+ async with self._semaphore:
129
+ response = await client.request(
130
+ method=method,
131
+ url=url,
132
+ headers=headers,
133
+ params=params,
134
+ json=json,
135
+ data=data,
136
+ timeout=req_timeout,
137
+ )
138
+
139
+ if response.status_code == 429:
140
+ retry_after = response.headers.get("Retry-After")
141
+ try:
142
+ sleep_time = float(retry_after) if retry_after else (self.backoff_factor * (2 ** (attempt - 1)) + random.uniform(0.5, 2.0))
143
+ except (ValueError, TypeError):
144
+ sleep_time = self.backoff_factor * (2 ** (attempt - 1)) + random.uniform(0.5, 2.0)
145
+
146
+ if sleep_time > max_retry_delay:
147
+ logger.warning(
148
+ f"HTTP 429 (Rate Limit) on {url}. Server requested {sleep_time:.1f}s delay (exceeds {max_retry_delay}s threshold). Skipping automatic wait."
149
+ )
150
+ if raise_for_status:
151
+ response.raise_for_status()
152
+ return response
153
+
154
+ if attempt < effective_retries:
155
+ logger.info(f"Rate limited (HTTP 429) on {url}. Backing off for {sleep_time:.1f}s (attempt {attempt}/{effective_retries})...")
156
+ await asyncio.sleep(sleep_time)
157
+ continue
158
+
159
+ if response.status_code in (500, 502, 503, 504):
160
+ if attempt < effective_retries:
161
+ sleep_time = self.backoff_factor * (2 ** (attempt - 1)) + random.uniform(0.1, 0.5)
162
+ await asyncio.sleep(sleep_time)
163
+ continue
164
+
165
+ if raise_for_status:
166
+ response.raise_for_status()
167
+
168
+ return response
169
+
170
+ except (httpx.ConnectError, httpx.ReadTimeout, httpx.ConnectTimeout, httpx.RemoteProtocolError) as exc:
171
+ if attempt >= effective_retries:
172
+ logger.warning(f"HTTP request to {url} failed after {effective_retries} attempts: {exc}")
173
+ raise
174
+ sleep_time = self.backoff_factor * (2 ** (attempt - 1)) + random.uniform(0.1, 0.5)
175
+ await asyncio.sleep(sleep_time)
176
+
177
+ raise httpx.RequestError(f"Failed to complete request to {url} after {effective_retries} attempts")
178
+
179
+ async def get(
180
+ self,
181
+ url: str,
182
+ headers: Optional[Dict[str, str]] = None,
183
+ params: Optional[Dict[str, Any]] = None,
184
+ timeout: Optional[float] = None,
185
+ max_retries: Optional[int] = None,
186
+ max_retry_delay: float = 8.0,
187
+ raise_for_status: bool = True,
188
+ ) -> httpx.Response:
189
+ """Convenience GET request."""
190
+ return await self.request(
191
+ method="GET",
192
+ url=url,
193
+ headers=headers,
194
+ params=params,
195
+ timeout=timeout,
196
+ max_retries=max_retries,
197
+ max_retry_delay=max_retry_delay,
198
+ raise_for_status=raise_for_status,
199
+ )
200
+
201
+ async def post(
202
+ self,
203
+ url: str,
204
+ headers: Optional[Dict[str, str]] = None,
205
+ params: Optional[Dict[str, Any]] = None,
206
+ json: Optional[Any] = None,
207
+ data: Optional[Any] = None,
208
+ timeout: Optional[float] = None,
209
+ max_retries: Optional[int] = None,
210
+ max_retry_delay: float = 8.0,
211
+ raise_for_status: bool = True,
212
+ ) -> httpx.Response:
213
+ """Convenience POST request."""
214
+ return await self.request(
215
+ method="POST",
216
+ url=url,
217
+ headers=headers,
218
+ params=params,
219
+ json=json,
220
+ data=data,
221
+ timeout=timeout,
222
+ max_retries=max_retries,
223
+ max_retry_delay=max_retry_delay,
224
+ raise_for_status=raise_for_status,
225
+ )
226
+
227
+ async def get_json(
228
+ self,
229
+ url: str,
230
+ headers: Optional[Dict[str, str]] = None,
231
+ params: Optional[Dict[str, Any]] = None,
232
+ timeout: Optional[float] = None,
233
+ max_retries: Optional[int] = None,
234
+ max_retry_delay: float = 8.0,
235
+ ) -> Optional[Any]:
236
+ """Fetch URL and parse JSON payload safely. Returns None on 404 or non-critical errors."""
237
+ try:
238
+ resp = await self.get(
239
+ url=url,
240
+ headers=headers,
241
+ params=params,
242
+ timeout=timeout,
243
+ max_retries=max_retries,
244
+ max_retry_delay=max_retry_delay,
245
+ raise_for_status=False,
246
+ )
247
+ if resp.status_code == 200:
248
+ return resp.json()
249
+ elif resp.status_code == 404:
250
+ return None
251
+ elif resp.status_code == 429:
252
+ logger.warning(f"HTTP 429 Rate Limit encountered for {url}")
253
+ return None
254
+ else:
255
+ logger.debug(f"HTTP {resp.status_code} for GET {url}")
256
+ return None
257
+ except Exception as err:
258
+ logger.debug(f"Error fetching JSON from {url}: {err}")
259
+ return None
260
+
261
+ async def post_json(
262
+ self,
263
+ url: str,
264
+ headers: Optional[Dict[str, str]] = None,
265
+ params: Optional[Dict[str, Any]] = None,
266
+ json: Optional[Any] = None,
267
+ data: Optional[Any] = None,
268
+ timeout: Optional[float] = None,
269
+ ) -> Optional[Any]:
270
+ """Send POST request and parse JSON payload safely. Returns None on 404 or non-critical errors."""
271
+ try:
272
+ resp = await self.post(
273
+ url=url,
274
+ headers=headers,
275
+ params=params,
276
+ json=json,
277
+ data=data,
278
+ timeout=timeout,
279
+ raise_for_status=False,
280
+ )
281
+ if resp.status_code == 200:
282
+ return resp.json()
283
+ elif resp.status_code == 404:
284
+ return None
285
+ else:
286
+ logger.debug(f"HTTP {resp.status_code} for POST {url}: {resp.text}")
287
+ return None
288
+ except Exception as err:
289
+ logger.debug(f"Error fetching JSON from POST {url}: {err}")
290
+ return None
291
+
292
+
293
+ # Global HTTP client instance
294
+ http_client = AsyncHTTPClient()
@@ -0,0 +1,378 @@
1
+ """Rich logger, console output formatters, and terminal UI utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import socket
7
+ from pathlib import Path
8
+ from typing import Any, List, Optional
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+ from rich.theme import Theme
14
+ from detecti.core.models import Finding, FindingType, HostResult, ScanResult, SeverityLevel
15
+
16
+ # Custom Rich theme
17
+ custom_theme = Theme({
18
+ "info": "cyan",
19
+ "warning": "yellow",
20
+ "error": "bold red",
21
+ "success": "bold green",
22
+ "critical": "bold white on red",
23
+ "high": "bold red",
24
+ "medium": "bold yellow",
25
+ "low": "bold blue",
26
+ "highlight": "bold magenta",
27
+ "muted": "dim white",
28
+ })
29
+
30
+ console = Console(theme=custom_theme)
31
+
32
+
33
+ def _read_banner_file() -> str:
34
+ """Read the banner from the 'banner' file in the current directory or package."""
35
+ candidate_paths = [
36
+ Path.cwd() / "banner",
37
+ Path.cwd() / "detecti-cli" / "banner",
38
+ Path.cwd() / "threattrack" / "banner",
39
+ Path(__file__).resolve().parent / "banner",
40
+ Path(__file__).resolve().parent.parent / "banner",
41
+ Path(__file__).resolve().parent.parent.parent / "banner",
42
+ ]
43
+ for p in candidate_paths:
44
+ if p.is_file():
45
+ try:
46
+ content = p.read_text(encoding="utf-8").rstrip()
47
+ if content:
48
+ return content
49
+ except Exception:
50
+ pass
51
+ return "DetecTI-CLI v2.0 - Cyber Lead Intelligence Engine\nExternal Attack Surface Management & Threat Intelligence CLI\nPowered by DetecTI Security"
52
+
53
+
54
+ def print_banner() -> None:
55
+ """Print the DetecTI-CLI ASCII logo banner directly from the 'banner' file."""
56
+ banner_content = _read_banner_file()
57
+ console.print(f"[bold cyan]{banner_content}[/bold cyan]\n", highlight=False)
58
+
59
+
60
+ def print_section_header(title: str) -> None:
61
+ """Print a clean section divider."""
62
+ console.print(f"\n[bold cyan]━━━ [white]{title}[/white] ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold cyan]")
63
+
64
+
65
+ def print_info(message: str) -> None:
66
+ """Print standard informational message."""
67
+ console.print(f" [info][*][/info] {message}")
68
+
69
+
70
+ def print_success(message: str) -> None:
71
+ """Print success message."""
72
+ console.print(f" [success][+][/success] {message}")
73
+
74
+
75
+ def print_warning(message: str) -> None:
76
+ """Print warning message."""
77
+ console.print(f" [warning][!][/warning] {message}")
78
+
79
+
80
+ def print_error(message: str) -> None:
81
+ """Print error message."""
82
+ console.print(f" [error][-][/error] {message}")
83
+
84
+
85
+ def render_host_dossier(host: HostResult) -> None:
86
+ """Render a dedicated, structured dossier for a specific host."""
87
+ loc_parts = [p for p in [host.country_name, host.city, host.region_code] if p]
88
+ loc_str = " • ".join(loc_parts) if loc_parts else "N/A"
89
+ org_str = host.org or host.isp or "N/A"
90
+ asn_str = f" ({host.asn})" if host.asn else ""
91
+
92
+ # Title with Host IP
93
+ console.print(f"\n[bold green]┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold green] [bold white on blue] IP: {host.ip} [/bold white on blue] [bold green]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓[/bold green]")
94
+ console.print(f" [cyan]Organization:[/cyan] {org_str}{asn_str}")
95
+ console.print(f" [cyan]Location:[/cyan] {loc_str}")
96
+ console.print(f" [cyan]OS:[/cyan] {host.os or 'N/A'}")
97
+ if host.hostnames:
98
+ console.print(f" [cyan]Hostnames:[/cyan] {', '.join(host.hostnames)}")
99
+ if host.domains:
100
+ console.print(f" [cyan]Domains:[/cyan] {', '.join(host.domains)}")
101
+
102
+ # 1. Ports Table for this Host
103
+ if host.ports:
104
+ port_table = Table(
105
+ show_header=True,
106
+ header_style="bold magenta",
107
+ show_lines=False,
108
+ title="[bold green]Open Ports & Running Services[/bold green]",
109
+ title_justify="left",
110
+ )
111
+ port_table.add_column("Port / Proto", style="bold cyan", width=14)
112
+ port_table.add_column("Service / Product", style="white")
113
+ port_table.add_column("Version", style="dim white", width=12)
114
+ port_table.add_column("Endpoint URL", style="cyan")
115
+ port_table.add_column("Source", style="green", width=18)
116
+
117
+ for p in sorted(host.ports, key=lambda x: x.port):
118
+ prod = p.product or p.service or "unknown"
119
+ port_label = f"{p.port}/{p.transport.upper()}"
120
+ src_str = p.source if p.sources else (host.source or "-")
121
+ port_table.add_row(
122
+ port_label,
123
+ prod,
124
+ p.version or "-",
125
+ p.url or "-",
126
+ src_str,
127
+ )
128
+ console.print(port_table)
129
+ else:
130
+ console.print(" [dim]No open ports identified for this host.[/dim]")
131
+
132
+ # 2. Vulnerabilities Table for this Host
133
+ if host.vulnerabilities:
134
+ vuln_table = Table(
135
+ show_header=True,
136
+ header_style="bold red",
137
+ show_lines=True,
138
+ title=f"[bold red]Identified Vulnerabilities ({len(host.vulnerabilities)} CVEs on {host.ip})[/bold red]",
139
+ title_justify="left",
140
+ )
141
+ vuln_table.add_column("CVE ID", style="bold white", width=16)
142
+ vuln_table.add_column("CWE Name", style="magenta", min_width=22)
143
+ vuln_table.add_column("CVSS Score", style="bold", width=14)
144
+ vuln_table.add_column("EPSS Risk", style="yellow", width=14)
145
+ vuln_table.add_column("CISA KEV", style="bold", width=12)
146
+ vuln_table.add_column("Public Exploits & PoCs", style="white")
147
+
148
+ for v in host.vulnerabilities:
149
+ sev_str = v.cvss_severity.value if hasattr(v.cvss_severity, "value") else str(v.cvss_severity)
150
+ sev_style = "critical" if sev_str == "CRITICAL" else "high" if sev_str == "HIGH" else "medium" if sev_str == "MEDIUM" else "low"
151
+ cvss_cell = f"[{sev_style}]{v.cvss_score or 'N/A'} ({sev_str})[/{sev_style}]"
152
+
153
+ epss_cell = f"{v.epss.epss_score * 100:.1f}%" if v.epss else "N/A"
154
+ kev_cell = "[bold white on red] YES [/bold white on red]" if v.in_cisa_kev else "[dim]No[/dim]"
155
+
156
+ exploits_info = []
157
+ for exp in v.exploits:
158
+ exploits_info.append(f"[bold red]{exp.source}:[/bold red] {exp.url}")
159
+
160
+ exploit_cell = "\n".join(exploits_info) if exploits_info else "[dim]None found[/dim]"
161
+ if v.cwe_name:
162
+ cwe_items = [c.strip() for c in v.cwe_name.split(",") if c.strip()]
163
+ cwe_cell = "\n".join(f"• {c}" for c in cwe_items) if len(cwe_items) > 1 else (cwe_items[0] if cwe_items else "N/A")
164
+ elif v.cwe_id:
165
+ cwe_cell = v.cwe_id
166
+ else:
167
+ cwe_cell = "[dim]N/A[/dim]"
168
+
169
+ vuln_table.add_row(
170
+ v.cve_id,
171
+ cwe_cell,
172
+ cvss_cell,
173
+ epss_cell,
174
+ kev_cell,
175
+ exploit_cell,
176
+ )
177
+ console.print(vuln_table)
178
+ else:
179
+ console.print(" [dim]No CVEs identified on this host.[/dim]")
180
+
181
+ console.print("[bold green]┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛[/bold green]")
182
+
183
+
184
+ def render_scan_output(result: ScanResult) -> None:
185
+ """Render comprehensive scan output organized per host and domain."""
186
+ # 1. Domain Surface Recon (Subdomains and Reverse WHOIS)
187
+ subdomains = [f for f in result.findings if f.type == FindingType.SUBDOMAIN and not f.host_ip]
188
+ assoc_domains = [f for f in result.findings if f.type == FindingType.ASSOCIATED_DOMAIN and not f.host_ip]
189
+
190
+ if subdomains or assoc_domains:
191
+ print_section_header("Domain Surface & Correlation")
192
+ if subdomains:
193
+ table = Table(title=f"Subdomains Discovered ({len(subdomains)})", show_header=True, header_style="bold cyan")
194
+ table.add_column("Subdomain", style="bold white")
195
+ table.add_column("Source", style="dim")
196
+ for sf in subdomains:
197
+ table.add_row(sf.value, sf.source)
198
+ console.print(table)
199
+
200
+ if assoc_domains:
201
+ table = Table(title=f"Associated Domains / Reverse WHOIS ({len(assoc_domains)})", show_header=True, header_style="bold blue")
202
+ table.add_column("Correlated Domain", style="bold white")
203
+ table.add_column("Source", style="dim")
204
+ for adf in assoc_domains:
205
+ table.add_row(adf.value, adf.source)
206
+ console.print(table)
207
+
208
+ # 2. Host Dossiers
209
+ if result.hosts:
210
+ print_section_header(f"Host Intelligence ({len(result.hosts)} Hosts Scanned)")
211
+ for host in result.hosts:
212
+ render_host_dossier(host)
213
+ elif result.target_type == "cve":
214
+ # Standalone CVE scan
215
+ print_section_header("Vulnerability Intelligence")
216
+ vuln_findings = [f for f in result.findings if f.type == FindingType.VULNERABILITY and f.vulnerability]
217
+ if vuln_findings:
218
+ table = Table(title=f"CVE Threat Intelligence for {result.target}", show_header=True, header_style="bold magenta", show_lines=True)
219
+ table.add_column("CVE ID", style="bold white", width=16)
220
+ table.add_column("CWE Name", style="magenta", min_width=22)
221
+ table.add_column("CVSS Score", style="bold", width=14)
222
+ table.add_column("EPSS Score", style="yellow", width=14)
223
+ table.add_column("CISA KEV", style="bold", width=12)
224
+ table.add_column("Exploits & PoCs", style="white")
225
+
226
+ for vf in vuln_findings:
227
+ v = vf.vulnerability
228
+ sev_str = v.cvss_severity.value if hasattr(v.cvss_severity, "value") else str(v.cvss_severity)
229
+ sev_style = "critical" if sev_str == "CRITICAL" else "high" if sev_str == "HIGH" else "medium" if sev_str == "MEDIUM" else "low"
230
+ cvss_cell = f"[{sev_style}]{v.cvss_score or 'N/A'} ({sev_str})[/{sev_style}]"
231
+ epss_cell = f"{v.epss.epss_score * 100:.2f}%" if v.epss else "N/A"
232
+ kev_cell = "[bold white on red] YES [/bold white on red]" if v.in_cisa_kev else "[dim]No[/dim]"
233
+
234
+ exploits_info = [f"[bold red]{e.source}:[/bold red] {e.url}" for e in v.exploits]
235
+ exploit_cell = "\n".join(exploits_info) if exploits_info else "[dim]None[/dim]"
236
+ if v.cwe_name:
237
+ cwe_items = [c.strip() for c in v.cwe_name.split(",") if c.strip()]
238
+ cwe_cell = "\n".join(f"• {c}" for c in cwe_items) if len(cwe_items) > 1 else (cwe_items[0] if cwe_items else "N/A")
239
+ elif v.cwe_id:
240
+ cwe_cell = v.cwe_id
241
+ else:
242
+ cwe_cell = "[dim]N/A[/dim]"
243
+
244
+ table.add_row(v.cve_id, cwe_cell, cvss_cell, epss_cell, kev_cell, exploit_cell)
245
+ console.print(table)
246
+ elif not subdomains and not assoc_domains:
247
+ print_warning("No findings were discovered for the specified target and filters.")
248
+
249
+
250
+ def get_real_ip() -> str:
251
+ """Get the primary local network IPv4 address of this machine (not 0.0.0.0)."""
252
+ try:
253
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
254
+ s.settimeout(0.5)
255
+ # Connect to public DNS IP to determine local routing interface IP
256
+ s.connect(("8.8.8.8", 80))
257
+ ip = s.getsockname()[0]
258
+ s.close()
259
+ if ip and not ip.startswith("127."):
260
+ return ip
261
+ except Exception:
262
+ pass
263
+ try:
264
+ hostname = socket.gethostname()
265
+ ip = socket.gethostbyname(hostname)
266
+ if ip and not ip.startswith("127."):
267
+ return ip
268
+ except Exception:
269
+ pass
270
+ return "127.0.0.1"
271
+
272
+
273
+ def render_summary_panel(summary: Any, elapsed: float) -> Panel:
274
+ """Generate a clean executive summary panel."""
275
+ lines = [
276
+ f"[bold white]Total Findings:[/bold white] {summary.total_findings}",
277
+ f"[bold green]Total Hosts Mapped:[/bold green] {summary.total_hosts_count}",
278
+ f"[cyan]Subdomains Discovered:[/cyan] {summary.subdomains_count}",
279
+ f"[blue]Associated Domains (Reverse WHOIS):[/blue] {summary.associated_domains_count}",
280
+ f"[green]Open Ports / Services:[/green] {summary.open_ports_count}",
281
+ f"[yellow]Vulnerabilities (CVEs):[/yellow] {summary.vulnerabilities_count} "
282
+ f"([bold red]{summary.critical_vulns_count} Critical[/bold red], "
283
+ f"[red]{summary.high_vulns_count} High[/red], "
284
+ f"[yellow]{summary.medium_vulns_count} Medium[/yellow], "
285
+ f"[blue]{summary.low_vulns_count} Low[/blue])",
286
+ f"[bold white on red] CISA Known Exploited (KEV): [/bold white on red] {summary.cisa_kev_count}",
287
+ f"[bold red]Exploits & PoCs Found:[/bold red] {summary.exploits_count}",
288
+ f"[dim]Scan duration: {elapsed:.2f} seconds[/dim]",
289
+ ]
290
+ return Panel(
291
+ "\n".join(lines),
292
+ title="[bold green]Executive Scan Summary[/bold green]",
293
+ border_style="green",
294
+ expand=False,
295
+ )
296
+
297
+
298
+ def render_executive_summary(result: ScanResult) -> None:
299
+ """Render high-impact executive summary focusing on key metrics, perimeter stats, and actionable dashboard access."""
300
+ is_cve = (result.target_type == "cve") or result.target.strip().upper().startswith("CVE-")
301
+
302
+ # 1. Executive Summary Panel
303
+ console.print("")
304
+ console.print(render_summary_panel(result.summary, result.elapsed_seconds))
305
+
306
+ # Runtime API & Recon Notices (e.g. Shodan [IP]: No information available for that IP.)
307
+ if result.warnings:
308
+ console.print("")
309
+ for w in result.warnings:
310
+ console.print(f" [warning][!][/warning] [yellow]{w}[/yellow]")
311
+
312
+ # 2. Critical & High-Impact Vulnerabilities Table (Only for standalone CVE lookups)
313
+ if is_cve:
314
+ all_vulns: List[tuple[str, Any]] = []
315
+ for host in result.hosts:
316
+ for v in host.vulnerabilities:
317
+ all_vulns.append((host.ip, v))
318
+
319
+ for f in result.findings:
320
+ if f.type == FindingType.VULNERABILITY and f.vulnerability and not f.host_ip:
321
+ all_vulns.append((result.target, f.vulnerability))
322
+
323
+ seen_keys = set()
324
+ unique_vulns = []
325
+ for h_ip, v in all_vulns:
326
+ k = (h_ip, v.cve_id)
327
+ if k not in seen_keys:
328
+ seen_keys.add(k)
329
+ unique_vulns.append((h_ip, v))
330
+
331
+ if unique_vulns:
332
+ print_section_header(f"CVE Threat Intelligence ({len(unique_vulns)} Vulnerability Details)")
333
+ table = Table(show_header=True, header_style="bold red", show_lines=True)
334
+ table.add_column("CVE ID", style="bold white", width=16)
335
+ table.add_column("Affected Target", style="bold cyan", width=18)
336
+ table.add_column("Severity / CVSS", style="bold", width=16)
337
+ table.add_column("EPSS Risk", style="yellow", width=12)
338
+ table.add_column("CISA KEV", style="bold", width=12)
339
+ table.add_column("Public PoCs & Weaponization", style="white")
340
+
341
+ for h_ip, v in unique_vulns:
342
+ sev_str = v.cvss_severity.value if hasattr(v.cvss_severity, "value") else str(v.cvss_severity)
343
+ sev_style = "critical" if "CRIT" in sev_str.upper() else "high" if "HIGH" in sev_str.upper() else "medium"
344
+ cvss_cell = f"[{sev_style}]{v.cvss_score or 'N/A'} ({sev_str})[/{sev_style}]"
345
+ epss_cell = f"{v.epss.epss_score * 100:.1f}%" if v.epss else "N/A"
346
+ kev_cell = "[bold white on red] YES [/bold white on red]" if v.in_cisa_kev else "[dim]No[/dim]"
347
+
348
+ exploits_info = [f"[bold red]{exp.source}:[/bold red] {exp.url}" for exp in v.exploits]
349
+ exploit_cell = "\n".join(exploits_info) if exploits_info else "[dim]None[/dim]"
350
+
351
+ table.add_row(v.cve_id, h_ip, cvss_cell, epss_cell, kev_cell, exploit_cell)
352
+
353
+ console.print(table)
354
+
355
+ # 3. Discovered Perimeter Highlights (For direct target scans)
356
+ if not is_cve:
357
+ subdomains = [f for f in result.findings if f.type == FindingType.SUBDOMAIN and not f.host_ip]
358
+ assoc_domains = [f for f in result.findings if f.type == FindingType.ASSOCIATED_DOMAIN and not f.host_ip]
359
+ if subdomains or assoc_domains:
360
+ summary_parts = []
361
+ if subdomains:
362
+ summary_parts.append(f"[bold cyan]{len(subdomains)} Subdomains[/bold cyan] (via crt.sh / DNS)")
363
+ if assoc_domains:
364
+ summary_parts.append(f"[bold blue]{len(assoc_domains)} Associated Domains[/bold blue] (via Reverse WHOIS)")
365
+ console.print(f"\n 🌐 [bold]Perimeter Intelligence:[/bold] {' • '.join(summary_parts)}")
366
+
367
+ # 4. DetecTIHound Web Dashboard Quick Access Callout (Only for asset/perimeter scans)
368
+ real_ip = get_real_ip()
369
+ port = 8000
370
+ console.print("")
371
+ dashboard_box = [
372
+ "[bold cyan]DetecTIHound — Interactive Attack Surface Graph[/bold cyan]",
373
+ "Explore full relational topology, technical banners, and active scans:",
374
+ f" 👉 [bold white]Local Access:[/bold white] [bold underline cyan]http://localhost:{port}[/bold underline cyan]",
375
+ f" 👉 [bold white]Network Access:[/bold white] [bold underline cyan]http://{real_ip}:{port}[/bold underline cyan]",
376
+ ]
377
+ console.print(Panel("\n".join(dashboard_box), border_style="cyan", expand=False))
378
+