stackscan 2.1.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.
- stackscan/__init__.py +5 -0
- stackscan/__main__.py +4 -0
- stackscan/analyzers/__init__.py +29 -0
- stackscan/analyzers/creds.py +218 -0
- stackscan/analyzers/cve.py +458 -0
- stackscan/analyzers/exposure.py +73 -0
- stackscan/analyzers/infra.py +114 -0
- stackscan/analyzers/security.py +26 -0
- stackscan/analyzers/services.py +285 -0
- stackscan/analyzers/tech.py +178 -0
- stackscan/cli.py +750 -0
- stackscan/config/__init__.py +19 -0
- stackscan/config/sigdb_loader.py +74 -0
- stackscan/config/sources.py +238 -0
- stackscan/core/__init__.py +3 -0
- stackscan/core/core.py +60 -0
- stackscan/data/builtin.sigdb +0 -0
- stackscan/data/cve.json.gz +0 -0
- stackscan/data/reekeer-logo.png +0 -0
- stackscan/data/subdomains.txt +522 -0
- stackscan/export.py +574 -0
- stackscan/net/__init__.py +22 -0
- stackscan/net/dns.py +168 -0
- stackscan/net/fingerprint.py +41 -0
- stackscan/net/geo.py +70 -0
- stackscan/net/ipinfo.py +94 -0
- stackscan/net/ports.py +219 -0
- stackscan/net/resolver.py +54 -0
- stackscan/net/subdomains.py +457 -0
- stackscan/net/tld.py +126 -0
- stackscan/net/tls.py +78 -0
- stackscan/render.py +541 -0
- stackscan/scan.py +545 -0
- stackscan/theme.py +23 -0
- stackscan/types/__init__.py +43 -0
- stackscan/types/models.py +18 -0
- stackscan/types/output.py +367 -0
- stackscan/utils/__init__.py +4 -0
- stackscan/utils/paths.py +10 -0
- stackscan/utils/urls.py +39 -0
- stackscan-2.1.0.dist-info/METADATA +341 -0
- stackscan-2.1.0.dist-info/RECORD +45 -0
- stackscan-2.1.0.dist-info/WHEEL +4 -0
- stackscan-2.1.0.dist-info/entry_points.txt +2 -0
- stackscan-2.1.0.dist-info/licenses/LICENSE +18 -0
stackscan/render.py
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from urllib.parse import urljoin
|
|
6
|
+
|
|
7
|
+
from rich.console import Console, Group, RenderableType
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from stackscan import theme
|
|
13
|
+
from stackscan.analyzers import port_category
|
|
14
|
+
from stackscan.types import CveMatch, IpInfo, ScanReport
|
|
15
|
+
from stackscan.utils import host_of
|
|
16
|
+
|
|
17
|
+
_SectionBuilder = Callable[[ScanReport], "RenderableType | None"]
|
|
18
|
+
_LABEL = f"bold {theme.ACCENT}"
|
|
19
|
+
_HEADER = f"bold {theme.ACCENT_2}"
|
|
20
|
+
BANNER = "\n ____ ____ __ ___ __ _ ____ ___ __ __ _\n/ ___)(_ _)/ _\\ / __)( / )/ ___) / __) / _\\ ( ( \\\n\\___ \\ )( / \\( (__ ) ( \\___ \\( (__ / \\/ /\n(____/ (__)\\_/\\_/ \\___)(__\\_)(____/ \\___)\\_/\\_/\\_)__)"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def render_banner(console: Console) -> None:
|
|
24
|
+
console.print(Text(BANNER, style=f"bold {theme.ACCENT}"), highlight=False)
|
|
25
|
+
console.print(
|
|
26
|
+
Text(
|
|
27
|
+
"Created by reekeer · https://github.com/reekeer/stackscan",
|
|
28
|
+
style=f"dim {theme.ACCENT}",
|
|
29
|
+
),
|
|
30
|
+
highlight=False,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _severity_text(cve: CveMatch) -> Text:
|
|
35
|
+
color = theme.SEVERITY.get(cve.severity.upper(), theme.MUTED)
|
|
36
|
+
label = cve.severity.upper()
|
|
37
|
+
if cve.cvss is not None:
|
|
38
|
+
label = f"{label} {cve.cvss:.1f}"
|
|
39
|
+
if cve.severity.upper() == "CRITICAL":
|
|
40
|
+
return Text(f" {label} ", style=f"bold white on {theme.DANGER}")
|
|
41
|
+
return Text(f" {label} ", style=f"bold {color}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _fmt_elapsed(seconds: float) -> str:
|
|
45
|
+
if seconds < 90:
|
|
46
|
+
return f"{seconds:.2f}s"
|
|
47
|
+
minutes = int(seconds // 60)
|
|
48
|
+
secs = int(seconds % 60)
|
|
49
|
+
return f"{minutes}m {secs}s ({seconds:.0f}s)"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _version_key(version: str) -> tuple[tuple[int, str], ...]:
|
|
53
|
+
|
|
54
|
+
parts: list[tuple[int, str]] = []
|
|
55
|
+
for chunk in re.split(r"[.\-_]", version.strip()):
|
|
56
|
+
match = re.match(r"(\d*)(.*)", chunk)
|
|
57
|
+
number = int(match.group(1)) if match and match.group(1) else 0
|
|
58
|
+
suffix = (match.group(2) if match else "").lower()
|
|
59
|
+
parts.append((number, suffix))
|
|
60
|
+
return tuple(parts)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _ip_sort_key(ip: str) -> tuple[int, ...]:
|
|
64
|
+
try:
|
|
65
|
+
import ipaddress
|
|
66
|
+
|
|
67
|
+
return (0, int(ipaddress.ip_address(ip.split("%", 1)[0])))
|
|
68
|
+
except ValueError:
|
|
69
|
+
return (1, 0)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _sorted_ips(ips: tuple[str, ...]) -> tuple[str, ...]:
|
|
73
|
+
return tuple(sorted(ips, key=_ip_sort_key))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _confidence_bar(pct: int) -> Text:
|
|
77
|
+
filled = round(pct / 10)
|
|
78
|
+
bar = "█" * filled + "░" * (10 - filled)
|
|
79
|
+
if pct >= 80:
|
|
80
|
+
color = theme.SUCCESS
|
|
81
|
+
elif pct >= 50:
|
|
82
|
+
color = theme.WARN
|
|
83
|
+
else:
|
|
84
|
+
color = theme.DANGER
|
|
85
|
+
return Text(f"{bar} {pct}%", style=color)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _network_section(report: ScanReport) -> RenderableType | None:
|
|
89
|
+
net = report.network
|
|
90
|
+
if net is None:
|
|
91
|
+
return None
|
|
92
|
+
grid = Table.grid(padding=(0, 1))
|
|
93
|
+
grid.add_column(style="bold cyan", no_wrap=True, justify="right")
|
|
94
|
+
grid.add_column(overflow="fold")
|
|
95
|
+
|
|
96
|
+
def row(label: str, values: tuple[str, ...]) -> None:
|
|
97
|
+
if values:
|
|
98
|
+
grid.add_row(label, ", ".join(values))
|
|
99
|
+
|
|
100
|
+
row("IPv4", _sorted_ips(net.ipv4))
|
|
101
|
+
row("IPv6", _sorted_ips(net.ipv6))
|
|
102
|
+
row("CNAME", net.cname)
|
|
103
|
+
if net.reverse_dns:
|
|
104
|
+
grid.add_row("PTR", ", ".join((f"{ip} -> {name}" for ip, name in net.reverse_dns.items())))
|
|
105
|
+
row("MX", net.mx)
|
|
106
|
+
row("NS", net.ns)
|
|
107
|
+
row("TXT", net.txt)
|
|
108
|
+
row("SOA", net.soa)
|
|
109
|
+
row("CAA", net.caa)
|
|
110
|
+
if net.domains:
|
|
111
|
+
grid.add_row("Domains", ", ".join(sorted(set(net.domains))))
|
|
112
|
+
if net.geo:
|
|
113
|
+
parts = [f"{ip}: {', '.join(v for v in data.values())}" for ip, data in net.geo.items()]
|
|
114
|
+
grid.add_row("Geo", "; ".join(parts))
|
|
115
|
+
if not grid.row_count:
|
|
116
|
+
return None
|
|
117
|
+
return grid
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _infra_section(report: ScanReport) -> RenderableType | None:
|
|
121
|
+
infra = report.infra
|
|
122
|
+
grid = Table.grid(padding=(0, 1))
|
|
123
|
+
grid.add_column(style="bold cyan", no_wrap=True, justify="right")
|
|
124
|
+
grid.add_column(overflow="fold")
|
|
125
|
+
if infra.cdn:
|
|
126
|
+
grid.add_row("CDN", ", ".join(infra.cdn))
|
|
127
|
+
if infra.waf:
|
|
128
|
+
grid.add_row("WAF", Text(", ".join(infra.waf), style="green"))
|
|
129
|
+
if infra.server:
|
|
130
|
+
grid.add_row("Server", ", ".join(infra.server))
|
|
131
|
+
if infra.proxy:
|
|
132
|
+
grid.add_row("Proxy", ", ".join(infra.proxy))
|
|
133
|
+
for note in infra.notes:
|
|
134
|
+
grid.add_row("Note", Text(note, style="dim"))
|
|
135
|
+
if not grid.row_count:
|
|
136
|
+
return None
|
|
137
|
+
return grid
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
_ACME_CAS: tuple[str, ...] = (
|
|
141
|
+
"let's encrypt",
|
|
142
|
+
"lets encrypt",
|
|
143
|
+
"zerossl",
|
|
144
|
+
"buypass",
|
|
145
|
+
"google trust services",
|
|
146
|
+
"actalis free",
|
|
147
|
+
)
|
|
148
|
+
_COMMERCIAL_CAS: tuple[str, ...] = (
|
|
149
|
+
"digicert",
|
|
150
|
+
"globalsign",
|
|
151
|
+
"sectigo",
|
|
152
|
+
"comodo",
|
|
153
|
+
"godaddy",
|
|
154
|
+
"entrust",
|
|
155
|
+
"thawte",
|
|
156
|
+
"geotrust",
|
|
157
|
+
"rapidssl",
|
|
158
|
+
"certum",
|
|
159
|
+
"starfield",
|
|
160
|
+
"network solutions",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _issuer_org(issuer: str) -> str:
|
|
165
|
+
|
|
166
|
+
fields = dict(part.split("=", 1) for part in issuer.split(", ") if "=" in part)
|
|
167
|
+
return (
|
|
168
|
+
fields.get("organizationName")
|
|
169
|
+
or fields.get("O")
|
|
170
|
+
or fields.get("commonName")
|
|
171
|
+
or fields.get("CN")
|
|
172
|
+
or issuer
|
|
173
|
+
).strip()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _ca_kind(issuer: str) -> str | None:
|
|
177
|
+
blob = issuer.lower()
|
|
178
|
+
if any(ca in blob for ca in _ACME_CAS):
|
|
179
|
+
return "ACME (free / auto-renewed)"
|
|
180
|
+
if "amazon" in blob:
|
|
181
|
+
return "Amazon ACM (free, AWS)"
|
|
182
|
+
if any(ca in blob for ca in _COMMERCIAL_CAS):
|
|
183
|
+
return "commercial (purchased)"
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _cert_expiry(not_after: str | None) -> Text | None:
|
|
188
|
+
if not not_after:
|
|
189
|
+
return None
|
|
190
|
+
import ssl
|
|
191
|
+
import time
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
remaining = ssl.cert_time_to_seconds(not_after) - time.time()
|
|
195
|
+
except (ValueError, OverflowError):
|
|
196
|
+
return Text(not_after)
|
|
197
|
+
days = int(remaining // 86400)
|
|
198
|
+
if days < 0:
|
|
199
|
+
return Text(f"{not_after} ({-days}d EXPIRED)", style=f"bold {theme.DANGER}")
|
|
200
|
+
if days <= 21:
|
|
201
|
+
return Text(f"{not_after} (expires in {days}d)", style=theme.WARN)
|
|
202
|
+
return Text(f"{not_after} (in {days}d)")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _tls_section(report: ScanReport) -> RenderableType | None:
|
|
206
|
+
tls = report.tls
|
|
207
|
+
if tls is None:
|
|
208
|
+
return None
|
|
209
|
+
grid = Table.grid(padding=(0, 1))
|
|
210
|
+
grid.add_column(style="bold cyan", no_wrap=True, justify="right")
|
|
211
|
+
grid.add_column(overflow="fold")
|
|
212
|
+
if tls.issuer:
|
|
213
|
+
ca_kind = _ca_kind(tls.issuer)
|
|
214
|
+
issued_by = _issuer_org(tls.issuer)
|
|
215
|
+
if ca_kind:
|
|
216
|
+
issued_by += f" · {ca_kind}"
|
|
217
|
+
grid.add_row("Issued by", issued_by)
|
|
218
|
+
expiry = _cert_expiry(tls.not_after)
|
|
219
|
+
if expiry is not None:
|
|
220
|
+
grid.add_row("Expires", expiry)
|
|
221
|
+
if tls.not_before:
|
|
222
|
+
grid.add_row("Issued", Text(tls.not_before, style=theme.MUTED))
|
|
223
|
+
proto = " ".join(filter(None, (tls.protocol, tls.cipher)))
|
|
224
|
+
if proto:
|
|
225
|
+
grid.add_row("Cipher", proto)
|
|
226
|
+
if tls.alpn:
|
|
227
|
+
grid.add_row("ALPN", tls.alpn)
|
|
228
|
+
if tls.subject_alt_names:
|
|
229
|
+
sans = ", ".join(tls.subject_alt_names[:8])
|
|
230
|
+
if len(tls.subject_alt_names) > 8:
|
|
231
|
+
sans += f" (+{len(tls.subject_alt_names) - 8} more)"
|
|
232
|
+
grid.add_row("SANs", sans)
|
|
233
|
+
if report.protocols:
|
|
234
|
+
grid.add_row("HTTP", " · ".join(report.protocols))
|
|
235
|
+
if not grid.row_count:
|
|
236
|
+
return None
|
|
237
|
+
return grid
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _tech_section(report: ScanReport) -> RenderableType | None:
|
|
241
|
+
|
|
242
|
+
grouped = report.by_category()
|
|
243
|
+
|
|
244
|
+
ordered = sorted(
|
|
245
|
+
report.software, key=lambda sw: (sw.name.lower(), _version_key(sw.version or ""))
|
|
246
|
+
)
|
|
247
|
+
software = [f"{sw.name} {sw.version}" if sw.version else sw.name for sw in ordered]
|
|
248
|
+
software = list(dict.fromkeys(software))
|
|
249
|
+
if not grouped and not software:
|
|
250
|
+
return None
|
|
251
|
+
grid = Table.grid(padding=(0, 1))
|
|
252
|
+
grid.add_column(style="bold magenta", no_wrap=True, justify="right")
|
|
253
|
+
grid.add_column(overflow="fold")
|
|
254
|
+
for category in sorted(grouped):
|
|
255
|
+
labels: list[str] = []
|
|
256
|
+
for tech in report.technologies:
|
|
257
|
+
if category in (tech.categories or ("uncategorized",)):
|
|
258
|
+
label = tech.name
|
|
259
|
+
if tech.location and tech.location != host_of(report.url):
|
|
260
|
+
label += f" @{tech.location}"
|
|
261
|
+
labels.append(label)
|
|
262
|
+
grid.add_row(category, ", ".join(sorted(set(labels))))
|
|
263
|
+
if software:
|
|
264
|
+
grid.add_row("versions", ", ".join(software))
|
|
265
|
+
return grid
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _sev_text(severity: str) -> Text:
|
|
269
|
+
color = theme.SEVERITY.get(severity.upper(), theme.MUTED)
|
|
270
|
+
return Text(f" {severity.upper()} ", style=f"bold {color}")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _service_cell(product: str, service: str | None) -> str:
|
|
274
|
+
|
|
275
|
+
name = (service or "").strip()
|
|
276
|
+
if product and name:
|
|
277
|
+
return f"{product} ({name})"
|
|
278
|
+
return product or name or "-"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _services_section(report: ScanReport) -> RenderableType | None:
|
|
282
|
+
|
|
283
|
+
ports = report.ports.ports if report.ports else ()
|
|
284
|
+
tech_services = [s for s in report.services if not s.evidence.startswith("port ")]
|
|
285
|
+
if not ports and not tech_services:
|
|
286
|
+
if report.ports is not None:
|
|
287
|
+
return Text(f"no services found ({report.ports.scanner})", style="dim")
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
table = Table(box=None, pad_edge=False)
|
|
291
|
+
table.add_column("Port", style="bold cyan", justify="right", no_wrap=True)
|
|
292
|
+
table.add_column("IP / Host", overflow="fold")
|
|
293
|
+
table.add_column("Service", overflow="fold")
|
|
294
|
+
table.add_column("Risk", no_wrap=True)
|
|
295
|
+
|
|
296
|
+
ordered_ports = sorted(ports, key=lambda p: (p.port, _ip_sort_key(p.host or "")))
|
|
297
|
+
for port in ordered_ports:
|
|
298
|
+
_category, severity = port_category(port.port, port.service)
|
|
299
|
+
product = " ".join(filter(None, (port.product, port.version)))
|
|
300
|
+
table.add_row(
|
|
301
|
+
f"{port.port}/{port.protocol}",
|
|
302
|
+
port.host or "-",
|
|
303
|
+
_service_cell(product, port.service),
|
|
304
|
+
_sev_text(severity),
|
|
305
|
+
)
|
|
306
|
+
for svc in tech_services:
|
|
307
|
+
table.add_row("-", "-", _service_cell(svc.name, svc.kind), _sev_text(svc.severity))
|
|
308
|
+
|
|
309
|
+
if report.ports is not None:
|
|
310
|
+
return Group(table, Text(f"via {report.ports.scanner}", style="dim"))
|
|
311
|
+
return table
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _fmt_locations(locations: tuple[str, ...]) -> str:
|
|
315
|
+
if not locations:
|
|
316
|
+
return "-"
|
|
317
|
+
shown = ", ".join(locations[:2])
|
|
318
|
+
if len(locations) > 2:
|
|
319
|
+
shown += f" +{len(locations) - 2}"
|
|
320
|
+
return shown
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _cve_section(report: ScanReport) -> RenderableType | None:
|
|
324
|
+
if not report.cves:
|
|
325
|
+
return None
|
|
326
|
+
table = Table(box=None, expand=True, pad_edge=False)
|
|
327
|
+
table.add_column("Severity", no_wrap=True)
|
|
328
|
+
table.add_column("CVE", style="bold", no_wrap=True)
|
|
329
|
+
table.add_column("Affects", no_wrap=True)
|
|
330
|
+
table.add_column("Where", overflow="fold")
|
|
331
|
+
table.add_column("Source", overflow="fold")
|
|
332
|
+
table.add_column("Confidence", no_wrap=True)
|
|
333
|
+
table.add_column("Summary", overflow="fold")
|
|
334
|
+
for cve in report.cves:
|
|
335
|
+
affects = f"{cve.product} {cve.version}" if cve.version else cve.product
|
|
336
|
+
summary = cve.summary if len(cve.summary) <= 80 else cve.summary[:77] + "..."
|
|
337
|
+
table.add_row(
|
|
338
|
+
_severity_text(cve),
|
|
339
|
+
cve.id,
|
|
340
|
+
affects,
|
|
341
|
+
_fmt_locations(cve.locations),
|
|
342
|
+
_fmt_locations(cve.sources),
|
|
343
|
+
_confidence_bar(cve.confidence),
|
|
344
|
+
summary,
|
|
345
|
+
)
|
|
346
|
+
return table
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _ipinfo_section(report: ScanReport) -> RenderableType | None:
|
|
350
|
+
if not report.ip_info:
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
unique: dict[str, IpInfo] = {}
|
|
354
|
+
for info in report.ip_info:
|
|
355
|
+
unique.setdefault(info.ip, info)
|
|
356
|
+
ordered = sorted(unique.values(), key=lambda i: _ip_sort_key(i.ip))
|
|
357
|
+
|
|
358
|
+
table = Table(box=None, pad_edge=False)
|
|
359
|
+
table.add_column("IP", style="bold cyan", no_wrap=True)
|
|
360
|
+
table.add_column("Location", overflow="fold")
|
|
361
|
+
table.add_column("Org / ISP", overflow="fold")
|
|
362
|
+
table.add_column("ASN", no_wrap=True)
|
|
363
|
+
table.add_column("Source", overflow="fold")
|
|
364
|
+
for info in ordered:
|
|
365
|
+
location = ", ".join(v for v in (info.city, info.country) if v) or "-"
|
|
366
|
+
org = info.org or info.isp or "-"
|
|
367
|
+
if info.is_cdn:
|
|
368
|
+
org = f"{org} [dim](CDN/proxy)[/dim]"
|
|
369
|
+
table.add_row(info.ip, location, org, info.asn or "-", info.source or "-")
|
|
370
|
+
return table
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _creds_section(report: ScanReport) -> RenderableType | None:
|
|
374
|
+
if not report.creds:
|
|
375
|
+
return None
|
|
376
|
+
table = Table(box=None, pad_edge=False, expand=True)
|
|
377
|
+
table.add_column("Target", style="bold cyan", no_wrap=True)
|
|
378
|
+
table.add_column("Finding", no_wrap=True)
|
|
379
|
+
table.add_column("Detail", overflow="fold")
|
|
380
|
+
for finding in report.creds:
|
|
381
|
+
if finding.kind == "default-creds":
|
|
382
|
+
label = Text(
|
|
383
|
+
f" DEFAULT CREDS {finding.username}:{finding.password} ", style="bold white on red"
|
|
384
|
+
)
|
|
385
|
+
elif finding.kind == "open-no-auth":
|
|
386
|
+
label = Text(" OPEN / NO AUTH ", style="bold white on red")
|
|
387
|
+
else:
|
|
388
|
+
label = Text("auth required", style="dim")
|
|
389
|
+
table.add_row(finding.target, label, f"{finding.service} — {finding.detail}")
|
|
390
|
+
return table
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _subdomains_section(report: ScanReport) -> RenderableType | None:
|
|
394
|
+
if not report.subdomains:
|
|
395
|
+
return None
|
|
396
|
+
table = Table(box=None, pad_edge=False)
|
|
397
|
+
table.add_column("Subdomain", style="bold cyan", overflow="fold")
|
|
398
|
+
table.add_column("Addresses", overflow="fold")
|
|
399
|
+
table.add_column("Source", style="dim", no_wrap=True)
|
|
400
|
+
for sub in report.subdomains:
|
|
401
|
+
addrs = ", ".join(sub.addresses) if sub.addresses else "(no A record)"
|
|
402
|
+
table.add_row(sub.name, addrs, sub.source)
|
|
403
|
+
return table
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _security_section(report: ScanReport) -> RenderableType | None:
|
|
407
|
+
sec = report.security
|
|
408
|
+
if not sec.present and (not sec.missing):
|
|
409
|
+
return None
|
|
410
|
+
grid = Table.grid(padding=(0, 1))
|
|
411
|
+
grid.add_column(style="bold cyan", no_wrap=True, justify="right")
|
|
412
|
+
grid.add_column(overflow="fold")
|
|
413
|
+
if sec.present:
|
|
414
|
+
grid.add_row("Present", Text(", ".join(sorted(sec.present)), style="green"))
|
|
415
|
+
if sec.missing:
|
|
416
|
+
grid.add_row("Missing", Text(", ".join(sec.missing), style="red"))
|
|
417
|
+
return grid
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _exposure_section(report: ScanReport) -> RenderableType | None:
|
|
421
|
+
exp = report.exposure
|
|
422
|
+
if exp is None:
|
|
423
|
+
return None
|
|
424
|
+
base = report.final_url or report.url
|
|
425
|
+
|
|
426
|
+
def url_for(resource: str, path: str) -> str:
|
|
427
|
+
return exp.urls.get(resource) or urljoin(base, path)
|
|
428
|
+
|
|
429
|
+
rows: list[tuple[str, str, str]] = []
|
|
430
|
+
if exp.git_exposed:
|
|
431
|
+
rows.append((".git EXPOSED", url_for(".git/HEAD", "/.git/HEAD"), "danger"))
|
|
432
|
+
if exp.robots_txt:
|
|
433
|
+
rows.append(("robots.txt", url_for("robots.txt", "/robots.txt"), "ok"))
|
|
434
|
+
if exp.sitemap:
|
|
435
|
+
rows.append(("sitemap.xml", url_for("sitemap.xml", "/sitemap.xml"), "ok"))
|
|
436
|
+
if exp.security_txt:
|
|
437
|
+
rows.append(("security.txt", url_for("security.txt", "/.well-known/security.txt"), "ok"))
|
|
438
|
+
if not rows:
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
grid = Table.grid(padding=(0, 2))
|
|
442
|
+
grid.add_column(no_wrap=True)
|
|
443
|
+
grid.add_column(overflow="fold")
|
|
444
|
+
for label, url, kind in rows:
|
|
445
|
+
style = "bold white on red" if kind == "danger" else "bold cyan"
|
|
446
|
+
grid.add_row(Text(label, style=style), Text(url, style="dim"))
|
|
447
|
+
return grid
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
_SECTIONS: tuple[tuple[str, _SectionBuilder], ...] = (
|
|
451
|
+
("Network / DNS", _network_section),
|
|
452
|
+
("IP intelligence", _ipinfo_section),
|
|
453
|
+
("Infrastructure", _infra_section),
|
|
454
|
+
("TLS / Protocol", _tls_section),
|
|
455
|
+
("Technologies & Software", _tech_section),
|
|
456
|
+
("Vulnerabilities (CVE)", _cve_section),
|
|
457
|
+
("Services & open ports", _services_section),
|
|
458
|
+
("Default creds / open devices", _creds_section),
|
|
459
|
+
("Subdomains", _subdomains_section),
|
|
460
|
+
("Security headers", _security_section),
|
|
461
|
+
("Exposure", _exposure_section),
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _report_panel(report: ScanReport) -> Panel:
|
|
466
|
+
status = f"[green]{report.status}[/green]" if report.status else "[red]—[/red]"
|
|
467
|
+
header = Table.grid(expand=True)
|
|
468
|
+
header.add_column(overflow="fold")
|
|
469
|
+
header.add_column(justify="right", no_wrap=True)
|
|
470
|
+
right = f"status {status}"
|
|
471
|
+
if report.elapsed is not None:
|
|
472
|
+
right += f" · [cyan]{_fmt_elapsed(report.elapsed)}[/cyan]"
|
|
473
|
+
ips: list[str] = []
|
|
474
|
+
if report.network is not None:
|
|
475
|
+
ips.extend(report.network.ipv4)
|
|
476
|
+
ips.extend(report.network.ipv6)
|
|
477
|
+
left = f"[bold]{report.final_url or report.url}[/bold]"
|
|
478
|
+
if ips:
|
|
479
|
+
left += f"\n[dim]{', '.join(ips)}[/dim]"
|
|
480
|
+
header.add_row(left, right)
|
|
481
|
+
blocks: list[RenderableType] = [header]
|
|
482
|
+
if report.error:
|
|
483
|
+
blocks.append(Text(f"error: {report.error}", style="red"))
|
|
484
|
+
worst = _worst_severity(report)
|
|
485
|
+
for title, builder in _SECTIONS:
|
|
486
|
+
rendered = builder(report)
|
|
487
|
+
if rendered is None:
|
|
488
|
+
continue
|
|
489
|
+
blocks.append(Text())
|
|
490
|
+
blocks.append(Text(f"▸ {title}", style="bold"))
|
|
491
|
+
blocks.append(rendered)
|
|
492
|
+
danger = worst in ("CRITICAL", "HIGH") or any(
|
|
493
|
+
f.kind in ("default-creds", "open-no-auth") for f in report.creds
|
|
494
|
+
)
|
|
495
|
+
border = theme.DANGER if danger else theme.ACCENT
|
|
496
|
+
return Panel(Group(*blocks), border_style=border, padding=(1, 2))
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _is_unreachable(report: ScanReport) -> bool:
|
|
500
|
+
return bool(report.error) and report.status is None and (not _has_findings(report))
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _unreachable_line(report: ScanReport) -> Text:
|
|
504
|
+
line = Text()
|
|
505
|
+
line.append(" [x] ", style=f"bold {theme.DANGER}")
|
|
506
|
+
line.append(report.url, style="bold")
|
|
507
|
+
line.append(" — site unavailable", style=theme.MUTED)
|
|
508
|
+
return line
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _worst_severity(report: ScanReport) -> str | None:
|
|
512
|
+
order = ["CRITICAL", "HIGH", "MEDIUM", "LOW"]
|
|
513
|
+
present = {cve.severity.upper() for cve in report.cves}
|
|
514
|
+
for level in order:
|
|
515
|
+
if level in present:
|
|
516
|
+
return level
|
|
517
|
+
return None
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _has_findings(report: ScanReport) -> bool:
|
|
521
|
+
return bool(
|
|
522
|
+
report.technologies
|
|
523
|
+
or report.infra.server
|
|
524
|
+
or report.infra.cdn
|
|
525
|
+
or report.cves
|
|
526
|
+
or (report.ports and report.ports.ports)
|
|
527
|
+
or report.subdomains
|
|
528
|
+
or report.ip_info
|
|
529
|
+
or report.creds
|
|
530
|
+
or (report.network and (report.network.ipv4 or report.network.ipv6))
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def render_reports(reports: list[ScanReport], console: Console, *, show_empty: bool) -> None:
|
|
535
|
+
for report in reports:
|
|
536
|
+
if _is_unreachable(report):
|
|
537
|
+
console.print(_unreachable_line(report))
|
|
538
|
+
continue
|
|
539
|
+
if not show_empty and (not _has_findings(report)) and (not report.error):
|
|
540
|
+
continue
|
|
541
|
+
console.print(_report_panel(report))
|