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/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()
|
detecti/utils/logger.py
ADDED
|
@@ -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
|
+
|