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
@@ -0,0 +1,457 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import re
5
+ import secrets
6
+ import socket
7
+ import urllib.request
8
+ from functools import lru_cache
9
+ from importlib import resources
10
+ from typing import Any, cast
11
+
12
+ from stackscan.types import Subdomain
13
+ from stackscan.utils import db_dir
14
+
15
+ _WORDLIST_URL = "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-110000.txt"
16
+ _WORDLIST_TIMEOUT = 20
17
+ _WORDLIST_MAX_BYTES = 8 * 1024 * 1024
18
+
19
+
20
+ def _parse_labels(text: str) -> tuple[str, ...]:
21
+ labels: list[str] = []
22
+ seen: set[str] = set()
23
+ for line in text.splitlines():
24
+ line = line.strip()
25
+ if not line or line.startswith("#") or line in seen:
26
+ continue
27
+ seen.add(line)
28
+ labels.append(line)
29
+ return tuple(labels)
30
+
31
+
32
+ @lru_cache(maxsize=1)
33
+ def load_bundled_wordlist() -> tuple[str, ...]:
34
+ text = resources.files("stackscan.data").joinpath("subdomains.txt").read_text("utf-8")
35
+ return _parse_labels(text)
36
+
37
+
38
+ def _download_wordlist() -> str:
39
+ request = urllib.request.Request(_WORDLIST_URL, headers={"User-Agent": "stackscan"})
40
+ with urllib.request.urlopen(request, timeout=_WORDLIST_TIMEOUT) as response:
41
+ return response.read(_WORDLIST_MAX_BYTES).decode("utf-8", "replace")
42
+
43
+
44
+ @lru_cache(maxsize=1)
45
+ def load_wordlist() -> tuple[str, ...]:
46
+ cache = db_dir() / "seclists-dns-top110k.txt"
47
+ if cache.is_file():
48
+ try:
49
+ return _parse_labels(cache.read_text("utf-8"))
50
+ except OSError:
51
+ pass
52
+ try:
53
+ text = _download_wordlist()
54
+ except (OSError, ValueError):
55
+ return load_bundled_wordlist()
56
+ try:
57
+ cache.parent.mkdir(parents=True, exist_ok=True)
58
+ cache.write_text(text, encoding="utf-8")
59
+ except OSError:
60
+ pass
61
+ labels = _parse_labels(text)
62
+ return labels or load_bundled_wordlist()
63
+
64
+
65
+ def apex_domain(host: str) -> str:
66
+ host = host.lower().strip(".")
67
+ if host.startswith("www."):
68
+ return host[4:]
69
+ return host
70
+
71
+
72
+ def _resolve(name: str) -> tuple[str, ...]:
73
+ try:
74
+ infos = socket.getaddrinfo(name, None, type=socket.SOCK_STREAM)
75
+ except OSError:
76
+ return ()
77
+ seen: list[str] = []
78
+ for info in infos:
79
+ addr = str(info[4][0])
80
+ if addr not in seen:
81
+ seen.append(addr)
82
+ return tuple(seen)
83
+
84
+
85
+ def _zone_transfer(apex: str) -> dict[str, tuple[str, ...]]:
86
+ try:
87
+ from dns import query as _dns_query, resolver as _dns_resolver, zone as _dns_zone
88
+ except ImportError:
89
+ return {}
90
+ resolver = cast("Any", _dns_resolver)
91
+ query = cast("Any", _dns_query)
92
+ zone_mod = cast("Any", _dns_zone)
93
+ try:
94
+ ns_answers: Any = resolver.resolve(apex, "NS", raise_on_no_answer=False)
95
+ except Exception:
96
+ return {}
97
+ found: dict[str, tuple[str, ...]] = {}
98
+ for ns in cast("list[Any]", ns_answers):
99
+ ns_name = str(getattr(ns, "target", ns)).rstrip(".")
100
+ if not ns_name:
101
+ continue
102
+ try:
103
+ xfr = query.xfr(ns_name, apex, timeout=5.0, lifetime=8.0)
104
+ zone = zone_mod.from_xfr(xfr)
105
+ except Exception:
106
+ continue
107
+ for name, _ttl, rdata in zone.iterate_rdatas("A"):
108
+ fqdn = str(name)
109
+ fqdn = apex if fqdn in ("@", "") else f"{fqdn}.{apex}".rstrip(".")
110
+ addr = str(getattr(rdata, "address", "")).strip()
111
+ if addr:
112
+ found.setdefault(fqdn, ())
113
+ if addr not in found[fqdn]:
114
+ found[fqdn] = (*found[fqdn], addr)
115
+ return found
116
+
117
+
118
+ def _ordered_labels(limit: int) -> tuple[str, ...]:
119
+ seen: set[str] = set()
120
+ ordered: list[str] = []
121
+ for label in (*load_bundled_wordlist(), *load_wordlist()):
122
+ if label not in seen:
123
+ seen.add(label)
124
+ ordered.append(label)
125
+ return tuple(ordered[:limit]) if limit > 0 else tuple(ordered)
126
+
127
+
128
+ _PUBLIC_RESOLVERS: tuple[str, ...] = (
129
+ "1.1.1.1",
130
+ "1.0.0.1",
131
+ "8.8.8.8",
132
+ "8.8.4.4",
133
+ "9.9.9.9",
134
+ "149.112.112.112",
135
+ "208.67.222.222",
136
+ "208.67.220.220",
137
+ "94.140.14.14",
138
+ "94.140.15.15",
139
+ "185.228.168.9",
140
+ "185.228.169.9",
141
+ "76.76.2.0",
142
+ "76.76.10.0",
143
+ "8.26.56.26",
144
+ "8.20.247.20",
145
+ "64.6.64.6",
146
+ "64.6.65.6",
147
+ "156.154.70.1",
148
+ "156.154.71.1",
149
+ "77.88.8.8",
150
+ "77.88.8.1",
151
+ "84.200.69.80",
152
+ "84.200.70.40",
153
+ "4.2.2.1",
154
+ "4.2.2.2",
155
+ )
156
+
157
+
158
+ async def _resolve_many(
159
+ names: list[str], timeout: float, workers: int
160
+ ) -> dict[str, tuple[str, ...]]:
161
+ try:
162
+ from dns import asyncquery as _asyncquery, message as _message, rdatatype as _rdatatype
163
+ except ImportError:
164
+ return await _resolve_many_thread(names, timeout, workers)
165
+ asyncquery = cast("Any", _asyncquery)
166
+ message = cast("Any", _message)
167
+ a_type = cast("Any", _rdatatype).A
168
+ query_timeout = min(timeout, 2.0)
169
+ semaphore = asyncio.Semaphore(max(workers, 1))
170
+ out: dict[str, tuple[str, ...]] = {}
171
+
172
+ async def query_once(name: str, server: str) -> Any | None:
173
+ async with semaphore:
174
+ try:
175
+ query = message.make_query(name, a_type)
176
+ return await asyncquery.udp(query, server, timeout=query_timeout)
177
+ except Exception:
178
+ return None
179
+
180
+ async def one(name: str, index: int) -> None:
181
+ response: Any | None = None
182
+ for attempt in range(3):
183
+ server = _PUBLIC_RESOLVERS[(index + attempt) % len(_PUBLIC_RESOLVERS)]
184
+ response = await query_once(name, server)
185
+ if response is not None:
186
+ break
187
+ if response is None:
188
+ return
189
+ addrs: list[str] = []
190
+ for rrset in cast("list[Any]", response.answer):
191
+ if rrset.rdtype != a_type:
192
+ continue
193
+ for item in cast("list[Any]", rrset):
194
+ addr = str(getattr(item, "address", "")).strip()
195
+ if addr and addr not in addrs:
196
+ addrs.append(addr)
197
+ if addrs:
198
+ out[name] = tuple(addrs)
199
+
200
+ await asyncio.gather(*(one(name, i) for i, name in enumerate(names)))
201
+ return out
202
+
203
+
204
+ async def resolve_many(
205
+ names: list[str], timeout: float, workers: int
206
+ ) -> dict[str, tuple[str, ...]]:
207
+ return await _resolve_many(names, timeout, workers)
208
+
209
+
210
+ async def resolve_existing(
211
+ names: list[str], *, timeout: float = 2.0, workers: int = 1500
212
+ ) -> list[str]:
213
+ resolved = await _resolve_many(names, timeout, workers)
214
+ return sorted(resolved)
215
+
216
+
217
+ async def _resolve_many_thread(
218
+ names: list[str], timeout: float, workers: int
219
+ ) -> dict[str, tuple[str, ...]]:
220
+ semaphore = asyncio.Semaphore(max(workers, 1))
221
+ out: dict[str, tuple[str, ...]] = {}
222
+
223
+ async def one(name: str) -> None:
224
+ async with semaphore:
225
+ try:
226
+ addrs = await asyncio.wait_for(asyncio.to_thread(_resolve, name), timeout=timeout)
227
+ except TimeoutError:
228
+ return
229
+ if addrs:
230
+ out[name] = addrs
231
+
232
+ await asyncio.gather(*(one(name) for name in names))
233
+ return out
234
+
235
+
236
+ async def _wildcard_ips(apex: str, timeout: float, workers: int) -> set[str]:
237
+ probes = [f"{secrets.token_hex(8)}-stackscan.{apex}" for _ in range(3)]
238
+ resolved = await _resolve_many(probes, timeout, workers)
239
+ ips: set[str] = set()
240
+ for addrs in resolved.values():
241
+ ips.update(addrs)
242
+ return ips
243
+
244
+
245
+ _RECURSIVE_PREFIXES: tuple[str, ...] = (
246
+ "www",
247
+ "mail",
248
+ "mx",
249
+ "mx1",
250
+ "mx2",
251
+ "mx3",
252
+ "smtp",
253
+ "imap",
254
+ "pop",
255
+ "webmail",
256
+ "ns",
257
+ "ns1",
258
+ "ns2",
259
+ "api",
260
+ "app",
261
+ "web",
262
+ "dev",
263
+ "staging",
264
+ "test",
265
+ "vpn",
266
+ "admin",
267
+ "portal",
268
+ "cdn",
269
+ "static",
270
+ "git",
271
+ "ftp",
272
+ "gateway",
273
+ "remote",
274
+ "autodiscover",
275
+ "autoconfig",
276
+ "cpanel",
277
+ "server",
278
+ "server1",
279
+ "node1",
280
+ )
281
+ _MAX_RECURSIVE = 3000
282
+ _HOST_RE = re.compile("[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+")
283
+
284
+
285
+ def hostnames_in_records(values: tuple[str, ...], apex: str) -> set[str]:
286
+ suffix = "." + apex
287
+ found: set[str] = set()
288
+ for value in values:
289
+ for match in _HOST_RE.findall(value.lower()):
290
+ host = match.strip(".")
291
+ if host != apex and host.endswith(suffix):
292
+ found.add(host)
293
+ return found
294
+
295
+
296
+ def _parent_zones(name: str, apex: str) -> list[str]:
297
+ zones: list[str] = []
298
+ labels = name.split(".")
299
+ apex_len = len(apex.split("."))
300
+ while len(labels) > apex_len + 1:
301
+ labels = labels[1:]
302
+ zones.append(".".join(labels))
303
+ return zones
304
+
305
+
306
+ async def enumerate_subdomains(
307
+ host: str,
308
+ *,
309
+ san_names: tuple[str, ...] = (),
310
+ dns_hosts: tuple[str, ...] = (),
311
+ timeout: float = 3.0,
312
+ workers: int = 100,
313
+ limit: int = 5000,
314
+ recursive: bool = True,
315
+ passive: bool = True,
316
+ ) -> list[Subdomain]:
317
+ apex = apex_domain(host)
318
+ if not apex:
319
+ return []
320
+ discovered: dict[str, Subdomain] = {}
321
+ dns_workers = min(max(workers * 10, 1000), 1500)
322
+
323
+ ct_coro = _cert_transparency(apex) if passive else _empty_set()
324
+ axfr, wildcard, ct_names = await asyncio.gather(
325
+ asyncio.to_thread(_zone_transfer, apex),
326
+ _wildcard_ips(apex, timeout, dns_workers),
327
+ ct_coro,
328
+ )
329
+ for name, addrs in axfr.items():
330
+ discovered[name] = Subdomain(name=name, addresses=addrs, source="axfr")
331
+
332
+ labels = await asyncio.to_thread(_ordered_labels, limit)
333
+ candidates: dict[str, str] = {}
334
+ for label in labels:
335
+ candidates.setdefault(f"{label}.{apex}", "dns-wordlist")
336
+ for san in san_names:
337
+ san = san.lower().lstrip("*.").strip(".")
338
+ if san and san != apex and san.endswith("." + apex):
339
+ candidates.setdefault(san, "tls-san")
340
+ for host_name in hostnames_in_records(dns_hosts, apex):
341
+ candidates.setdefault(host_name, "dns-record")
342
+
343
+ for ct_name in ct_names:
344
+ candidates.setdefault(ct_name, "crt.sh")
345
+ pending = [name for name in candidates if name not in discovered]
346
+ await _resolve_into(discovered, candidates, pending, timeout, dns_workers, wildcard)
347
+
348
+ for name, source in candidates.items():
349
+ if source in ("crt.sh", "tls-san", "dns-record") and name not in discovered:
350
+ discovered[name] = Subdomain(name=name, addresses=(), source=source)
351
+
352
+ if recursive:
353
+ bases: set[str] = set()
354
+ for name in list(discovered):
355
+ if name == apex or not name.endswith("." + apex):
356
+ continue
357
+ bases.add(name)
358
+ bases.update(_parent_zones(name, apex))
359
+ rec: dict[str, str] = {}
360
+ for base in bases:
361
+ for prefix in _RECURSIVE_PREFIXES:
362
+ cand = f"{prefix}.{base}"
363
+ if cand not in discovered and cand not in candidates:
364
+ rec.setdefault(cand, "recursive")
365
+ rec_pending = list(rec)[:_MAX_RECURSIVE]
366
+ await _resolve_into(discovered, rec, rec_pending, timeout, dns_workers, wildcard)
367
+ return sorted(discovered.values(), key=lambda sub: sub.name)
368
+
369
+
370
+ async def _resolve_into(
371
+ discovered: dict[str, Subdomain],
372
+ sources: dict[str, str],
373
+ pending: list[str],
374
+ timeout: float,
375
+ workers: int,
376
+ wildcard: set[str],
377
+ ) -> None:
378
+ resolved = await _resolve_many(pending, timeout, workers)
379
+ for name, addrs in resolved.items():
380
+ source = sources.get(name, "dns")
381
+
382
+ if (
383
+ wildcard
384
+ and source not in ("tls-san", "dns-record", "axfr", "crt.sh")
385
+ and (set(addrs) <= wildcard)
386
+ ):
387
+ continue
388
+ discovered.setdefault(name, Subdomain(name=name, addresses=addrs, source=source))
389
+
390
+
391
+ async def _empty_set() -> set[str]:
392
+ return set()
393
+
394
+
395
+ _CT_TIMEOUT = 25
396
+
397
+
398
+ async def _cert_transparency(apex: str) -> set[str]:
399
+
400
+ async def safe(coro: Any) -> set[str]:
401
+ try:
402
+ return await coro
403
+ except Exception:
404
+ return set()
405
+
406
+ crtsh_names = await safe(_crtsh(apex))
407
+ if not crtsh_names:
408
+ crtsh_names = await safe(_crtsh(apex))
409
+ certspotter_names = await safe(_certspotter(apex))
410
+ return crtsh_names | certspotter_names
411
+
412
+
413
+ def _ct_names_under(raw: object, apex: str) -> set[str]:
414
+ suffix = "." + apex
415
+ out: set[str] = set()
416
+ for chunk in str(raw).replace(",", "\n").splitlines():
417
+ name = chunk.strip().lower().lstrip("*.").strip(".")
418
+ if name and name != apex and name.endswith(suffix) and " " not in name:
419
+ out.add(name)
420
+ return out
421
+
422
+
423
+ async def _crtsh(apex: str) -> set[str]:
424
+ import aiohttp
425
+
426
+ url = f"https://crt.sh/?q=%25.{apex}&output=json"
427
+ timeout = aiohttp.ClientTimeout(total=_CT_TIMEOUT)
428
+ async with aiohttp.ClientSession(timeout=timeout) as session:
429
+ async with session.get(url, headers={"User-Agent": "stackscan"}) as resp:
430
+ if resp.status != 200:
431
+ return set()
432
+ rows = cast("list[dict[str, Any]]", await resp.json(content_type=None))
433
+ found: set[str] = set()
434
+ for row in rows:
435
+ found |= _ct_names_under(row.get("name_value", ""), apex)
436
+ found |= _ct_names_under(row.get("common_name", ""), apex)
437
+ return found
438
+
439
+
440
+ async def _certspotter(apex: str) -> set[str]:
441
+ import aiohttp
442
+
443
+ url = (
444
+ "https://api.certspotter.com/v1/issuances"
445
+ f"?domain={apex}&include_subdomains=true&expand=dns_names"
446
+ )
447
+ timeout = aiohttp.ClientTimeout(total=_CT_TIMEOUT)
448
+ async with aiohttp.ClientSession(timeout=timeout) as session:
449
+ async with session.get(url, headers={"User-Agent": "stackscan"}) as resp:
450
+ if resp.status != 200:
451
+ return set()
452
+ rows = cast("list[dict[str, Any]]", await resp.json())
453
+ found: set[str] = set()
454
+ for row in rows:
455
+ for name in cast("list[str]", row.get("dns_names") or []):
456
+ found |= _ct_names_under(name, apex)
457
+ return found
stackscan/net/tld.py ADDED
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import urllib.request
5
+ from functools import lru_cache
6
+ from itertools import product
7
+
8
+ from stackscan.utils import db_dir
9
+
10
+ _TLD_URL = "https://data.iana.org/TLD/tlds-alpha-by-domain.txt"
11
+ _TLD_TIMEOUT = 20
12
+ _TLD_MAX_BYTES = 2 * 1024 * 1024
13
+ _MAX_CANDIDATES = 50000
14
+ _FALLBACK_TLDS: tuple[str, ...] = (
15
+ "com",
16
+ "net",
17
+ "org",
18
+ "info",
19
+ "biz",
20
+ "io",
21
+ "co",
22
+ "ru",
23
+ "az",
24
+ "de",
25
+ "fr",
26
+ "uk",
27
+ "us",
28
+ "cn",
29
+ "jp",
30
+ "in",
31
+ "br",
32
+ "it",
33
+ "es",
34
+ "nl",
35
+ "pl",
36
+ "ua",
37
+ "kz",
38
+ "gov",
39
+ "edu",
40
+ "xyz",
41
+ "app",
42
+ "dev",
43
+ "online",
44
+ "site",
45
+ "tech",
46
+ "lol",
47
+ "me",
48
+ "tv",
49
+ "cc",
50
+ )
51
+ _SLOT_RE = re.compile("^\\*+(\\d*)$")
52
+
53
+
54
+ def _parse_tlds(text: str) -> tuple[str, ...]:
55
+ out: list[str] = []
56
+ for line in text.splitlines():
57
+ line = line.strip().lower()
58
+ if not line or line.startswith("#"):
59
+ continue
60
+ out.append(line)
61
+ return tuple(out)
62
+
63
+
64
+ @lru_cache(maxsize=1)
65
+ def load_tlds() -> tuple[str, ...]:
66
+ cache = db_dir() / "iana-tlds.txt"
67
+ if cache.is_file():
68
+ try:
69
+ parsed = _parse_tlds(cache.read_text("utf-8"))
70
+ if parsed:
71
+ return parsed
72
+ except OSError:
73
+ pass
74
+ try:
75
+ request = urllib.request.Request(_TLD_URL, headers={"User-Agent": "stackscan"})
76
+ with urllib.request.urlopen(request, timeout=_TLD_TIMEOUT) as response:
77
+ text = response.read(_TLD_MAX_BYTES).decode("utf-8", "replace")
78
+ except (OSError, ValueError):
79
+ return _FALLBACK_TLDS
80
+ try:
81
+ cache.parent.mkdir(parents=True, exist_ok=True)
82
+ cache.write_text(text, encoding="utf-8")
83
+ except OSError:
84
+ pass
85
+ return _parse_tlds(text) or _FALLBACK_TLDS
86
+
87
+
88
+ def has_wildcard(target: str) -> bool:
89
+ return "*" in target
90
+
91
+
92
+ def _slot_values(spec: str, tlds: tuple[str, ...]) -> list[str]:
93
+ match = _SLOT_RE.match(spec)
94
+ if match is None:
95
+ return [spec]
96
+ length = match.group(1)
97
+ if length:
98
+ n = int(length)
99
+ return [t for t in tlds if len(t) == n]
100
+ return list(tlds)
101
+
102
+
103
+ def expand_wildcard_target(target: str, tlds: tuple[str, ...] | None = None) -> list[str]:
104
+ tlds = tlds if tlds is not None else load_tlds()
105
+ host = target.strip()
106
+ for prefix in ("https://", "http://"):
107
+ if host.startswith(prefix):
108
+ host = host[len(prefix) :]
109
+ host = host.split("/", 1)[0].strip(".").lower()
110
+ if "*" not in host:
111
+ return [host]
112
+ labels = host.split(".")
113
+ per_label = [_slot_values(label, tlds) for label in labels]
114
+ total = 1
115
+ for values in per_label:
116
+ total *= max(len(values), 1)
117
+ if total > _MAX_CANDIDATES:
118
+ break
119
+ out: list[str] = []
120
+ for combo in product(*per_label):
121
+ candidate = ".".join(combo)
122
+ if candidate and "*" not in candidate:
123
+ out.append(candidate)
124
+ if len(out) >= _MAX_CANDIDATES:
125
+ break
126
+ return out
stackscan/net/tls.py ADDED
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ import socket
4
+ import ssl
5
+ from typing import Any, cast
6
+
7
+ from stackscan.types import TlsInfo
8
+
9
+
10
+ def _as_str(value: object) -> str | None:
11
+ return value if isinstance(value, str) else None
12
+
13
+
14
+ def _join_rdn(rdn: object) -> str:
15
+
16
+ if not isinstance(rdn, (tuple, list)):
17
+ return ""
18
+ parts: list[str] = []
19
+ for rdn_entry in cast("tuple[object, ...]", rdn):
20
+ if not isinstance(rdn_entry, (tuple, list)):
21
+ continue
22
+ for pair in cast("tuple[object, ...]", rdn_entry):
23
+ if isinstance(pair, (tuple, list)) and len(cast("tuple[object, ...]", pair)) == 2:
24
+ key, value = cast("tuple[object, object]", pair)
25
+ parts.append(f"{key}={value}")
26
+ return ", ".join(parts)
27
+
28
+
29
+ def _subject_alt_names(cert: dict[str, Any]) -> tuple[str, ...]:
30
+ san_raw = cert.get("subjectAltName")
31
+ if not isinstance(san_raw, (tuple, list)):
32
+ return ()
33
+ names: list[str] = []
34
+ for entry in cast("tuple[object, ...]", san_raw):
35
+ if not isinstance(entry, (tuple, list)):
36
+ continue
37
+ pair = cast("tuple[object, ...]", entry)
38
+ if len(pair) == 2 and pair[0] == "DNS" and isinstance(pair[1], str):
39
+ names.append(pair[1])
40
+ return tuple(names)
41
+
42
+
43
+ def fetch_tls_info(
44
+ host: str, port: int = 443, *, timeout: float = 8.0, insecure: bool = False
45
+ ) -> TlsInfo | None:
46
+ if insecure:
47
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
48
+ context.check_hostname = False
49
+ context.verify_mode = ssl.CERT_NONE
50
+ else:
51
+ context = ssl.create_default_context()
52
+ try:
53
+ context.set_alpn_protocols(["h2", "http/1.1"])
54
+ except NotImplementedError:
55
+ pass
56
+ try:
57
+ with socket.create_connection((host, port), timeout=timeout) as sock:
58
+ with context.wrap_socket(sock, server_hostname=host) as tls:
59
+ raw_cert = tls.getpeercert()
60
+ protocol = tls.version()
61
+ cipher_tuple = tls.cipher()
62
+ alpn = tls.selected_alpn_protocol()
63
+ except (OSError, ssl.SSLError, ValueError):
64
+ return None
65
+ cipher = cipher_tuple[0] if cipher_tuple else None
66
+ if not raw_cert:
67
+ return TlsInfo(protocol=protocol, cipher=cipher, alpn=alpn)
68
+ cert = cast("dict[str, Any]", raw_cert)
69
+ return TlsInfo(
70
+ subject=_join_rdn(cert.get("subject")) or None,
71
+ issuer=_join_rdn(cert.get("issuer")) or None,
72
+ subject_alt_names=_subject_alt_names(cert),
73
+ not_before=_as_str(cert.get("notBefore")),
74
+ not_after=_as_str(cert.get("notAfter")),
75
+ protocol=protocol,
76
+ cipher=cipher,
77
+ alpn=alpn,
78
+ )