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.
Files changed (45) hide show
  1. stackscan/__init__.py +5 -0
  2. stackscan/__main__.py +4 -0
  3. stackscan/analyzers/__init__.py +29 -0
  4. stackscan/analyzers/creds.py +218 -0
  5. stackscan/analyzers/cve.py +458 -0
  6. stackscan/analyzers/exposure.py +73 -0
  7. stackscan/analyzers/infra.py +114 -0
  8. stackscan/analyzers/security.py +26 -0
  9. stackscan/analyzers/services.py +285 -0
  10. stackscan/analyzers/tech.py +178 -0
  11. stackscan/cli.py +750 -0
  12. stackscan/config/__init__.py +19 -0
  13. stackscan/config/sigdb_loader.py +74 -0
  14. stackscan/config/sources.py +238 -0
  15. stackscan/core/__init__.py +3 -0
  16. stackscan/core/core.py +60 -0
  17. stackscan/data/builtin.sigdb +0 -0
  18. stackscan/data/cve.json.gz +0 -0
  19. stackscan/data/reekeer-logo.png +0 -0
  20. stackscan/data/subdomains.txt +522 -0
  21. stackscan/export.py +574 -0
  22. stackscan/net/__init__.py +22 -0
  23. stackscan/net/dns.py +168 -0
  24. stackscan/net/fingerprint.py +41 -0
  25. stackscan/net/geo.py +70 -0
  26. stackscan/net/ipinfo.py +94 -0
  27. stackscan/net/ports.py +219 -0
  28. stackscan/net/resolver.py +54 -0
  29. stackscan/net/subdomains.py +457 -0
  30. stackscan/net/tld.py +126 -0
  31. stackscan/net/tls.py +78 -0
  32. stackscan/render.py +541 -0
  33. stackscan/scan.py +545 -0
  34. stackscan/theme.py +23 -0
  35. stackscan/types/__init__.py +43 -0
  36. stackscan/types/models.py +18 -0
  37. stackscan/types/output.py +367 -0
  38. stackscan/utils/__init__.py +4 -0
  39. stackscan/utils/paths.py +10 -0
  40. stackscan/utils/urls.py +39 -0
  41. stackscan-2.1.0.dist-info/METADATA +341 -0
  42. stackscan-2.1.0.dist-info/RECORD +45 -0
  43. stackscan-2.1.0.dist-info/WHEEL +4 -0
  44. stackscan-2.1.0.dist-info/entry_points.txt +2 -0
  45. 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))