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/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)
|