stackscan 2.3.0__tar.gz → 2.6.0__tar.gz
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-2.3.0 → stackscan-2.6.0}/PKG-INFO +1 -1
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/__init__.py +1 -1
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/analyzers/__init__.py +2 -1
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/analyzers/cve.py +2 -5
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/analyzers/generic.py +121 -73
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/analyzers/infra.py +57 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/cli.py +105 -34
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/export.py +31 -29
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/net/__init__.py +4 -1
- stackscan-2.6.0/src/stackscan/net/dns.py +251 -0
- stackscan-2.6.0/src/stackscan/net/resolver.py +58 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/net/subdomains.py +101 -12
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/net/tld.py +56 -0
- stackscan-2.6.0/src/stackscan/net/whois.py +177 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/render.py +62 -61
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/scan.py +99 -37
- stackscan-2.6.0/src/stackscan/theme.py +64 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/types/__init__.py +2 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/types/output.py +38 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/tests/test_cve.py +26 -1
- {stackscan-2.3.0 → stackscan-2.6.0}/tests/test_dns_records.py +17 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/tests/test_export.py +49 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/tests/test_infra.py +24 -1
- {stackscan-2.3.0 → stackscan-2.6.0}/tests/test_scan.py +46 -1
- {stackscan-2.3.0 → stackscan-2.6.0}/tests/test_subdomains.py +23 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/tests/test_tech.py +22 -0
- stackscan-2.6.0/tests/test_theme.py +26 -0
- stackscan-2.6.0/tests/test_whois.py +105 -0
- stackscan-2.3.0/src/stackscan/net/dns.py +0 -175
- stackscan-2.3.0/src/stackscan/net/resolver.py +0 -54
- stackscan-2.3.0/src/stackscan/theme.py +0 -23
- {stackscan-2.3.0 → stackscan-2.6.0}/.gitignore +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/LICENSE +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/README.md +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/pyproject.toml +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/scripts/build_cve_db.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/__main__.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/analyzers/creds.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/analyzers/exposure.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/analyzers/osdetect.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/analyzers/security.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/analyzers/services.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/analyzers/social.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/analyzers/tech.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/analyzers/vibe.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/config/__init__.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/config/sigdb_loader.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/config/sources.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/core/__init__.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/core/core.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/data/builtin.sigdb +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/data/cve.json.gz +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/data/reekeer-logo.png +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/data/subdomains.txt +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/net/fingerprint.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/net/geo.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/net/ipinfo.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/net/ports.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/net/tls.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/scanners/__init__.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/scanners/isp_blocked.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/scanners/secrets.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/scanners/takeover.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/types/models.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/utils/__init__.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/utils/paths.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/src/stackscan/utils/urls.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/tests/test_brute.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/tests/test_builtin_sigdb.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/tests/test_cli_helpers.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/tests/test_creds.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/tests/test_extra.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/tests/test_osdetect.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/tests/test_ports.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/tests/test_scanners.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/tests/test_services.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/tests/test_social.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/tests/test_sources.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/tests/test_vhost.py +0 -0
- {stackscan-2.3.0 → stackscan-2.6.0}/tests/test_vibe.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackscan
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.6.0
|
|
4
4
|
Summary: Full web stack, infrastructure, DNS, port, subdomain and CVE analyzer.
|
|
5
5
|
Project-URL: Source, https://github.com/reekeer/stackscan
|
|
6
6
|
Project-URL: Issues, https://github.com/reekeer/stackscan/issues
|
|
@@ -7,7 +7,7 @@ from stackscan.analyzers.cve import (
|
|
|
7
7
|
software_from_ports,
|
|
8
8
|
)
|
|
9
9
|
from stackscan.analyzers.exposure import ExposureProbe, analyze_exposure
|
|
10
|
-
from stackscan.analyzers.infra import analyze_infra
|
|
10
|
+
from stackscan.analyzers.infra import analyze_infra, summarize_edge
|
|
11
11
|
from stackscan.analyzers.osdetect import detect_os
|
|
12
12
|
from stackscan.analyzers.security import analyze_security_headers
|
|
13
13
|
from stackscan.analyzers.services import classify_services, port_category
|
|
@@ -19,6 +19,7 @@ __all__ = [
|
|
|
19
19
|
"TechAnalyzer",
|
|
20
20
|
"analyze_exposure",
|
|
21
21
|
"analyze_infra",
|
|
22
|
+
"summarize_edge",
|
|
22
23
|
"analyze_security_headers",
|
|
23
24
|
"brute_devices",
|
|
24
25
|
"detect_devices",
|
|
@@ -281,12 +281,9 @@ _SEVERITY_RANK = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
|
|
|
281
281
|
|
|
282
282
|
def _confidence(version: str, rng: dict[str, str], source: str, backported: bool = False) -> int:
|
|
283
283
|
if backported:
|
|
284
|
-
# Distro backports are a common source of phantom CVEs because the
|
|
285
|
-
# banner version does not reveal the patchlevel. Keep them below the
|
|
286
|
-
# default confidence threshold so they only appear when explicitly asked.
|
|
287
284
|
if "start_incl" in rng or "start_excl" in rng:
|
|
288
|
-
return
|
|
289
|
-
return
|
|
285
|
+
return 35
|
|
286
|
+
return 20
|
|
290
287
|
comps = len([c for c in re.split("[.\\-_]", version) if c[:1].isdigit()])
|
|
291
288
|
bounded = ("start_incl" in rng or "start_excl" in rng) and (
|
|
292
289
|
"end_incl" in rng or "end_excl" in rng
|
|
@@ -4,69 +4,73 @@ import re
|
|
|
4
4
|
|
|
5
5
|
from stackscan.types import Software, Technology
|
|
6
6
|
|
|
7
|
-
SERVER_NAMES: frozenset[str] = frozenset(
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
7
|
+
SERVER_NAMES: frozenset[str] = frozenset(
|
|
8
|
+
{
|
|
9
|
+
"nginx",
|
|
10
|
+
"apache",
|
|
11
|
+
"httpd",
|
|
12
|
+
"lighttpd",
|
|
13
|
+
"litespeed",
|
|
14
|
+
"caddy",
|
|
15
|
+
"openresty",
|
|
16
|
+
"iis",
|
|
17
|
+
"microsoft-iis",
|
|
18
|
+
"cherokee",
|
|
19
|
+
"h2o",
|
|
20
|
+
"boa",
|
|
21
|
+
"thttpd",
|
|
22
|
+
"mini_httpd",
|
|
23
|
+
"rejetto",
|
|
24
|
+
"cowboy",
|
|
25
|
+
"tornado",
|
|
26
|
+
"gunicorn",
|
|
27
|
+
"uwsgi",
|
|
28
|
+
"jetty",
|
|
29
|
+
"tomcat",
|
|
30
|
+
"apache tomcat",
|
|
31
|
+
"websphere",
|
|
32
|
+
"glassfish",
|
|
33
|
+
"play",
|
|
34
|
+
"spray",
|
|
35
|
+
"kestrel",
|
|
36
|
+
"cassini",
|
|
37
|
+
"kangle",
|
|
38
|
+
"resin",
|
|
39
|
+
"weblogic",
|
|
40
|
+
"zope",
|
|
41
|
+
"aolserver",
|
|
42
|
+
"yaws",
|
|
43
|
+
}
|
|
44
|
+
)
|
|
43
45
|
|
|
44
46
|
# Product names we never want to emit as a generic technology/software hit.
|
|
45
|
-
_NOISE_NAMES: frozenset[str] = frozenset(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
47
|
+
_NOISE_NAMES: frozenset[str] = frozenset(
|
|
48
|
+
{
|
|
49
|
+
"http",
|
|
50
|
+
"https",
|
|
51
|
+
"www",
|
|
52
|
+
"html",
|
|
53
|
+
"css",
|
|
54
|
+
"json",
|
|
55
|
+
"xml",
|
|
56
|
+
"js",
|
|
57
|
+
"png",
|
|
58
|
+
"jpg",
|
|
59
|
+
"jpeg",
|
|
60
|
+
"gif",
|
|
61
|
+
"svg",
|
|
62
|
+
"ico",
|
|
63
|
+
"woff",
|
|
64
|
+
"woff2",
|
|
65
|
+
"ttf",
|
|
66
|
+
"eot",
|
|
67
|
+
"php",
|
|
68
|
+
"asp",
|
|
69
|
+
"aspx",
|
|
70
|
+
"jsp",
|
|
71
|
+
"cgi",
|
|
72
|
+
}
|
|
73
|
+
)
|
|
70
74
|
|
|
71
75
|
_CORE_COMMIT_RE = re.compile(
|
|
72
76
|
r"([A-Za-z][A-Za-z0-9\s_-]{1,40})\s+Core\s+\(([a-f0-9]{4,})\b\)", re.IGNORECASE
|
|
@@ -94,14 +98,59 @@ _SERVER_VERSION_RE = re.compile(
|
|
|
94
98
|
)
|
|
95
99
|
|
|
96
100
|
_COMMIT_AFTER_NAME_RE = re.compile(
|
|
97
|
-
r"\b([A-Za-z][A-Za-z0-9
|
|
101
|
+
r"\b([A-Za-z][A-Za-z0-9._-]{1,40})\s+\(([a-f0-9]{7,40})\)", re.IGNORECASE
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
_STOPWORDS: frozenset[str] = frozenset(
|
|
105
|
+
{
|
|
106
|
+
"and",
|
|
107
|
+
"or",
|
|
108
|
+
"the",
|
|
109
|
+
"a",
|
|
110
|
+
"an",
|
|
111
|
+
"with",
|
|
112
|
+
"for",
|
|
113
|
+
"to",
|
|
114
|
+
"of",
|
|
115
|
+
"in",
|
|
116
|
+
"on",
|
|
117
|
+
"our",
|
|
118
|
+
"your",
|
|
119
|
+
"my",
|
|
120
|
+
"this",
|
|
121
|
+
"that",
|
|
122
|
+
"is",
|
|
123
|
+
"are",
|
|
124
|
+
"was",
|
|
125
|
+
"were",
|
|
126
|
+
"by",
|
|
127
|
+
"from",
|
|
128
|
+
"using",
|
|
129
|
+
"use",
|
|
130
|
+
"plus",
|
|
131
|
+
"via",
|
|
132
|
+
}
|
|
98
133
|
)
|
|
99
134
|
|
|
100
135
|
|
|
136
|
+
_VERSION_TOKEN_RE = re.compile(r"^v?\d[\w.]*$", re.IGNORECASE)
|
|
137
|
+
|
|
138
|
+
|
|
101
139
|
def _normalize_name(name: str) -> str:
|
|
102
140
|
return " ".join(name.split()).strip()
|
|
103
141
|
|
|
104
142
|
|
|
143
|
+
def _clean_product_name(name: str) -> str:
|
|
144
|
+
kept: list[str] = []
|
|
145
|
+
for word in _normalize_name(name).split():
|
|
146
|
+
if word.lower() in _STOPWORDS:
|
|
147
|
+
break
|
|
148
|
+
kept.append(word)
|
|
149
|
+
while len(kept) > 1 and _VERSION_TOKEN_RE.match(kept[-1]):
|
|
150
|
+
kept.pop()
|
|
151
|
+
return " ".join(kept)
|
|
152
|
+
|
|
153
|
+
|
|
105
154
|
def _software_name(name: str) -> str:
|
|
106
155
|
return _normalize_name(name).lower().replace(" ", "")
|
|
107
156
|
|
|
@@ -131,17 +180,11 @@ def is_commit_hash(value: str) -> bool:
|
|
|
131
180
|
|
|
132
181
|
|
|
133
182
|
def extract_generic_tech(body: str) -> list[Technology]:
|
|
134
|
-
"""Return generic service/infrastructure technologies found in the body.
|
|
135
|
-
|
|
136
|
-
This is meant to catch products and commit hashes that do not have a
|
|
137
|
-
dedicated signature in the bundled database, e.g. a 404 page footer that
|
|
138
|
-
says ``nginx/1.24.0`` or ``CurseForge Core (a26fded)``.
|
|
139
|
-
"""
|
|
140
183
|
hits: list[tuple[str, str, str, str | None]] = []
|
|
141
184
|
seen: set[tuple[str, str | None]] = set()
|
|
142
185
|
|
|
143
186
|
def remember(name: str, evidence: str, version: str | None) -> None:
|
|
144
|
-
name =
|
|
187
|
+
name = _clean_product_name(name)
|
|
145
188
|
if len(name) < 2 or _is_noise(name) or not _is_plausible_name(name):
|
|
146
189
|
return
|
|
147
190
|
key = (name.lower(), version)
|
|
@@ -155,13 +198,17 @@ def extract_generic_tech(body: str) -> list[Technology]:
|
|
|
155
198
|
version = match.group(2)
|
|
156
199
|
remember(name, f"body:{name}/{version}", version)
|
|
157
200
|
|
|
201
|
+
powered_starts: set[int] = set()
|
|
158
202
|
for match in _POWERED_BY_VERSION_RE.finditer(body):
|
|
159
203
|
name = _normalize_name(match.group(1))
|
|
160
204
|
version = match.group(2)
|
|
205
|
+
powered_starts.add(match.start())
|
|
161
206
|
if name and not _is_noise(name):
|
|
162
207
|
remember(name, f"body:powered-by {name} {version}", version)
|
|
163
208
|
|
|
164
209
|
for match in _POWERED_BY_PLAIN_RE.finditer(body):
|
|
210
|
+
if match.start() in powered_starts:
|
|
211
|
+
continue
|
|
165
212
|
name = _normalize_name(match.group(1))
|
|
166
213
|
if name and not _is_noise(name):
|
|
167
214
|
remember(name, f"body:powered-by {name}", None)
|
|
@@ -181,14 +228,12 @@ def extract_generic_tech(body: str) -> list[Technology]:
|
|
|
181
228
|
return True
|
|
182
229
|
return False
|
|
183
230
|
|
|
184
|
-
# Product name directly followed by a short hex commit (no "Core" keyword).
|
|
185
231
|
for match in _COMMIT_AFTER_NAME_RE.finditer(body):
|
|
186
232
|
if _overlaps_core(match.span()):
|
|
187
233
|
continue
|
|
188
234
|
name = _normalize_name(match.group(1))
|
|
189
235
|
commit = match.group(2).lower()
|
|
190
236
|
if name and not _is_noise(name) and is_commit_hash(commit):
|
|
191
|
-
# Prefer the Core match if we already have one for the same product.
|
|
192
237
|
if (name.lower(), commit) not in seen:
|
|
193
238
|
remember(name, f"body:{name} ({commit})", commit)
|
|
194
239
|
|
|
@@ -205,12 +250,11 @@ def extract_generic_tech(body: str) -> list[Technology]:
|
|
|
205
250
|
|
|
206
251
|
|
|
207
252
|
def extract_generic_software(body: str, location: str = "") -> list[Software]:
|
|
208
|
-
"""Return Software records for generic product/version/commit patterns."""
|
|
209
253
|
out: list[Software] = []
|
|
210
254
|
seen: set[tuple[str, str | None]] = set()
|
|
211
255
|
|
|
212
256
|
def add(name: str, version: str | None, evidence: str) -> None:
|
|
213
|
-
name =
|
|
257
|
+
name = _clean_product_name(name)
|
|
214
258
|
if len(name) < 2 or not _is_plausible_name(name):
|
|
215
259
|
return
|
|
216
260
|
sname = _software_name(name)
|
|
@@ -234,13 +278,17 @@ def extract_generic_software(body: str, location: str = "") -> list[Software]:
|
|
|
234
278
|
version = match.group(2)
|
|
235
279
|
add(name, version, f"body:{name}/{version}")
|
|
236
280
|
|
|
281
|
+
powered_starts: set[int] = set()
|
|
237
282
|
for match in _POWERED_BY_VERSION_RE.finditer(body):
|
|
238
283
|
name = _normalize_name(match.group(1))
|
|
239
284
|
version = match.group(2)
|
|
285
|
+
powered_starts.add(match.start())
|
|
240
286
|
if name:
|
|
241
287
|
add(name, version, f"body:powered-by {name} {version}")
|
|
242
288
|
|
|
243
289
|
for match in _POWERED_BY_PLAIN_RE.finditer(body):
|
|
290
|
+
if match.start() in powered_starts:
|
|
291
|
+
continue
|
|
244
292
|
name = _normalize_name(match.group(1))
|
|
245
293
|
if name:
|
|
246
294
|
add(name, None, f"body:powered-by {name}")
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
|
|
3
5
|
from stackscan.types import Headers, InfraInfo
|
|
4
6
|
|
|
5
7
|
_Signature = tuple[str, str, str | None]
|
|
@@ -92,6 +94,61 @@ def _proxy_notes(headers: Headers, host: str) -> list[str]:
|
|
|
92
94
|
return notes
|
|
93
95
|
|
|
94
96
|
|
|
97
|
+
_ROLE_LABEL: dict[str, str] = {"cdn": "CDN", "waf": "WAF", "proxy": "reverse proxy"}
|
|
98
|
+
_ROLE_ORDER: tuple[str, ...] = ("cdn", "waf", "proxy")
|
|
99
|
+
_ORG_SUFFIXES: tuple[str, ...] = (
|
|
100
|
+
", inc.",
|
|
101
|
+
", inc",
|
|
102
|
+
" inc.",
|
|
103
|
+
" inc",
|
|
104
|
+
" llc",
|
|
105
|
+
" ltd",
|
|
106
|
+
" ltd.",
|
|
107
|
+
" gmbh",
|
|
108
|
+
" corporation",
|
|
109
|
+
" technologies",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _canonical_provider(org: str) -> str:
|
|
114
|
+
name = org.strip()
|
|
115
|
+
low = name.lower()
|
|
116
|
+
for suffix in _ORG_SUFFIXES:
|
|
117
|
+
if low.endswith(suffix):
|
|
118
|
+
name = name[: -len(suffix)].strip()
|
|
119
|
+
low = name.lower()
|
|
120
|
+
return name
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def summarize_edge(infra: InfraInfo, cdn_orgs: Iterable[str] = (), *, sep: str = " → ") -> str:
|
|
124
|
+
roles: dict[str, list[str]] = {}
|
|
125
|
+
order: list[str] = []
|
|
126
|
+
role_names = {"cdn": infra.cdn, "waf": infra.waf, "proxy": infra.proxy}
|
|
127
|
+
for role in _ROLE_ORDER:
|
|
128
|
+
for name in role_names[role]:
|
|
129
|
+
if name not in roles:
|
|
130
|
+
roles[name] = []
|
|
131
|
+
order.append(name)
|
|
132
|
+
if role not in roles[name]:
|
|
133
|
+
roles[name].append(role)
|
|
134
|
+
for org in cdn_orgs:
|
|
135
|
+
name = _canonical_provider(org)
|
|
136
|
+
if not name:
|
|
137
|
+
continue
|
|
138
|
+
if any(name.lower() in known.lower() or known.lower() in name.lower() for known in roles):
|
|
139
|
+
continue
|
|
140
|
+
roles[name] = ["cdn"]
|
|
141
|
+
order.append(name)
|
|
142
|
+
if not order:
|
|
143
|
+
return ""
|
|
144
|
+
order.sort(key=lambda n: 0 if ({"waf", "proxy"} & set(roles[n])) else 1)
|
|
145
|
+
parts: list[str] = []
|
|
146
|
+
for name in order:
|
|
147
|
+
labels = ", ".join(_ROLE_LABEL[r] for r in _ROLE_ORDER if r in roles[name])
|
|
148
|
+
parts.append(f"{name} ({labels})" if labels else name)
|
|
149
|
+
return sep.join(parts)
|
|
150
|
+
|
|
151
|
+
|
|
95
152
|
def analyze_infra(headers: Headers, cookies: tuple[str, ...], host: str) -> InfraInfo:
|
|
96
153
|
cdn = _collect(headers, CDN_SIGNATURES)
|
|
97
154
|
waf = _collect(headers, WAF_SIGNATURES)
|