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/scan.py
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from time import perf_counter
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from stackscan.analyzers import (
|
|
10
|
+
ExposureProbe,
|
|
11
|
+
TechAnalyzer,
|
|
12
|
+
analyze_exposure,
|
|
13
|
+
analyze_infra,
|
|
14
|
+
analyze_security_headers,
|
|
15
|
+
check_default_creds,
|
|
16
|
+
classify_services,
|
|
17
|
+
extract_software,
|
|
18
|
+
match_cves,
|
|
19
|
+
match_cves_online,
|
|
20
|
+
merge_cve_matches,
|
|
21
|
+
software_from_ports,
|
|
22
|
+
)
|
|
23
|
+
from stackscan.net import (
|
|
24
|
+
GeoProvider,
|
|
25
|
+
enrich_ips,
|
|
26
|
+
enumerate_subdomains,
|
|
27
|
+
fetch_tls_info,
|
|
28
|
+
lookup_geo,
|
|
29
|
+
resolve_host,
|
|
30
|
+
scan_ports,
|
|
31
|
+
)
|
|
32
|
+
from stackscan.types import (
|
|
33
|
+
CredFinding,
|
|
34
|
+
FetchResult,
|
|
35
|
+
IpInfo,
|
|
36
|
+
NetworkInfo,
|
|
37
|
+
Port,
|
|
38
|
+
PortScan,
|
|
39
|
+
ScanReport,
|
|
40
|
+
SiteFinding,
|
|
41
|
+
Software,
|
|
42
|
+
TlsInfo,
|
|
43
|
+
)
|
|
44
|
+
from stackscan.utils import host_of, is_https, port_of
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from stackscan.core import StackscanSession
|
|
48
|
+
|
|
49
|
+
_HTTP_PORTS: frozenset[int] = frozenset(
|
|
50
|
+
{80, 443, 631, 2082, 2083, 3000, 7547, 8000, 8080, 8081, 8443, 8888, 9000}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _http_url(host: str, port: int, tls: bool) -> str:
|
|
55
|
+
scheme = "https" if tls else "http"
|
|
56
|
+
if (tls and port == 443) or (not tls and port == 80):
|
|
57
|
+
return f"{scheme}://{host}"
|
|
58
|
+
return f"{scheme}://{host}:{port}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _fetch_with_fallback(
|
|
62
|
+
url: str, session: StackscanSession, options: ScanOptions, report: ScanReport
|
|
63
|
+
) -> tuple[FetchResult | None, str]:
|
|
64
|
+
attempts = [url]
|
|
65
|
+
if is_https(url):
|
|
66
|
+
attempts.append("http://" + url.split("://", 1)[1])
|
|
67
|
+
first_error: str | None = None
|
|
68
|
+
for attempt in attempts:
|
|
69
|
+
try:
|
|
70
|
+
fetched = await session.fetch(
|
|
71
|
+
attempt,
|
|
72
|
+
timeout=options.timeout,
|
|
73
|
+
user_agent=options.user_agent,
|
|
74
|
+
insecure=options.insecure,
|
|
75
|
+
max_bytes=options.max_bytes,
|
|
76
|
+
)
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
if first_error is None:
|
|
79
|
+
first_error = str(exc)
|
|
80
|
+
continue
|
|
81
|
+
return (fetched, attempt)
|
|
82
|
+
report.error = first_error
|
|
83
|
+
return (None, url)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _dns_record_hosts(net: NetworkInfo | None) -> tuple[str, ...]:
|
|
87
|
+
if net is None:
|
|
88
|
+
return ()
|
|
89
|
+
return (*net.mx, *net.ns, *net.cname, *net.txt, *net.soa)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _http_protocols(fetched: FetchResult, tls: TlsInfo | None) -> list[str]:
|
|
93
|
+
protocols: list[str] = []
|
|
94
|
+
if fetched.http_version:
|
|
95
|
+
protocols.append(f"HTTP/{fetched.http_version}")
|
|
96
|
+
if tls and tls.alpn == "h2":
|
|
97
|
+
protocols.append("HTTP/2 (ALPN)")
|
|
98
|
+
alt_svc = fetched.headers.get("alt-svc", "")
|
|
99
|
+
if "h3" in alt_svc:
|
|
100
|
+
protocols.append("HTTP/3 (Alt-Svc)")
|
|
101
|
+
return protocols
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class ScanOptions:
|
|
106
|
+
timeout: float
|
|
107
|
+
user_agent: str
|
|
108
|
+
insecure: bool
|
|
109
|
+
max_bytes: int
|
|
110
|
+
dns: bool = True
|
|
111
|
+
tls: bool = True
|
|
112
|
+
geo: bool = True
|
|
113
|
+
probe: bool = True
|
|
114
|
+
cve: bool = True
|
|
115
|
+
cve_online: bool = False
|
|
116
|
+
ports: bool = False
|
|
117
|
+
subdomains: bool = False
|
|
118
|
+
ip_info: bool = True
|
|
119
|
+
default_creds: bool = False
|
|
120
|
+
port_timeout: float = 2.0
|
|
121
|
+
prefer_nmap: bool = True
|
|
122
|
+
workers: int = 100
|
|
123
|
+
subdomain_limit: int = 5000
|
|
124
|
+
cred_limit: int = 100
|
|
125
|
+
ct_logs: bool = True
|
|
126
|
+
concurrency: int = 10
|
|
127
|
+
full: bool = False
|
|
128
|
+
smart_scan: bool = False
|
|
129
|
+
discover_sites: bool = False
|
|
130
|
+
site_limit: int = 20
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _collect_dns_domains(host: str, dns: Any) -> tuple[str, ...]:
|
|
134
|
+
domains: set[str] = {host}
|
|
135
|
+
for vals in (dns.cname, dns.mx, dns.ns, dns.soa, dns.txt):
|
|
136
|
+
for value in vals:
|
|
137
|
+
for part in value.split():
|
|
138
|
+
part = part.strip(". ")
|
|
139
|
+
if "." in part and not part.replace(".", "").isdigit():
|
|
140
|
+
domains.add(part.lower())
|
|
141
|
+
for name in dns.reverse_dns.values():
|
|
142
|
+
domains.add(name.lower().rstrip("."))
|
|
143
|
+
return tuple(sorted(domains))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def _resolve_network(host: str, options: ScanOptions, geo: GeoProvider) -> NetworkInfo | None:
|
|
147
|
+
if not host or not options.dns:
|
|
148
|
+
return None
|
|
149
|
+
dns = await asyncio.to_thread(resolve_host, host)
|
|
150
|
+
geo_map: dict[str, dict[str, str]] = {}
|
|
151
|
+
if options.geo and geo.enabled:
|
|
152
|
+
geo_map = await asyncio.to_thread(lookup_geo, (*dns.ipv4, *dns.ipv6), geo)
|
|
153
|
+
domains = _collect_dns_domains(host, dns)
|
|
154
|
+
return NetworkInfo(
|
|
155
|
+
host=host,
|
|
156
|
+
ipv4=dns.ipv4,
|
|
157
|
+
ipv6=dns.ipv6,
|
|
158
|
+
cname=dns.cname,
|
|
159
|
+
mx=dns.mx,
|
|
160
|
+
ns=dns.ns,
|
|
161
|
+
txt=dns.txt,
|
|
162
|
+
soa=dns.soa,
|
|
163
|
+
caa=dns.caa,
|
|
164
|
+
reverse_dns=dns.reverse_dns,
|
|
165
|
+
geo=geo_map,
|
|
166
|
+
domains=domains,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def _scan_site(
|
|
171
|
+
url: str,
|
|
172
|
+
session: StackscanSession,
|
|
173
|
+
options: ScanOptions,
|
|
174
|
+
analyzer: TechAnalyzer,
|
|
175
|
+
probe: ExposureProbe,
|
|
176
|
+
) -> SiteFinding:
|
|
177
|
+
tls: TlsInfo | None = None
|
|
178
|
+
try:
|
|
179
|
+
fetched = await session.fetch(
|
|
180
|
+
url,
|
|
181
|
+
timeout=min(options.timeout, 6.0),
|
|
182
|
+
user_agent=options.user_agent,
|
|
183
|
+
insecure=options.insecure,
|
|
184
|
+
max_bytes=options.max_bytes,
|
|
185
|
+
)
|
|
186
|
+
except Exception as exc:
|
|
187
|
+
return SiteFinding(url=url, error=str(exc))
|
|
188
|
+
host = host_of(url)
|
|
189
|
+
if host and is_https(url):
|
|
190
|
+
try:
|
|
191
|
+
tls = await asyncio.to_thread(
|
|
192
|
+
fetch_tls_info, host, port_of(url), insecure=options.insecure
|
|
193
|
+
)
|
|
194
|
+
except Exception:
|
|
195
|
+
tls = None
|
|
196
|
+
return SiteFinding(
|
|
197
|
+
url=url,
|
|
198
|
+
final_url=fetched.url,
|
|
199
|
+
status=fetched.status,
|
|
200
|
+
technologies=analyzer.detect(fetched),
|
|
201
|
+
software=extract_software(fetched.headers, fetched.body, location=host_of(fetched.url)),
|
|
202
|
+
infra=analyze_infra(fetched.headers, tuple(fetched.cookies), host),
|
|
203
|
+
security=analyze_security_headers(fetched.headers),
|
|
204
|
+
exposure=await analyze_exposure(session, fetched.url, probe) if options.probe else None,
|
|
205
|
+
protocols=_http_protocols(fetched, tls),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _collect_ips(report: ScanReport) -> set[str]:
|
|
210
|
+
ips: set[str] = set()
|
|
211
|
+
if report.network is not None:
|
|
212
|
+
ips.update(report.network.ipv4)
|
|
213
|
+
ips.update(report.network.ipv6)
|
|
214
|
+
for sub in report.subdomains:
|
|
215
|
+
ips.update(sub.addresses)
|
|
216
|
+
return ips
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _collect_site_candidates(report: ScanReport, limit: int) -> list[str]:
|
|
220
|
+
candidates: list[str] = []
|
|
221
|
+
seen: set[str] = set()
|
|
222
|
+
primary = report.final_url or report.url
|
|
223
|
+
|
|
224
|
+
def add(url: str) -> None:
|
|
225
|
+
if url not in seen:
|
|
226
|
+
seen.add(url)
|
|
227
|
+
candidates.append(url)
|
|
228
|
+
|
|
229
|
+
if primary:
|
|
230
|
+
add(primary)
|
|
231
|
+
|
|
232
|
+
port_scan = report.ports
|
|
233
|
+
if port_scan is not None:
|
|
234
|
+
for port in port_scan.ports:
|
|
235
|
+
if port.port not in _HTTP_PORTS:
|
|
236
|
+
continue
|
|
237
|
+
host = port.host or report.network.host if report.network else None
|
|
238
|
+
if not host:
|
|
239
|
+
continue
|
|
240
|
+
tls = port.port in (443, 8443, 2083) or "https" in (port.service or "").lower()
|
|
241
|
+
add(_http_url(host, port.port, tls))
|
|
242
|
+
|
|
243
|
+
for sub in report.subdomains:
|
|
244
|
+
if sub.name in seen or f"https://{sub.name}" in seen:
|
|
245
|
+
continue
|
|
246
|
+
add(f"https://{sub.name}")
|
|
247
|
+
add(f"http://{sub.name}")
|
|
248
|
+
|
|
249
|
+
return candidates[:limit]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
async def _scan_derived_sites(
|
|
253
|
+
candidates: list[str],
|
|
254
|
+
session: StackscanSession,
|
|
255
|
+
options: ScanOptions,
|
|
256
|
+
analyzer: TechAnalyzer,
|
|
257
|
+
) -> list[SiteFinding]:
|
|
258
|
+
if not candidates:
|
|
259
|
+
return []
|
|
260
|
+
probe = ExposureProbe(
|
|
261
|
+
timeout=min(options.timeout, 6.0),
|
|
262
|
+
user_agent=options.user_agent,
|
|
263
|
+
insecure=options.insecure,
|
|
264
|
+
)
|
|
265
|
+
semaphore = asyncio.Semaphore(max(options.concurrency, 5))
|
|
266
|
+
|
|
267
|
+
async def one(url: str) -> SiteFinding:
|
|
268
|
+
async with semaphore:
|
|
269
|
+
return await _scan_site(url, session, options, analyzer, probe)
|
|
270
|
+
|
|
271
|
+
results = await asyncio.gather(*(one(url) for url in candidates))
|
|
272
|
+
seen_urls: set[str] = set()
|
|
273
|
+
unique: list[SiteFinding] = []
|
|
274
|
+
for r in results:
|
|
275
|
+
if r.status is None:
|
|
276
|
+
continue
|
|
277
|
+
key = (r.final_url or r.url).rstrip("/")
|
|
278
|
+
if key in seen_urls:
|
|
279
|
+
continue
|
|
280
|
+
seen_urls.add(key)
|
|
281
|
+
unique.append(r)
|
|
282
|
+
return unique
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _all_software(report: ScanReport) -> list[Software]:
|
|
286
|
+
software: list[Software] = []
|
|
287
|
+
seen: set[tuple[str, str | None]] = set()
|
|
288
|
+
|
|
289
|
+
def add(items: list[Software]) -> None:
|
|
290
|
+
for item in items:
|
|
291
|
+
key = (item.name.lower(), item.version)
|
|
292
|
+
if key in seen:
|
|
293
|
+
continue
|
|
294
|
+
seen.add(key)
|
|
295
|
+
software.append(item)
|
|
296
|
+
|
|
297
|
+
add(report.software)
|
|
298
|
+
add(software_from_ports(report.ports))
|
|
299
|
+
for site in report.site_findings:
|
|
300
|
+
add(site.software)
|
|
301
|
+
return software
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
async def _scan_ports_on_ips(
|
|
305
|
+
ips: set[str],
|
|
306
|
+
options: ScanOptions,
|
|
307
|
+
) -> tuple[PortScan | None, dict[str, PortScan]]:
|
|
308
|
+
if not ips or not options.ports:
|
|
309
|
+
return (None, {})
|
|
310
|
+
semaphore = asyncio.Semaphore(max(options.workers // 10, 4))
|
|
311
|
+
|
|
312
|
+
async def one(ip: str) -> tuple[str, PortScan]:
|
|
313
|
+
async with semaphore:
|
|
314
|
+
scan = await scan_ports(
|
|
315
|
+
ip,
|
|
316
|
+
timeout=options.port_timeout,
|
|
317
|
+
prefer_nmap=options.prefer_nmap,
|
|
318
|
+
workers=max(options.workers // len(ips), 20),
|
|
319
|
+
)
|
|
320
|
+
return (ip, scan)
|
|
321
|
+
|
|
322
|
+
results = await asyncio.gather(*(one(ip) for ip in ips))
|
|
323
|
+
per_ip: dict[str, PortScan] = {ip: scan for ip, scan in results}
|
|
324
|
+
seen_ports: set[tuple[str | None, int]] = set()
|
|
325
|
+
all_ports: list[Port] = []
|
|
326
|
+
for scan in per_ip.values():
|
|
327
|
+
for port in scan.ports:
|
|
328
|
+
key = (port.host, port.port)
|
|
329
|
+
if key in seen_ports:
|
|
330
|
+
continue
|
|
331
|
+
seen_ports.add(key)
|
|
332
|
+
all_ports.append(port)
|
|
333
|
+
all_ports.sort(key=lambda p: (p.host or "", p.port))
|
|
334
|
+
merged = PortScan(
|
|
335
|
+
scanner="nmap" if options.prefer_nmap else "connect",
|
|
336
|
+
ports=tuple(all_ports),
|
|
337
|
+
note=f"scanned {len(per_ip)} host(s)",
|
|
338
|
+
)
|
|
339
|
+
return (merged, per_ip)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
async def _enrich_unique_ips(
|
|
343
|
+
report: ScanReport,
|
|
344
|
+
options: ScanOptions,
|
|
345
|
+
) -> list[IpInfo]:
|
|
346
|
+
if not options.ip_info:
|
|
347
|
+
return []
|
|
348
|
+
unique: set[str] = set()
|
|
349
|
+
sources: dict[str, str] = {}
|
|
350
|
+
if report.network is not None:
|
|
351
|
+
host = report.network.host
|
|
352
|
+
for ip in (*report.network.ipv4, *report.network.ipv6):
|
|
353
|
+
unique.add(ip)
|
|
354
|
+
sources.setdefault(ip, host)
|
|
355
|
+
for sub in report.subdomains:
|
|
356
|
+
for ip in sub.addresses:
|
|
357
|
+
unique.add(ip)
|
|
358
|
+
sources.setdefault(ip, sub.name)
|
|
359
|
+
for port in report.ports.ports if report.ports else ():
|
|
360
|
+
if port.host:
|
|
361
|
+
sources.setdefault(port.host, f"port {port.port}/{port.protocol}")
|
|
362
|
+
if not unique:
|
|
363
|
+
return []
|
|
364
|
+
return await enrich_ips(tuple(unique), workers=options.workers, sources=sources)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
async def _check_default_creds_smart(
|
|
368
|
+
report: ScanReport,
|
|
369
|
+
options: ScanOptions,
|
|
370
|
+
) -> list[CredFinding]:
|
|
371
|
+
if not options.default_creds or report.ports is None:
|
|
372
|
+
return []
|
|
373
|
+
findings: list[CredFinding] = []
|
|
374
|
+
semaphore = asyncio.Semaphore(max(options.concurrency, 5))
|
|
375
|
+
|
|
376
|
+
async def one(host: str, scan: PortScan) -> list[CredFinding]:
|
|
377
|
+
async with semaphore:
|
|
378
|
+
return await check_default_creds(
|
|
379
|
+
host,
|
|
380
|
+
scan,
|
|
381
|
+
timeout=min(options.timeout, 8.0),
|
|
382
|
+
workers=max(options.workers // 10, 4),
|
|
383
|
+
cred_limit=options.cred_limit,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
hosts: set[str] = set()
|
|
387
|
+
if report.network is not None:
|
|
388
|
+
hosts.add(report.network.host)
|
|
389
|
+
for port in report.ports.ports:
|
|
390
|
+
if port.host:
|
|
391
|
+
hosts.add(port.host)
|
|
392
|
+
for sub in report.subdomains:
|
|
393
|
+
hosts.add(sub.name)
|
|
394
|
+
|
|
395
|
+
results = await asyncio.gather(*(one(host, report.ports) for host in hosts))
|
|
396
|
+
for result in results:
|
|
397
|
+
findings.extend(result)
|
|
398
|
+
return findings
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
async def scan_target(
|
|
402
|
+
url: str,
|
|
403
|
+
*,
|
|
404
|
+
matchers_analyzer: TechAnalyzer,
|
|
405
|
+
session: StackscanSession,
|
|
406
|
+
options: ScanOptions,
|
|
407
|
+
geo: GeoProvider,
|
|
408
|
+
semaphore: asyncio.Semaphore,
|
|
409
|
+
log: Callable[[str], None] | None = None,
|
|
410
|
+
) -> ScanReport:
|
|
411
|
+
host = host_of(url)
|
|
412
|
+
report = ScanReport(url=url)
|
|
413
|
+
started = perf_counter()
|
|
414
|
+
|
|
415
|
+
def stage(message: str) -> None:
|
|
416
|
+
if log is not None:
|
|
417
|
+
log(message)
|
|
418
|
+
|
|
419
|
+
async with semaphore:
|
|
420
|
+
stage("resolving DNS · fetching page · TLS handshake")
|
|
421
|
+
tls_coro = (
|
|
422
|
+
asyncio.to_thread(fetch_tls_info, host, port_of(url), insecure=options.insecure)
|
|
423
|
+
if options.tls and host and is_https(url)
|
|
424
|
+
else _aval(None)
|
|
425
|
+
)
|
|
426
|
+
(fetched, _effective_url), report.network, report.tls = await asyncio.gather(
|
|
427
|
+
_fetch_with_fallback(url, session, options, report),
|
|
428
|
+
_resolve_network(host, options, geo),
|
|
429
|
+
tls_coro,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
if fetched is not None:
|
|
433
|
+
stage("parsing page · detecting technologies")
|
|
434
|
+
report.final_url = fetched.url
|
|
435
|
+
report.status = fetched.status
|
|
436
|
+
report.technologies = matchers_analyzer.detect(fetched)
|
|
437
|
+
report.infra = analyze_infra(fetched.headers, tuple(fetched.cookies), host)
|
|
438
|
+
report.security = analyze_security_headers(fetched.headers)
|
|
439
|
+
report.protocols = _http_protocols(fetched, report.tls)
|
|
440
|
+
|
|
441
|
+
if (options.subdomains and host) or (options.probe and fetched is not None):
|
|
442
|
+
bits: list[str] = []
|
|
443
|
+
if options.subdomains and host:
|
|
444
|
+
bits.append("enumerating subdomains")
|
|
445
|
+
if options.probe and fetched is not None:
|
|
446
|
+
bits.append("probing exposure")
|
|
447
|
+
stage(" · ".join(bits))
|
|
448
|
+
san = report.tls.subject_alt_names if report.tls else ()
|
|
449
|
+
sub_coro = (
|
|
450
|
+
enumerate_subdomains(
|
|
451
|
+
host,
|
|
452
|
+
san_names=san,
|
|
453
|
+
dns_hosts=_dns_record_hosts(report.network),
|
|
454
|
+
timeout=options.port_timeout,
|
|
455
|
+
workers=options.workers,
|
|
456
|
+
limit=options.subdomain_limit,
|
|
457
|
+
passive=options.ct_logs,
|
|
458
|
+
)
|
|
459
|
+
if options.subdomains and host
|
|
460
|
+
else _aval([])
|
|
461
|
+
)
|
|
462
|
+
exp_coro = (
|
|
463
|
+
analyze_exposure(
|
|
464
|
+
session,
|
|
465
|
+
fetched.url,
|
|
466
|
+
ExposureProbe(
|
|
467
|
+
timeout=options.timeout,
|
|
468
|
+
user_agent=options.user_agent,
|
|
469
|
+
insecure=options.insecure,
|
|
470
|
+
),
|
|
471
|
+
)
|
|
472
|
+
if options.probe and fetched is not None
|
|
473
|
+
else _aval(None)
|
|
474
|
+
)
|
|
475
|
+
report.subdomains, report.exposure = await asyncio.gather(sub_coro, exp_coro)
|
|
476
|
+
|
|
477
|
+
if options.smart_scan:
|
|
478
|
+
ips = _collect_ips(report)
|
|
479
|
+
stage(f"scanning ports on {len(ips)} host(s)")
|
|
480
|
+
report.ports, _ = await _scan_ports_on_ips(ips, options)
|
|
481
|
+
elif options.ports and host:
|
|
482
|
+
stage("scanning ports")
|
|
483
|
+
report.ports = await scan_ports(
|
|
484
|
+
host,
|
|
485
|
+
timeout=options.port_timeout,
|
|
486
|
+
prefer_nmap=options.prefer_nmap,
|
|
487
|
+
workers=options.workers,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
if options.discover_sites:
|
|
491
|
+
stage("probing derived sites")
|
|
492
|
+
report.site_findings = await _scan_derived_sites(
|
|
493
|
+
_collect_site_candidates(report, options.site_limit),
|
|
494
|
+
session,
|
|
495
|
+
options,
|
|
496
|
+
matchers_analyzer,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
if options.cve:
|
|
500
|
+
stage("correlating CVEs")
|
|
501
|
+
report.software = extract_software(
|
|
502
|
+
fetched.headers if fetched else {},
|
|
503
|
+
fetched.body if fetched else "",
|
|
504
|
+
location=host_of(fetched.url) if fetched else host,
|
|
505
|
+
)
|
|
506
|
+
report.software.extend(software_from_ports(report.ports))
|
|
507
|
+
report.cves = match_cves(_all_software(report))
|
|
508
|
+
|
|
509
|
+
if options.ip_info or options.default_creds or (options.cve and options.cve_online):
|
|
510
|
+
stage("IP intelligence · default-cred checks")
|
|
511
|
+
online_coro = (
|
|
512
|
+
match_cves_online(_all_software(report))
|
|
513
|
+
if options.cve and options.cve_online
|
|
514
|
+
else _aval([])
|
|
515
|
+
)
|
|
516
|
+
ipinfo_coro = _enrich_unique_ips(report, options) if options.ip_info else _aval([])
|
|
517
|
+
creds_coro = _creds(report, host, options) if options.default_creds else _aval([])
|
|
518
|
+
online_cves, report.ip_info, report.creds = await asyncio.gather(
|
|
519
|
+
online_coro, ipinfo_coro, creds_coro
|
|
520
|
+
)
|
|
521
|
+
if online_cves:
|
|
522
|
+
report.cves = merge_cve_matches(report.cves, online_cves)
|
|
523
|
+
|
|
524
|
+
report.services = classify_services(report)
|
|
525
|
+
report.elapsed = perf_counter() - started
|
|
526
|
+
return report
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
async def _aval(value: object) -> Any:
|
|
530
|
+
|
|
531
|
+
return value
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
async def _creds(report: ScanReport, host: str, options: ScanOptions) -> list[CredFinding]:
|
|
535
|
+
if options.smart_scan:
|
|
536
|
+
return await _check_default_creds_smart(report, options)
|
|
537
|
+
if host and report.ports is not None:
|
|
538
|
+
return await check_default_creds(
|
|
539
|
+
host,
|
|
540
|
+
report.ports,
|
|
541
|
+
timeout=options.timeout,
|
|
542
|
+
workers=max(options.workers // 10, 4),
|
|
543
|
+
cred_limit=options.cred_limit,
|
|
544
|
+
)
|
|
545
|
+
return []
|
stackscan/theme.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
BG = "#050713"
|
|
4
|
+
BG_SOFT = "#0c1024"
|
|
5
|
+
BG_PANEL = "#0b1026"
|
|
6
|
+
TEXT = "#f5f7ff"
|
|
7
|
+
MUTED = "#9aa3c7"
|
|
8
|
+
ACCENT = "#7d78ea"
|
|
9
|
+
ACCENT_2 = "#a855f7"
|
|
10
|
+
ACCENT_SOFT = "#c3bef6"
|
|
11
|
+
DANGER = "#fb7185"
|
|
12
|
+
WARN = "#fbbf24"
|
|
13
|
+
SUCCESS = "#34d399"
|
|
14
|
+
BORDER = "#1c2140"
|
|
15
|
+
CREDIT = "Created by reekeer · https://github.com/reekeer/stackscan"
|
|
16
|
+
SEVERITY = {
|
|
17
|
+
"CRITICAL": DANGER,
|
|
18
|
+
"HIGH": "#fb923c",
|
|
19
|
+
"MEDIUM": WARN,
|
|
20
|
+
"LOW": ACCENT_SOFT,
|
|
21
|
+
"NONE": MUTED,
|
|
22
|
+
"UNKNOWN": MUTED,
|
|
23
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from stackscan.types.models import Cookies, FetchResult, Headers
|
|
2
|
+
from stackscan.types.output import (
|
|
3
|
+
CredFinding,
|
|
4
|
+
CveMatch,
|
|
5
|
+
DetectedTech,
|
|
6
|
+
ExposureInfo,
|
|
7
|
+
InfraInfo,
|
|
8
|
+
IpInfo,
|
|
9
|
+
NetworkInfo,
|
|
10
|
+
Port,
|
|
11
|
+
PortScan,
|
|
12
|
+
ScanReport,
|
|
13
|
+
SecurityHeaders,
|
|
14
|
+
ServiceFinding,
|
|
15
|
+
SiteFinding,
|
|
16
|
+
Software,
|
|
17
|
+
Subdomain,
|
|
18
|
+
Technology,
|
|
19
|
+
TlsInfo,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Cookies",
|
|
24
|
+
"CredFinding",
|
|
25
|
+
"CveMatch",
|
|
26
|
+
"DetectedTech",
|
|
27
|
+
"ExposureInfo",
|
|
28
|
+
"FetchResult",
|
|
29
|
+
"Headers",
|
|
30
|
+
"InfraInfo",
|
|
31
|
+
"IpInfo",
|
|
32
|
+
"NetworkInfo",
|
|
33
|
+
"Port",
|
|
34
|
+
"PortScan",
|
|
35
|
+
"ScanReport",
|
|
36
|
+
"SecurityHeaders",
|
|
37
|
+
"ServiceFinding",
|
|
38
|
+
"SiteFinding",
|
|
39
|
+
"Software",
|
|
40
|
+
"Subdomain",
|
|
41
|
+
"Technology",
|
|
42
|
+
"TlsInfo",
|
|
43
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import TypeAlias
|
|
6
|
+
|
|
7
|
+
Headers: TypeAlias = Mapping[str, str]
|
|
8
|
+
Cookies: TypeAlias = Sequence[str]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class FetchResult:
|
|
13
|
+
url: str
|
|
14
|
+
status: int
|
|
15
|
+
headers: Headers
|
|
16
|
+
body: str
|
|
17
|
+
cookies: Cookies
|
|
18
|
+
http_version: str | None = None
|