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/net/dns.py ADDED
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import socket
5
+ from concurrent.futures import ThreadPoolExecutor
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, cast
8
+
9
+ _QUERY_TIMEOUT = 2.0
10
+ _QUERY_LIFETIME = 3.0
11
+ _EXTENDED_TYPES = ("CNAME", "MX", "NS", "TXT", "SOA", "CAA")
12
+ _DNS_ATTEMPTS = 3
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class DnsResult:
17
+ host: str
18
+ ipv4: tuple[str, ...]
19
+ ipv6: tuple[str, ...]
20
+ cname: tuple[str, ...]
21
+ reverse_dns: dict[str, str]
22
+ mx: tuple[str, ...] = ()
23
+ ns: tuple[str, ...] = ()
24
+ txt: tuple[str, ...] = ()
25
+ soa: tuple[str, ...] = ()
26
+ caa: tuple[str, ...] = ()
27
+ resolver_available: bool = False
28
+ extras: dict[str, tuple[str, ...]] = field(default_factory=dict[str, tuple[str, ...]])
29
+
30
+
31
+ def _addrinfo(host: str, family: int) -> list[str]:
32
+ for attempt in range(_DNS_ATTEMPTS):
33
+ try:
34
+ infos = socket.getaddrinfo(host, None, family=family, type=socket.SOCK_STREAM)
35
+ except socket.gaierror as exc:
36
+ if attempt + 1 < _DNS_ATTEMPTS and exc.errno in (socket.EAI_AGAIN, socket.EAI_FAIL):
37
+ continue
38
+ return []
39
+ except OSError:
40
+ return []
41
+ seen: list[str] = []
42
+ for info in infos:
43
+ addr = str(info[4][0])
44
+ if family == socket.AF_INET6 and _is_v4_mapped(addr):
45
+ continue
46
+ if addr not in seen:
47
+ seen.append(addr)
48
+ return seen
49
+ return []
50
+
51
+
52
+ def _is_v4_mapped(addr: str) -> bool:
53
+ try:
54
+ parsed = ipaddress.ip_address(addr.split("%", 1)[0])
55
+ except ValueError:
56
+ return False
57
+ return isinstance(parsed, ipaddress.IPv6Address) and parsed.ipv4_mapped is not None
58
+
59
+
60
+ def _reverse(ip: str) -> str | None:
61
+ try:
62
+ name, _, _ = socket.gethostbyaddr(ip)
63
+ except OSError:
64
+ return None
65
+ return name
66
+
67
+
68
+ def _load_resolver() -> Any | None:
69
+ try:
70
+ from dns import resolver as _dns_resolver
71
+ except ImportError:
72
+ return None
73
+ return cast("Any", _dns_resolver)
74
+
75
+
76
+ def _query(resolver: Any, host: str, rdtype: str) -> list[str]:
77
+ from dns import exception as _dns_exc # type: ignore[import-untyped]
78
+
79
+ answers: Any = None
80
+ for attempt in range(_DNS_ATTEMPTS):
81
+ try:
82
+ answers = resolver.resolve(host, rdtype, raise_on_no_answer=False)
83
+ break
84
+ except (_dns_exc.Timeout, _dns_exc.DNSException) as exc:
85
+ if attempt + 1 < _DNS_ATTEMPTS and isinstance(exc, _dns_exc.Timeout):
86
+ continue
87
+ return []
88
+ except Exception:
89
+ return []
90
+ if answers is None:
91
+ return []
92
+ values: list[str] = []
93
+ for rdata in cast("list[Any]", answers):
94
+ text = _format_rdata(rdtype, rdata)
95
+ if text and text not in values:
96
+ values.append(text)
97
+ return values
98
+
99
+
100
+ def _format_rdata(rdtype: str, rdata: Any) -> str:
101
+ if rdtype == "MX":
102
+ pref = getattr(rdata, "preference", "")
103
+ exchange = str(getattr(rdata, "exchange", "")).rstrip(".") or "."
104
+ return f"{pref} {exchange}".strip()
105
+ if rdtype in {"NS", "CNAME"}:
106
+ return str(getattr(rdata, "target", rdata)).rstrip(".")
107
+ if rdtype == "TXT":
108
+ strings = getattr(rdata, "strings", None)
109
+ if strings:
110
+ return "".join(
111
+ part.decode("utf-8", "replace") if isinstance(part, bytes) else str(part)
112
+ for part in cast("list[Any]", strings)
113
+ )
114
+ return str(rdata).strip('"')
115
+ if rdtype == "SOA":
116
+ mname = str(getattr(rdata, "mname", "")).rstrip(".")
117
+ rname = str(getattr(rdata, "rname", "")).rstrip(".")
118
+ serial = getattr(rdata, "serial", "")
119
+ return f"{mname} {rname} {serial}".strip()
120
+ if rdtype == "CAA":
121
+ flags = getattr(rdata, "flags", "")
122
+ tag = getattr(rdata, "tag", "")
123
+ value = getattr(rdata, "value", "")
124
+ if isinstance(tag, bytes):
125
+ tag = tag.decode("utf-8", "replace")
126
+ if isinstance(value, bytes):
127
+ value = value.decode("utf-8", "replace")
128
+ return f"{flags} {tag} {value}".strip()
129
+ return str(rdata).rstrip(".")
130
+
131
+
132
+ def _extended_records(host: str) -> dict[str, tuple[str, ...]]:
133
+ resolver_mod = _load_resolver()
134
+ if resolver_mod is None:
135
+ return {}
136
+ resolver = resolver_mod.Resolver()
137
+ resolver.timeout = _QUERY_TIMEOUT
138
+ resolver.lifetime = _QUERY_LIFETIME
139
+ with ThreadPoolExecutor(max_workers=len(_EXTENDED_TYPES)) as pool:
140
+ futures = {
141
+ rdtype: pool.submit(_query, resolver, host, rdtype) for rdtype in _EXTENDED_TYPES
142
+ }
143
+ return {rdtype: tuple(future.result()) for rdtype, future in futures.items()}
144
+
145
+
146
+ def resolve_host(host: str, *, reverse: bool = True) -> DnsResult:
147
+ ipv4 = tuple(_addrinfo(host, socket.AF_INET))
148
+ ipv6 = tuple(_addrinfo(host, socket.AF_INET6))
149
+ records = _extended_records(host)
150
+ reverse_map: dict[str, str] = {}
151
+ if reverse:
152
+ for ip in (*ipv4, *ipv6):
153
+ name = _reverse(ip)
154
+ if name:
155
+ reverse_map[ip] = name
156
+ return DnsResult(
157
+ host=host,
158
+ ipv4=ipv4,
159
+ ipv6=ipv6,
160
+ cname=records.get("CNAME", ()),
161
+ reverse_dns=reverse_map,
162
+ mx=records.get("MX", ()),
163
+ ns=records.get("NS", ()),
164
+ txt=records.get("TXT", ()),
165
+ soa=records.get("SOA", ()),
166
+ caa=records.get("CAA", ()),
167
+ resolver_available=bool(records),
168
+ )
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ _BANNER_SIGNATURES: tuple[tuple[re.Pattern[str], str, str], ...] = (
6
+ (re.compile("SSH-\\d+\\.\\d+-OpenSSH[_-]([\\w.]+)", re.I), "ssh", "OpenSSH"),
7
+ (re.compile("SSH-\\d+\\.\\d+-Dropbear[_-]?([\\w.]+)?", re.I), "ssh", "Dropbear"),
8
+ (re.compile("220[- ].*ProFTPD (\\d[\\w.]+)", re.I), "ftp", "ProFTPD"),
9
+ (re.compile("220[- ].*\\(?vsFTPd (\\d[\\w.]+)\\)?", re.I), "ftp", "vsftpd"),
10
+ (re.compile("220[- ].*Pure-FTPd", re.I), "ftp", "Pure-FTPd"),
11
+ (re.compile("220[- ].*FileZilla Server (\\d[\\w.]+)", re.I), "ftp", "FileZilla"),
12
+ (re.compile("220[- ].*Exim (\\d[\\w.]+)", re.I), "smtp", "Exim"),
13
+ (re.compile("220[- ].*Postfix", re.I), "smtp", "Postfix"),
14
+ (re.compile("220[- ].*Sendmail (\\d[\\w.]+)", re.I), "smtp", "Sendmail"),
15
+ (re.compile("\\+OK.*Dovecot", re.I), "pop3", "Dovecot"),
16
+ (re.compile("\\* OK.*Dovecot", re.I), "imap", "Dovecot"),
17
+ (re.compile("^-ERR|^\\+PONG|^\\$\\d", re.I), "redis", "Redis"),
18
+ (re.compile("RFB (\\d+\\.\\d+)", re.I), "vnc", "VNC"),
19
+ )
20
+ _HTTP_SERVER_RE = re.compile("^server:\\s*(.+?)\\s*$", re.I | re.M)
21
+ _SERVER_TOKEN_RE = re.compile("([A-Za-z][A-Za-z0-9._+-]*?)/([\\w.]+)")
22
+
23
+
24
+ def fingerprint_banner(banner: str) -> tuple[str | None, str | None, str | None]:
25
+ for pattern, service, product in _BANNER_SIGNATURES:
26
+ match = pattern.search(banner)
27
+ if match:
28
+ version = match.group(1) if match.groups() else None
29
+ return (service, product, version)
30
+ return (None, None, None)
31
+
32
+
33
+ def fingerprint_http(raw_response: str) -> tuple[str | None, str | None]:
34
+ match = _HTTP_SERVER_RE.search(raw_response)
35
+ if not match:
36
+ return (None, None)
37
+ server = match.group(1).strip()
38
+ token = _SERVER_TOKEN_RE.search(server)
39
+ if token:
40
+ return (token.group(1), token.group(2))
41
+ return (server, None)
stackscan/net/geo.py ADDED
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any, cast
6
+
7
+
8
+ class GeoProvider:
9
+ def __init__(self, db_path: str | Path | None = None) -> None:
10
+ env_path = os.environ.get("STACKSCAN_GEOIP_DB")
11
+ resolved = db_path or env_path
12
+ self._db_path = Path(resolved) if resolved else None
13
+ self._reader: Any = None
14
+ self._unavailable = False
15
+
16
+ @property
17
+ def enabled(self) -> bool:
18
+ return self._db_path is not None and self._db_path.is_file()
19
+
20
+ def _ensure_reader(self) -> Any:
21
+ if self._reader is not None or self._unavailable:
22
+ return self._reader
23
+ if not self.enabled:
24
+ self._unavailable = True
25
+ return None
26
+ try:
27
+ from geoip2 import database as _geoip_db
28
+ except ImportError:
29
+ self._unavailable = True
30
+ return None
31
+ try:
32
+ self._reader = cast("Any", _geoip_db).Reader(str(self._db_path))
33
+ except Exception:
34
+ self._unavailable = True
35
+ return None
36
+ return self._reader
37
+
38
+ def lookup(self, ip: str) -> dict[str, str]:
39
+ reader: Any = self._ensure_reader()
40
+ if reader is None:
41
+ return {}
42
+ try:
43
+ response: Any = reader.city(ip)
44
+ except Exception:
45
+ return {}
46
+ out: dict[str, str] = {}
47
+ country = getattr(getattr(response, "country", None), "iso_code", None)
48
+ country_name = getattr(getattr(response, "country", None), "name", None)
49
+ city = getattr(getattr(response, "city", None), "name", None)
50
+ if isinstance(country, str):
51
+ out["country_code"] = country
52
+ if isinstance(country_name, str):
53
+ out["country"] = country_name
54
+ if isinstance(city, str):
55
+ out["city"] = city
56
+ return out
57
+
58
+
59
+ def lookup_geo(
60
+ ips: tuple[str, ...], provider: GeoProvider | None = None
61
+ ) -> dict[str, dict[str, str]]:
62
+ provider = provider or GeoProvider()
63
+ if not provider.enabled:
64
+ return {}
65
+ result: dict[str, dict[str, str]] = {}
66
+ for ip in ips:
67
+ data = provider.lookup(ip)
68
+ if data:
69
+ result[ip] = data
70
+ return result
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import ipaddress
5
+ from typing import Any, cast
6
+
7
+ from stackscan.types import IpInfo
8
+
9
+ _IPWHO_URL = "https://ipwho.is/"
10
+ _CDN_KEYWORDS = (
11
+ "cloudflare",
12
+ "fastly",
13
+ "akamai",
14
+ "cloudfront",
15
+ "amazon",
16
+ "aws",
17
+ "google",
18
+ "microsoft",
19
+ "azure",
20
+ "incapsula",
21
+ "imperva",
22
+ "sucuri",
23
+ "stackpath",
24
+ "cdn77",
25
+ "bunny",
26
+ "keycdn",
27
+ "limelight",
28
+ "edgecast",
29
+ "gcore",
30
+ "edgio",
31
+ "verizon",
32
+ )
33
+
34
+
35
+ def _is_public(ip: str) -> bool:
36
+ try:
37
+ parsed = ipaddress.ip_address(ip.split("%", 1)[0])
38
+ except ValueError:
39
+ return False
40
+ return not (parsed.is_private or parsed.is_loopback or parsed.is_link_local)
41
+
42
+
43
+ def _looks_like_cdn(*values: str | None) -> bool:
44
+ blob = " ".join(v.lower() for v in values if v)
45
+ return any(keyword in blob for keyword in _CDN_KEYWORDS)
46
+
47
+
48
+ async def enrich_ips(
49
+ ips: tuple[str, ...],
50
+ *,
51
+ timeout: float = 10.0,
52
+ workers: int = 5,
53
+ sources: dict[str, str] | None = None,
54
+ ) -> list[IpInfo]:
55
+ import aiohttp
56
+
57
+ targets = [ip for ip in dict.fromkeys(ips) if _is_public(ip)]
58
+ if not targets:
59
+ return []
60
+ sources = sources or {}
61
+ semaphore = asyncio.Semaphore(max(workers, 1))
62
+ client_timeout = aiohttp.ClientTimeout(total=timeout)
63
+ async with aiohttp.ClientSession(timeout=client_timeout) as session:
64
+
65
+ async def lookup(ip: str) -> IpInfo | None:
66
+ async with semaphore:
67
+ try:
68
+ async with session.get(
69
+ f"{_IPWHO_URL}{ip}", headers={"User-Agent": "stackscan"}
70
+ ) as resp:
71
+ if resp.status != 200:
72
+ return None
73
+ data = cast("dict[str, Any]", await resp.json())
74
+ except (aiohttp.ClientError, TimeoutError, ValueError, OSError):
75
+ return None
76
+ if not data.get("success"):
77
+ return None
78
+ connection = cast("dict[str, Any]", data.get("connection") or {})
79
+ asn = connection.get("asn")
80
+ org = connection.get("org")
81
+ isp = connection.get("isp")
82
+ return IpInfo(
83
+ ip=ip,
84
+ country=data.get("country"),
85
+ city=data.get("city"),
86
+ org=org,
87
+ isp=isp,
88
+ asn=f"AS{asn}" if asn else None,
89
+ is_cdn=_looks_like_cdn(org, isp),
90
+ source=sources.get(ip, ""),
91
+ )
92
+
93
+ results = await asyncio.gather(*(lookup(ip) for ip in targets))
94
+ return [info for info in results if info is not None]
stackscan/net/ports.py ADDED
@@ -0,0 +1,219 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import shutil
5
+ from typing import Any, cast
6
+
7
+ from stackscan.net.fingerprint import fingerprint_banner, fingerprint_http
8
+ from stackscan.types import Port, PortScan
9
+
10
+ COMMON_PORTS: dict[int, tuple[str, str]] = {
11
+ 21: ("ftp", "banner"),
12
+ 22: ("ssh", "banner"),
13
+ 23: ("telnet", "banner"),
14
+ 25: ("smtp", "banner"),
15
+ 53: ("domain", "none"),
16
+ 80: ("http", "http"),
17
+ 110: ("pop3", "banner"),
18
+ 111: ("rpcbind", "none"),
19
+ 135: ("msrpc", "none"),
20
+ 139: ("netbios-ssn", "none"),
21
+ 143: ("imap", "banner"),
22
+ 443: ("https", "http"),
23
+ 445: ("microsoft-ds", "none"),
24
+ 465: ("smtps", "banner"),
25
+ 554: ("rtsp", "rtsp"),
26
+ 587: ("submission", "banner"),
27
+ 631: ("ipp", "http"),
28
+ 993: ("imaps", "none"),
29
+ 995: ("pop3s", "none"),
30
+ 1433: ("ms-sql", "none"),
31
+ 1723: ("pptp", "none"),
32
+ 1883: ("mqtt", "banner"),
33
+ 2082: ("cpanel", "http"),
34
+ 2083: ("cpanel-ssl", "http"),
35
+ 2222: ("ssh-alt", "banner"),
36
+ 3000: ("http-dev", "http"),
37
+ 3306: ("mysql", "banner"),
38
+ 3389: ("ms-wbt-server", "none"),
39
+ 5432: ("postgresql", "none"),
40
+ 5900: ("vnc", "banner"),
41
+ 5985: ("wsman", "none"),
42
+ 6379: ("redis", "banner"),
43
+ 7547: ("cwmp", "http"),
44
+ 8000: ("http-alt", "http"),
45
+ 8080: ("http-proxy", "http"),
46
+ 8081: ("http-alt", "http"),
47
+ 8443: ("https-alt", "http"),
48
+ 8554: ("rtsp-alt", "rtsp"),
49
+ 8888: ("http-alt", "http"),
50
+ 9000: ("http-alt", "http"),
51
+ 9200: ("elasticsearch", "http"),
52
+ 11211: ("memcached", "none"),
53
+ 27017: ("mongodb", "none"),
54
+ }
55
+ _BANNER_BYTES = 512
56
+
57
+
58
+ def nmap_available() -> bool:
59
+ return shutil.which("nmap") is not None
60
+
61
+
62
+ def default_ports() -> tuple[int, ...]:
63
+ return tuple(sorted(COMMON_PORTS))
64
+
65
+
66
+ async def scan_ports(
67
+ host: str,
68
+ *,
69
+ ports: tuple[int, ...] | None = None,
70
+ timeout: float = 2.0,
71
+ prefer_nmap: bool = True,
72
+ workers: int = 100,
73
+ ) -> PortScan:
74
+ targets = ports or default_ports()
75
+ if prefer_nmap and nmap_available():
76
+ result = await asyncio.to_thread(_run_nmap, host, targets)
77
+ if result is not None:
78
+ return result
79
+ return await _connect_scan(host, targets, timeout, workers)
80
+
81
+
82
+ def _run_nmap(host: str, ports: tuple[int, ...]) -> PortScan | None:
83
+ try:
84
+ import nmap
85
+ except ImportError:
86
+ return None
87
+ try:
88
+ scanner = cast("Any", nmap).PortScanner()
89
+ port_arg = ",".join(str(p) for p in ports)
90
+ scanner.scan(host, port_arg, arguments="-Pn -sV --version-light -T4")
91
+ except Exception:
92
+ return None
93
+ found: list[Port] = []
94
+ try:
95
+ hosts = cast("list[str]", scanner.all_hosts())
96
+ except Exception:
97
+ return None
98
+ for scanned in hosts:
99
+ tcp = cast("dict[int, dict[str, Any]]", scanner[scanned].get("tcp", {}))
100
+ for number, info in tcp.items():
101
+ if info.get("state") != "open":
102
+ continue
103
+ product = info.get("product") or None
104
+ version = info.get("version") or None
105
+ extra = info.get("extrainfo") or None
106
+ if extra and version:
107
+ version = f"{version} ({extra})"
108
+ found.append(
109
+ Port(
110
+ port=int(number),
111
+ protocol="tcp",
112
+ state="open",
113
+ service=info.get("name") or None,
114
+ product=product,
115
+ version=version,
116
+ host=host,
117
+ )
118
+ )
119
+ found.sort(key=lambda p: p.port)
120
+ return PortScan(scanner="nmap", ports=tuple(found))
121
+
122
+
123
+ async def _connect_scan(
124
+ host: str, ports: tuple[int, ...], timeout: float, workers: int
125
+ ) -> PortScan:
126
+ semaphore = asyncio.Semaphore(max(workers, 1))
127
+
128
+ async def probe(port: int) -> Port | None:
129
+ async with semaphore:
130
+ return await _probe_port(host, port, timeout)
131
+
132
+ results = await asyncio.gather(*(probe(port) for port in ports))
133
+ open_ports = tuple(sorted((p for p in results if p is not None), key=lambda p: p.port))
134
+ note = "pure-Python connect scan (install nmap + python-nmap for full -sV detection)"
135
+ return PortScan(scanner="connect", ports=open_ports, note=note)
136
+
137
+
138
+ async def _probe_port(host: str, port: int, timeout: float) -> Port | None:
139
+ service, probe = COMMON_PORTS.get(port, ("unknown", "banner"))
140
+ try:
141
+ reader, writer = await asyncio.wait_for(
142
+ asyncio.open_connection(host, port), timeout=timeout
143
+ )
144
+ except (TimeoutError, OSError):
145
+ return None
146
+ product: str | None = None
147
+ version: str | None = None
148
+ try:
149
+ if probe == "banner":
150
+ banner = await _read(reader, timeout)
151
+ if banner:
152
+ svc, product, version = fingerprint_banner(banner)
153
+ service = svc or service
154
+ if not product:
155
+ version = banner[:80]
156
+ elif probe == "http":
157
+ product, version = await _http_probe(host, port, reader, writer, timeout)
158
+ elif probe == "rtsp":
159
+ product, version = await _rtsp_probe(host, port, reader, writer, timeout)
160
+ finally:
161
+ writer.close()
162
+ try:
163
+ await writer.wait_closed()
164
+ except (TimeoutError, OSError):
165
+ pass
166
+ return Port(
167
+ port=port,
168
+ protocol="tcp",
169
+ state="open",
170
+ service=service,
171
+ product=product,
172
+ version=version,
173
+ host=host,
174
+ )
175
+
176
+
177
+ async def _read(reader: asyncio.StreamReader, timeout: float) -> str | None:
178
+ try:
179
+ data = await asyncio.wait_for(reader.read(_BANNER_BYTES), timeout=min(timeout, 2.5))
180
+ except (TimeoutError, OSError):
181
+ return None
182
+ if not data:
183
+ return None
184
+ return data.decode("utf-8", "replace").strip() or None
185
+
186
+
187
+ async def _http_probe(
188
+ host: str, port: int, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, timeout: float
189
+ ) -> tuple[str | None, str | None]:
190
+ request = (
191
+ f"GET / HTTP/1.0\r\nHost: {host}\r\nUser-Agent: stackscan\r\nConnection: close\r\n\r\n"
192
+ )
193
+ try:
194
+ writer.write(request.encode("ascii", "ignore"))
195
+ await asyncio.wait_for(writer.drain(), timeout=min(timeout, 2.5))
196
+ except (TimeoutError, OSError):
197
+ return (None, None)
198
+ raw = await _read(reader, timeout)
199
+ if not raw:
200
+ return (None, None)
201
+ return fingerprint_http(raw)
202
+
203
+
204
+ async def _rtsp_probe(
205
+ host: str, port: int, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, timeout: float
206
+ ) -> tuple[str | None, str | None]:
207
+ request = f"OPTIONS rtsp://{host}:{port} RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: stackscan\r\n\r\n"
208
+ try:
209
+ writer.write(request.encode("ascii", "ignore"))
210
+ await asyncio.wait_for(writer.drain(), timeout=min(timeout, 2.5))
211
+ except (TimeoutError, OSError):
212
+ return (None, None)
213
+ raw = await _read(reader, timeout)
214
+ if not raw:
215
+ return (None, None)
216
+ server = fingerprint_http(raw)
217
+ if server != (None, None):
218
+ return server
219
+ return ("RTSP", None)
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import socket
4
+
5
+ import aiohttp
6
+ from aiohttp.abc import AbstractResolver, ResolveResult
7
+ from aiohttp.resolver import ThreadedResolver
8
+
9
+
10
+ class FallbackResolver(AbstractResolver):
11
+ def __init__(self) -> None:
12
+ self._system = ThreadedResolver()
13
+
14
+ async def resolve(
15
+ self, host: str, port: int = 0, family: socket.AddressFamily = socket.AddressFamily.AF_INET
16
+ ) -> list[ResolveResult]:
17
+ try:
18
+ return await self._system.resolve(host, port, family)
19
+ except OSError:
20
+ results = await self._fallback(host, port, family)
21
+ if results:
22
+ return results
23
+ raise
24
+
25
+ async def _fallback(
26
+ self, host: str, port: int, family: socket.AddressFamily
27
+ ) -> list[ResolveResult]:
28
+ if family not in (socket.AddressFamily.AF_INET, socket.AF_UNSPEC):
29
+ return []
30
+ try:
31
+ from stackscan.net.subdomains import resolve_many
32
+
33
+ resolved = await resolve_many([host], 3.0, 100)
34
+ except Exception:
35
+ return []
36
+ addresses = resolved.get(host, ())
37
+ return [
38
+ ResolveResult(
39
+ hostname=host,
40
+ host=address,
41
+ port=port,
42
+ family=socket.AF_INET,
43
+ proto=0,
44
+ flags=socket.AI_NUMERICHOST,
45
+ )
46
+ for address in addresses
47
+ ]
48
+
49
+ async def close(self) -> None:
50
+ await self._system.close()
51
+
52
+
53
+ def build_connector() -> aiohttp.TCPConnector:
54
+ return aiohttp.TCPConnector(resolver=FallbackResolver())