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