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
|
@@ -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
|
+
)
|