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/cli.py ADDED
@@ -0,0 +1,750 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import sys
7
+ import time
8
+ from collections.abc import Callable, Iterable
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from stackscan import __version__, theme
16
+ from stackscan.analyzers import TechAnalyzer
17
+ from stackscan.config import NoSignaturesError, SourceError, SourceStore, build_matchers
18
+ from stackscan.net import GeoProvider, nmap_available
19
+ from stackscan.render import render_banner, render_reports
20
+ from stackscan.scan import ScanOptions, scan_target
21
+ from stackscan.types import DetectedTech, ScanReport
22
+ from stackscan.utils import expand_cidr, is_cidr, normalize_url
23
+
24
+ DEFAULT_TIMEOUT = 12.0
25
+ DEFAULT_MAX_BYTES = 1000000
26
+ DEFAULT_CONCURRENCY = 10
27
+ DEFAULT_WORKERS = 350
28
+ DEFAULT_USER_AGENT = "stackscan/2.0 (+https://github.com/reekeer/stackscan)"
29
+
30
+
31
+ class _HelpAction(argparse.Action):
32
+ def __call__(
33
+ self,
34
+ parser: argparse.ArgumentParser,
35
+ namespace: argparse.Namespace,
36
+ values: object,
37
+ option_string: str | None = None,
38
+ ) -> None:
39
+ render_banner(Console())
40
+ parser.print_help()
41
+ parser.exit()
42
+
43
+
44
+ def _build_scan_parser() -> argparse.ArgumentParser:
45
+ parser = argparse.ArgumentParser(
46
+ prog="stackscan",
47
+ description="Full web stack analyzer (technologies, edge infra, network, exposure).",
48
+ add_help=False,
49
+ )
50
+ parser.add_argument(
51
+ "-h", "--help", action=_HelpAction, nargs=0, help="Show this help message and exit."
52
+ )
53
+
54
+ parser.add_argument("targets", nargs="*", help="Target URLs or hostnames.")
55
+ parser.add_argument("-f", "--file", dest="file", type=Path, help="File with targets, one/line.")
56
+ parser.add_argument("--sigdb", dest="sigdb", help="Explicit .sigdb path (overrides default).")
57
+ parser.add_argument("--no-sources", action="store_true", help="Ignore configured sources.")
58
+ parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, help="Request timeout.")
59
+ parser.add_argument("--user-agent", dest="user_agent", default=DEFAULT_USER_AGENT)
60
+ parser.add_argument("--insecure", action="store_true", help="Disable TLS verification.")
61
+ parser.add_argument("--max-bytes", dest="max_bytes", type=int, default=DEFAULT_MAX_BYTES)
62
+ parser.add_argument(
63
+ "--concurrency", type=int, default=DEFAULT_CONCURRENCY, help="Concurrent targets."
64
+ )
65
+ parser.add_argument("--geoip-db", dest="geoip_db", help="MaxMind .mmdb for IP geolocation.")
66
+ parser.add_argument("--no-dns", action="store_true", help="Skip DNS/IP resolution.")
67
+ parser.add_argument("--no-tls", action="store_true", help="Skip TLS certificate inspection.")
68
+ parser.add_argument("--no-geo", action="store_true", help="Skip IP geolocation.")
69
+ parser.add_argument("--no-probe", action="store_true", help="Skip passive exposure probes.")
70
+ parser.add_argument("--no-cve", action="store_true", help="Skip CVE correlation.")
71
+ parser.add_argument(
72
+ "--cve-online",
73
+ action="store_true",
74
+ help="Also query NVD live for detected products (default is offline).",
75
+ )
76
+ parser.add_argument("--no-builtin", action="store_true", help="Ignore the bundled sigdb.")
77
+ parser.add_argument(
78
+ "--full",
79
+ action="store_true",
80
+ help="Deep scan: enable ports, subdomains, offline CVEs, IP info, and default-cred checks.",
81
+ )
82
+ parser.add_argument(
83
+ "--ports",
84
+ action="store_true",
85
+ help="Active port/service scan (nmap if installed, else a Python connect scan).",
86
+ )
87
+ parser.add_argument(
88
+ "--no-nmap",
89
+ action="store_true",
90
+ help="Force the built-in Python port scan even when nmap is available.",
91
+ )
92
+ parser.add_argument(
93
+ "--port-timeout", dest="port_timeout", type=float, default=1.5, help="Per-port timeout."
94
+ )
95
+ parser.add_argument(
96
+ "--subdomains",
97
+ action="store_true",
98
+ help="Enumerate subdomains via AXFR + DNS wordlist + TLS SANs.",
99
+ )
100
+ parser.add_argument(
101
+ "--subdomain-limit",
102
+ dest="subdomain_limit",
103
+ type=int,
104
+ default=5000,
105
+ help="Max wordlist labels to resolve (0 = the full ~870k list, slow).",
106
+ )
107
+ parser.add_argument(
108
+ "--site-limit",
109
+ dest="site_limit",
110
+ type=int,
111
+ default=20,
112
+ help="Max derived sites to analyze from discovered open ports (0 = unlimited).",
113
+ )
114
+ parser.add_argument(
115
+ "--default-creds",
116
+ dest="default_creds",
117
+ action="store_true",
118
+ help="Bounded default-credential / open-device check (authorized targets only).",
119
+ )
120
+ parser.add_argument(
121
+ "--cred-limit",
122
+ dest="cred_limit",
123
+ type=int,
124
+ default=50,
125
+ help="Max default-credential pairs to try per device (0 = full SecLists list).",
126
+ )
127
+ parser.add_argument(
128
+ "--no-ip-info", dest="no_ip_info", action="store_true", help="Skip ipwho.is."
129
+ )
130
+ parser.add_argument(
131
+ "--workers",
132
+ type=int,
133
+ default=DEFAULT_WORKERS,
134
+ help="Parallel workers for ports/subdomains/creds.",
135
+ )
136
+ parser.add_argument(
137
+ "--no",
138
+ dest="no_passes",
139
+ default="",
140
+ metavar="LIST",
141
+ help='Skip passes by name, e.g. --no "dns,tls,geo,probe,cve,ip-info".',
142
+ )
143
+ parser.add_argument(
144
+ "--export",
145
+ dest="export",
146
+ default="",
147
+ metavar="FMTS",
148
+ help="Write report file(s): comma-separated json,xml,html.",
149
+ )
150
+ parser.add_argument(
151
+ "--output",
152
+ dest="output",
153
+ default="stackscan-report",
154
+ help="Base name/path for --export files (default: stackscan-report).",
155
+ )
156
+ parser.add_argument("--json", dest="json_output", action="store_true", help="JSON output.")
157
+ parser.add_argument(
158
+ "--json-graph",
159
+ dest="json_graph",
160
+ action="store_true",
161
+ help="Include a nodes/edges graph in JSON output.",
162
+ )
163
+ parser.add_argument("--show-empty", action="store_true", help="Show targets with no findings.")
164
+ parser.add_argument("--compact", action="store_true", help="Compact one-row-per-target table.")
165
+ parser.add_argument("--no-banner", action="store_true", help="Do not print the banner.")
166
+ parser.add_argument(
167
+ "-v",
168
+ "--verbose",
169
+ dest="verbose",
170
+ type=int,
171
+ default=0,
172
+ help=argparse.SUPPRESS,
173
+ )
174
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
175
+ return parser
176
+
177
+
178
+ def _extract_verbose(argv: list[str]) -> tuple[int, list[str]]:
179
+
180
+ level = 0
181
+ rest: list[str] = []
182
+ i = 0
183
+ while i < len(argv):
184
+ arg = argv[i]
185
+ if arg in ("-v", "--verbose"):
186
+ level = max(level, 1)
187
+ if i + 1 < len(argv) and argv[i + 1].isdigit():
188
+ level = int(argv[i + 1])
189
+ i += 1
190
+ elif arg.startswith("--verbose="):
191
+ value = arg.split("=", 1)[1]
192
+ level = int(value) if value.isdigit() else 1
193
+ elif len(arg) >= 2 and arg[0] == "-" and set(arg[1:]) == {"v"}:
194
+ level = max(level, len(arg) - 1)
195
+ else:
196
+ rest.append(arg)
197
+ i += 1
198
+ return level, rest
199
+
200
+
201
+ _NO_MAP: dict[str, str] = {
202
+ "dns": "no_dns",
203
+ "tls": "no_tls",
204
+ "geo": "no_geo",
205
+ "probe": "no_probe",
206
+ "cve": "no_cve",
207
+ "ip-info": "no_ip_info",
208
+ "ipinfo": "no_ip_info",
209
+ "ip": "no_ip_info",
210
+ "builtin": "no_builtin",
211
+ "sources": "no_sources",
212
+ "nmap": "no_nmap",
213
+ "banner": "no_banner",
214
+ "subdomains": "no_subdomains",
215
+ "ports": "no_ports",
216
+ "creds": "no_creds",
217
+ "default-creds": "no_creds",
218
+ "cve-online": "no_cve_online",
219
+ "ct": "no_ct",
220
+ "crt": "no_ct",
221
+ "passive": "no_ct",
222
+ }
223
+
224
+
225
+ def _apply_no(args: argparse.Namespace, console: Console) -> None:
226
+ for token in args.no_passes.replace(" ", ",").split(","):
227
+ token = token.strip().lower().lstrip("-")
228
+ if not token:
229
+ continue
230
+ attr = _NO_MAP.get(token)
231
+ if attr is None:
232
+ _warn(console, f"unknown --no pass: {token} (known: {', '.join(sorted(_NO_MAP))})")
233
+ continue
234
+ setattr(args, attr, True)
235
+
236
+
237
+ def _read_targets(path: Path | None, positional: Iterable[str]) -> list[str]:
238
+ targets = [item.strip() for item in positional if item.strip()]
239
+ if path is None:
240
+ return targets
241
+ if not path.is_file():
242
+ raise FileNotFoundError(f"Targets file not found: {path}")
243
+ for line in path.read_text(encoding="utf-8").splitlines():
244
+ line = line.strip()
245
+ if not line or line.startswith("#"):
246
+ continue
247
+ targets.append(line)
248
+ return targets
249
+
250
+
251
+ def _dedupe(items: Iterable[str]) -> list[str]:
252
+ seen: set[str] = set()
253
+ ordered: list[str] = []
254
+ for item in items:
255
+ if item in seen:
256
+ continue
257
+ seen.add(item)
258
+ ordered.append(item)
259
+ return ordered
260
+
261
+
262
+ def _format_detected(detected: DetectedTech) -> str:
263
+ if not detected:
264
+ return "-"
265
+ chunks: list[str] = []
266
+ for category in sorted(detected):
267
+ techs = ", ".join(detected[category])
268
+ chunks.append(f"{category}: {techs}")
269
+ return " | ".join(chunks)
270
+
271
+
272
+ async def _expand_wildcards(raw_targets: list[str], workers: int) -> list[str]:
273
+ from stackscan.net import expand_wildcard_target, has_wildcard, resolve_existing
274
+
275
+ console = Console(stderr=True)
276
+ out: list[str] = []
277
+ for target in raw_targets:
278
+ if not has_wildcard(target):
279
+ out.append(target)
280
+ continue
281
+ candidates = expand_wildcard_target(target)
282
+ console.print(
283
+ f"[{theme.MUTED}]expanding[/] {target} → {len(candidates)} candidates, resolving…",
284
+ highlight=False,
285
+ )
286
+ resolving = await resolve_existing(candidates, workers=max(workers * 10, 1000))
287
+ console.print(f"[{theme.SUCCESS}][+][/] {target}: {len(resolving)} live domain(s)")
288
+ out.extend(resolving)
289
+ return out
290
+
291
+
292
+ async def _run_scans(args: argparse.Namespace) -> list[ScanReport]:
293
+ from stackscan.core import StackscanSession
294
+
295
+ raw_targets = _read_targets(args.file, args.targets)
296
+ if not raw_targets:
297
+ return []
298
+ expanded: list[str] = []
299
+ for target in raw_targets:
300
+ if is_cidr(target):
301
+ expanded.extend(expand_cidr(target))
302
+ else:
303
+ expanded.append(target)
304
+ raw_targets = expanded
305
+ raw_targets = await _expand_wildcards(raw_targets, max(args.workers, 1))
306
+ if not raw_targets:
307
+ return []
308
+ targets = _dedupe([normalize_url(target) for target in raw_targets])
309
+ matchers = build_matchers(
310
+ args.sigdb, use_sources=not args.no_sources, use_builtin=not args.no_builtin
311
+ )
312
+ analyzer = TechAnalyzer(matchers)
313
+ geo = GeoProvider(args.geoip_db)
314
+ full = args.full
315
+ options = ScanOptions(
316
+ timeout=args.timeout,
317
+ user_agent=args.user_agent,
318
+ insecure=args.insecure,
319
+ max_bytes=args.max_bytes,
320
+ dns=not args.no_dns,
321
+ tls=not args.no_tls,
322
+ geo=not args.no_geo,
323
+ probe=not args.no_probe,
324
+ cve=not args.no_cve,
325
+ cve_online=args.cve_online and (not getattr(args, "no_cve_online", False)),
326
+ ports=(args.ports or full) and (not getattr(args, "no_ports", False)),
327
+ subdomains=(args.subdomains or full) and (not getattr(args, "no_subdomains", False)),
328
+ ip_info=not args.no_ip_info,
329
+ default_creds=(args.default_creds or full) and (not getattr(args, "no_creds", False)),
330
+ port_timeout=args.port_timeout,
331
+ prefer_nmap=not args.no_nmap,
332
+ workers=max(args.workers, 1),
333
+ subdomain_limit=max(args.subdomain_limit, 0),
334
+ cred_limit=max(args.cred_limit, 0),
335
+ ct_logs=not getattr(args, "no_ct", False),
336
+ concurrency=max(args.concurrency, 1),
337
+ full=full,
338
+ smart_scan=full,
339
+ discover_sites=full,
340
+ site_limit=max(args.site_limit, 0),
341
+ )
342
+ from typing import Any
343
+
344
+ from rich.progress import (
345
+ BarColumn,
346
+ MofNCompleteColumn,
347
+ Progress,
348
+ SpinnerColumn,
349
+ TextColumn,
350
+ TimeElapsedColumn,
351
+ )
352
+
353
+ err_console = Console(stderr=True)
354
+ total_targets = len(targets)
355
+ completed = 0
356
+
357
+ async def scan_one(
358
+ target: str,
359
+ progress_obj: Progress | None = None,
360
+ task_id: Any | None = None,
361
+ stage_total: int = 1,
362
+ ) -> ScanReport:
363
+ nonlocal completed
364
+ if args.verbose == 1:
365
+ err_console.print(f"[{theme.MUTED}][*] Starting scan of {target}...[/]")
366
+
367
+ stage_log: Callable[[str], None] | None = None
368
+ if args.verbose >= 2 and progress_obj is not None and task_id is not None:
369
+
370
+ def _stage_log(message: str, _t: str = target) -> None:
371
+ progress_obj.update(
372
+ task_id,
373
+ advance=1,
374
+ description=f"[~] {_t} · {message}",
375
+ )
376
+
377
+ stage_log = _stage_log
378
+ progress_obj.update(task_id, description=f"[~] {target} · starting...")
379
+
380
+ report = await scan_target(
381
+ target,
382
+ matchers_analyzer=analyzer,
383
+ session=session,
384
+ options=options,
385
+ geo=geo,
386
+ semaphore=semaphore,
387
+ log=stage_log,
388
+ )
389
+
390
+ completed += 1
391
+
392
+ findings: list[str] = []
393
+ if report.error:
394
+ findings.append(f"error: {report.error}")
395
+ else:
396
+ if report.status is not None:
397
+ findings.append(f"status {report.status}")
398
+ tech_count = len(report.technologies)
399
+ if tech_count:
400
+ findings.append(f"{tech_count} tech")
401
+ if report.ports:
402
+ open_ports = len(report.ports.ports)
403
+ if open_ports:
404
+ findings.append(f"{open_ports} port(s)")
405
+ if report.subdomains:
406
+ findings.append(f"{len(report.subdomains)} subdomain(s)")
407
+ if report.site_findings:
408
+ findings.append(f"{len(report.site_findings)} site(s)")
409
+
410
+ summary_str = ", ".join(findings)
411
+ if args.verbose == 1:
412
+ err_console.print(
413
+ f"[{theme.SUCCESS}][+][/] [{completed}/{total_targets}] Finished {target} in {_fmt_elapsed(report.elapsed or 0)} ({summary_str})",
414
+ highlight=False,
415
+ )
416
+
417
+ if progress_obj is not None and task_id is not None:
418
+ if args.verbose >= 2:
419
+ progress_obj.update(task_id, completed=stage_total, description=f"[+] {target}")
420
+ else:
421
+ progress_obj.update(task_id, advance=1, description=f"Scanning: {target}")
422
+
423
+ return report
424
+
425
+ _stage_count = 7
426
+ semaphore = asyncio.Semaphore(max(args.concurrency, 1))
427
+ async with StackscanSession() as session:
428
+ if not args.json_output:
429
+ transient = args.verbose < 2
430
+ with Progress(
431
+ SpinnerColumn(),
432
+ TextColumn("[progress.description]{task.description}"),
433
+ BarColumn(),
434
+ MofNCompleteColumn(),
435
+ TimeElapsedColumn(),
436
+ console=err_console,
437
+ transient=transient,
438
+ ) as progress:
439
+ if args.verbose >= 2:
440
+ task_ids = {
441
+ target: progress.add_task(f"[~] {target}", total=_stage_count)
442
+ for target in targets
443
+ }
444
+ tasks = [
445
+ scan_one(target, progress, task_ids[target], _stage_count)
446
+ for target in targets
447
+ ]
448
+ else:
449
+ task_id = progress.add_task("Scanning targets...", total=total_targets)
450
+ tasks = [scan_one(target, progress, task_id) for target in targets]
451
+ return list(await asyncio.gather(*tasks))
452
+ else:
453
+ tasks = [scan_one(target) for target in targets]
454
+ return list(await asyncio.gather(*tasks))
455
+
456
+
457
+ def _fmt_elapsed(seconds: float) -> str:
458
+ if seconds < 90:
459
+ return f"{seconds:.2f}s"
460
+ minutes = int(seconds // 60)
461
+ secs = int(seconds % 60)
462
+ return f"{minutes}m {secs}s ({seconds:.0f}s)"
463
+
464
+
465
+ def _infra_summary(report: ScanReport) -> str:
466
+ infra = report.infra
467
+ parts: list[str] = []
468
+ if infra.cdn:
469
+ parts.append("cdn: " + ", ".join(infra.cdn))
470
+ if infra.waf:
471
+ parts.append("waf: " + ", ".join(infra.waf))
472
+ if infra.server:
473
+ parts.append("server: " + ", ".join(infra.server))
474
+ if infra.proxy:
475
+ parts.append("proxy: " + ", ".join(infra.proxy))
476
+ return " | ".join(parts) if parts else "-"
477
+
478
+
479
+ def _exposure_summary(report: ScanReport) -> str:
480
+ if report.exposure is None:
481
+ return "-"
482
+ flags: list[str] = []
483
+ if report.exposure.git_exposed:
484
+ flags.append("[red].git[/red]")
485
+ if report.exposure.robots_txt:
486
+ flags.append("robots")
487
+ if report.exposure.sitemap:
488
+ flags.append("sitemap")
489
+ if report.exposure.security_txt:
490
+ flags.append("security.txt")
491
+ return ", ".join(flags) if flags else "-"
492
+
493
+
494
+ def _render_table(reports: list[ScanReport], show_empty: bool) -> None:
495
+ console = Console()
496
+ table = Table(title="Stackscan Report")
497
+ table.add_column("Target", style="cyan", overflow="fold")
498
+ table.add_column("IPs", overflow="fold")
499
+ table.add_column("Status", justify="right")
500
+ table.add_column("Infrastructure")
501
+ table.add_column("Technologies")
502
+ table.add_column("Exposure")
503
+ table.add_column("Error", style="red")
504
+ for report in reports:
505
+ has_findings = bool(report.technologies) or bool(report.infra.server or report.infra.cdn)
506
+ if not show_empty and (not has_findings) and (not report.error):
507
+ continue
508
+ ips: list[str] = []
509
+ if report.network is not None:
510
+ ips.extend(report.network.ipv4)
511
+ ips.extend(report.network.ipv6)
512
+ table.add_row(
513
+ report.final_url or report.url,
514
+ ", ".join(ips) if ips else "-",
515
+ str(report.status) if report.status is not None else "-",
516
+ _infra_summary(report),
517
+ _format_detected(report.by_category()),
518
+ _exposure_summary(report),
519
+ report.error or "",
520
+ )
521
+ console.print(table)
522
+
523
+
524
+ def _payload(
525
+ reports: list[ScanReport], elapsed: float, include_graph: bool = False
526
+ ) -> dict[str, object]:
527
+ payload: dict[str, object] = {
528
+ "scanner": "stackscan",
529
+ "version": __version__,
530
+ "generated_at": datetime.now(UTC).isoformat(),
531
+ "elapsed_seconds": round(elapsed, 3),
532
+ "results": [report.to_dict() for report in reports],
533
+ }
534
+ if include_graph:
535
+ from typing import Any, cast
536
+
537
+ from stackscan.export import build_graph
538
+
539
+ payload["graph"] = build_graph(cast("list[dict[str, Any]]", payload["results"]))
540
+ return payload
541
+
542
+
543
+ def _render_json(reports: list[ScanReport], elapsed: float, include_graph: bool = False) -> None:
544
+ json.dump(_payload(reports, elapsed, include_graph), sys.stdout, ensure_ascii=False, indent=2)
545
+ sys.stdout.write("\n")
546
+
547
+
548
+ _EXPORTERS: dict[str, tuple[str, str]] = {
549
+ "json": ("json", "to_json"),
550
+ "xml": ("xml", "to_xml"),
551
+ "html": ("html", "to_html"),
552
+ }
553
+
554
+
555
+ def _write_exports(
556
+ reports: list[ScanReport],
557
+ elapsed: float,
558
+ spec: str,
559
+ output: str,
560
+ console: Console,
561
+ include_graph: bool = False,
562
+ ) -> None:
563
+ from stackscan import export
564
+
565
+ requested: list[str] = []
566
+ for token in spec.replace(" ", ",").split(","):
567
+ fmt = token.strip().lower()
568
+ if not fmt:
569
+ continue
570
+ if fmt not in _EXPORTERS:
571
+ _warn(console, f"unknown --export format: {fmt} (known: json, xml, html)")
572
+ continue
573
+ requested.append(fmt)
574
+ for fmt in dict.fromkeys(requested):
575
+ ext, func = _EXPORTERS[fmt]
576
+ payload = _payload(reports, elapsed, include_graph=(include_graph and fmt == "json"))
577
+ text: str = getattr(export, func)(payload)
578
+ out = Path(f"{output}.{ext}")
579
+ out.write_text(text, encoding="utf-8")
580
+ console.print(f"[{theme.SUCCESS}][+][/] wrote {out}", highlight=False)
581
+
582
+
583
+ def _scan_summary(reports: list[ScanReport], elapsed: float) -> str:
584
+ targets = len(reports)
585
+ cves = sum(len(report.cves) for report in reports)
586
+ critical = sum(
587
+ 1 for report in reports for cve in report.cves if cve.severity.upper() == "CRITICAL"
588
+ )
589
+ exposed = sum(
590
+ 1
591
+ for report in reports
592
+ for finding in report.creds
593
+ if finding.kind in ("default-creds", "open-no-auth")
594
+ )
595
+ ports = sum(len(report.ports.ports) for report in reports if report.ports is not None)
596
+ subdomains = sum(len(report.subdomains) for report in reports)
597
+ sites = sum(len(report.site_findings) for report in reports)
598
+ parts = [f"[bold]{targets}[/bold] target(s)", f"[bold]{cves}[/bold] CVE(s)"]
599
+ if critical:
600
+ parts.append(f"[bold {theme.DANGER}]{critical} critical[/]")
601
+ if ports:
602
+ parts.append(f"[bold]{ports}[/bold] open port(s)")
603
+ if subdomains:
604
+ parts.append(f"[bold]{subdomains}[/bold] subdomain(s)")
605
+ if sites:
606
+ parts.append(f"[bold]{sites}[/bold] site(s)")
607
+ if exposed:
608
+ parts.append(f"[bold {theme.DANGER}]{exposed} exposed device(s)[/]")
609
+ parts.append(f"done in [{theme.ACCENT}]{_fmt_elapsed(elapsed)}[/]")
610
+ return " · ".join(parts)
611
+
612
+
613
+ def _warn(console: Console, message: str) -> None:
614
+ console.print(f"[{theme.WARN}][!][/] {message}", highlight=False)
615
+
616
+
617
+ def _error(console: Console, message: str) -> None:
618
+ console.print(f"[{theme.DANGER}][x][/] {message}", highlight=False)
619
+
620
+
621
+ def _increase_nofile_limit() -> None:
622
+ try:
623
+ import resource
624
+
625
+ soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
626
+ target = min(hard, 8192) if hard != resource.RLIM_INFINITY else 8192
627
+ if soft < target:
628
+ resource.setrlimit(resource.RLIMIT_NOFILE, (target, hard))
629
+ except Exception:
630
+ pass
631
+
632
+
633
+ def _scan_command(argv: list[str]) -> int:
634
+ _increase_nofile_limit()
635
+ verbose_level, argv = _extract_verbose(argv)
636
+ parser = _build_scan_parser()
637
+ args = parser.parse_args(argv)
638
+ args.verbose = verbose_level
639
+ err_console = Console(stderr=True)
640
+ _apply_no(args, err_console)
641
+ if not args.no_banner:
642
+ render_banner(err_console)
643
+ if (args.ports or args.full) and (not args.no_nmap) and (not nmap_available()):
644
+ _warn(err_console, "nmap not found — using the built-in Python connect scan.")
645
+ if args.default_creds or args.full:
646
+ _warn(
647
+ err_console,
648
+ "default-credential checks enabled — only scan systems you are authorized to test.",
649
+ )
650
+ started = time.perf_counter()
651
+ try:
652
+ reports = asyncio.run(_run_scans(args))
653
+ except FileNotFoundError as exc:
654
+ _error(err_console, str(exc))
655
+ return 2
656
+ except NoSignaturesError as exc:
657
+ _error(err_console, str(exc))
658
+ return 2
659
+ except Exception as exc:
660
+ _error(err_console, f"Failed to scan: {exc}")
661
+ return 1
662
+ elapsed = time.perf_counter() - started
663
+ if not reports:
664
+ _warn(err_console, "No targets provided.")
665
+ return 2
666
+ if args.export:
667
+ _write_exports(
668
+ reports, elapsed, args.export, args.output, err_console, include_graph=args.json_graph
669
+ )
670
+ if args.json_output:
671
+ _render_json(reports, elapsed, include_graph=args.json_graph)
672
+ elif args.compact:
673
+ _render_table(reports, args.show_empty)
674
+ err_console.print(_scan_summary(reports, elapsed))
675
+ else:
676
+ render_reports(reports, Console(), show_empty=args.show_empty)
677
+ err_console.print(_scan_summary(reports, elapsed))
678
+ return 0
679
+
680
+
681
+ def _sigdb_command(argv: list[str]) -> int:
682
+ parser = argparse.ArgumentParser(
683
+ prog="stackscan sigdb",
684
+ description="Manage signature sources.",
685
+ add_help=False,
686
+ )
687
+ parser.add_argument(
688
+ "-h", "--help", action=_HelpAction, nargs=0, help="Show this help message and exit."
689
+ )
690
+ sub = parser.add_subparsers(dest="action", required=True)
691
+ add_p = sub.add_parser("add", help="Add a signature source (http URL or git repo).")
692
+ add_p.add_argument("url", help="Source URL: .sigdb, rules JSON, or a git repository.")
693
+ sub.add_parser("list", help="List configured sources.")
694
+ remove_p = sub.add_parser("remove", help="Remove a source by id or url.")
695
+ remove_p.add_argument("key", help="Source id or url.")
696
+ update_p = sub.add_parser("update", help="Re-fetch and recompile a source (or all).")
697
+ update_p.add_argument("key", nargs="?", help="Source id or url; omit to update all.")
698
+ args = parser.parse_args(argv)
699
+ store = SourceStore()
700
+ console = Console()
701
+ try:
702
+ if args.action == "add":
703
+ source = store.add(args.url)
704
+ console.print(
705
+ f"[green]Added[/green] {args.url} ([cyan]{source.kind}[/cyan], id={source.id}) -> {source.path}"
706
+ )
707
+ return 0
708
+ if args.action == "list":
709
+ sources = store.list()
710
+ if not sources:
711
+ console.print("[yellow]No sources configured.[/yellow]")
712
+ return 0
713
+ table = Table(title="Signature Sources")
714
+ table.add_column("ID", style="cyan")
715
+ table.add_column("Kind")
716
+ table.add_column("URL", overflow="fold")
717
+ table.add_column("Added")
718
+ for source in sources:
719
+ added = datetime.fromtimestamp(source.added, UTC).strftime("%Y-%m-%d")
720
+ table.add_row(source.id, source.kind, source.url, added)
721
+ console.print(table)
722
+ return 0
723
+ if args.action == "remove":
724
+ removed = store.remove(args.key)
725
+ if removed:
726
+ console.print(f"[green]Removed[/green] {args.key}")
727
+ return 0
728
+ console.print(f"[yellow]No source matched[/yellow] {args.key}")
729
+ return 1
730
+ if args.action == "update":
731
+ refreshed = store.update(args.key)
732
+ if not refreshed:
733
+ console.print("[yellow]Nothing to update.[/yellow]")
734
+ return 1
735
+ for source in refreshed:
736
+ console.print(f"[green]Updated[/green] {source.url} (id={source.id})")
737
+ return 0
738
+ except SourceError as exc:
739
+ Console(stderr=True).print(f"[red]{exc}[/red]")
740
+ return 1
741
+ return 2
742
+
743
+
744
+ def main(argv: Iterable[str] | None = None) -> int:
745
+ args = list(argv) if argv is not None else sys.argv[1:]
746
+ if args and args[0] == "sigdb":
747
+ return _sigdb_command(args[1:])
748
+ if args and args[0] == "scan":
749
+ args = args[1:]
750
+ return _scan_command(args)