stackscan 2.3.0__tar.gz → 2.5.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.
Files changed (80) hide show
  1. {stackscan-2.3.0 → stackscan-2.5.0}/PKG-INFO +1 -1
  2. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/__init__.py +1 -1
  3. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/analyzers/__init__.py +2 -1
  4. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/analyzers/cve.py +2 -5
  5. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/analyzers/generic.py +121 -73
  6. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/analyzers/infra.py +57 -0
  7. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/analyzers/osdetect.py +3 -1
  8. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/analyzers/tech.py +55 -16
  9. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/cli.py +100 -29
  10. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/export.py +42 -9
  11. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/net/__init__.py +4 -1
  12. stackscan-2.5.0/src/stackscan/net/dns.py +247 -0
  13. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/net/fingerprint.py +7 -1
  14. stackscan-2.5.0/src/stackscan/net/resolver.py +58 -0
  15. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/net/subdomains.py +101 -12
  16. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/net/tld.py +56 -0
  17. stackscan-2.5.0/src/stackscan/net/whois.py +139 -0
  18. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/render.py +71 -19
  19. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/scan.py +81 -28
  20. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/scanners/secrets.py +14 -7
  21. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/scanners/takeover.py +4 -3
  22. stackscan-2.5.0/src/stackscan/theme.py +63 -0
  23. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/types/__init__.py +2 -0
  24. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/types/output.py +30 -0
  25. {stackscan-2.3.0 → stackscan-2.5.0}/tests/test_brute.py +3 -1
  26. {stackscan-2.3.0 → stackscan-2.5.0}/tests/test_cli_helpers.py +0 -3
  27. {stackscan-2.3.0 → stackscan-2.5.0}/tests/test_cve.py +26 -1
  28. {stackscan-2.3.0 → stackscan-2.5.0}/tests/test_infra.py +24 -1
  29. {stackscan-2.3.0 → stackscan-2.5.0}/tests/test_ports.py +3 -1
  30. {stackscan-2.3.0 → stackscan-2.5.0}/tests/test_scan.py +29 -1
  31. {stackscan-2.3.0 → stackscan-2.5.0}/tests/test_services.py +6 -2
  32. {stackscan-2.3.0 → stackscan-2.5.0}/tests/test_subdomains.py +23 -0
  33. {stackscan-2.3.0 → stackscan-2.5.0}/tests/test_tech.py +22 -0
  34. stackscan-2.5.0/tests/test_theme.py +26 -0
  35. stackscan-2.5.0/tests/test_whois.py +89 -0
  36. stackscan-2.3.0/src/stackscan/net/dns.py +0 -175
  37. stackscan-2.3.0/src/stackscan/net/resolver.py +0 -54
  38. stackscan-2.3.0/src/stackscan/theme.py +0 -23
  39. {stackscan-2.3.0 → stackscan-2.5.0}/.gitignore +0 -0
  40. {stackscan-2.3.0 → stackscan-2.5.0}/LICENSE +0 -0
  41. {stackscan-2.3.0 → stackscan-2.5.0}/README.md +0 -0
  42. {stackscan-2.3.0 → stackscan-2.5.0}/pyproject.toml +0 -0
  43. {stackscan-2.3.0 → stackscan-2.5.0}/scripts/build_cve_db.py +0 -0
  44. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/__main__.py +0 -0
  45. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/analyzers/creds.py +0 -0
  46. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/analyzers/exposure.py +0 -0
  47. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/analyzers/security.py +0 -0
  48. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/analyzers/services.py +0 -0
  49. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/analyzers/social.py +0 -0
  50. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/analyzers/vibe.py +0 -0
  51. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/config/__init__.py +0 -0
  52. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/config/sigdb_loader.py +0 -0
  53. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/config/sources.py +0 -0
  54. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/core/__init__.py +0 -0
  55. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/core/core.py +0 -0
  56. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/data/builtin.sigdb +0 -0
  57. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/data/cve.json.gz +0 -0
  58. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/data/reekeer-logo.png +0 -0
  59. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/data/subdomains.txt +0 -0
  60. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/net/geo.py +0 -0
  61. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/net/ipinfo.py +0 -0
  62. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/net/ports.py +0 -0
  63. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/net/tls.py +0 -0
  64. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/scanners/__init__.py +0 -0
  65. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/scanners/isp_blocked.py +0 -0
  66. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/types/models.py +0 -0
  67. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/utils/__init__.py +0 -0
  68. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/utils/paths.py +0 -0
  69. {stackscan-2.3.0 → stackscan-2.5.0}/src/stackscan/utils/urls.py +0 -0
  70. {stackscan-2.3.0 → stackscan-2.5.0}/tests/test_builtin_sigdb.py +0 -0
  71. {stackscan-2.3.0 → stackscan-2.5.0}/tests/test_creds.py +0 -0
  72. {stackscan-2.3.0 → stackscan-2.5.0}/tests/test_dns_records.py +0 -0
  73. {stackscan-2.3.0 → stackscan-2.5.0}/tests/test_export.py +0 -0
  74. {stackscan-2.3.0 → stackscan-2.5.0}/tests/test_extra.py +0 -0
  75. {stackscan-2.3.0 → stackscan-2.5.0}/tests/test_osdetect.py +0 -0
  76. {stackscan-2.3.0 → stackscan-2.5.0}/tests/test_scanners.py +0 -0
  77. {stackscan-2.3.0 → stackscan-2.5.0}/tests/test_social.py +0 -0
  78. {stackscan-2.3.0 → stackscan-2.5.0}/tests/test_sources.py +0 -0
  79. {stackscan-2.3.0 → stackscan-2.5.0}/tests/test_vhost.py +0 -0
  80. {stackscan-2.3.0 → stackscan-2.5.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.0
3
+ Version: 2.5.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
@@ -2,4 +2,4 @@ from .core import StackscanSession
2
2
  from .utils import normalize_url
3
3
 
4
4
  __all__ = ["StackscanSession", "__version__", "normalize_url"]
5
- __version__ = "2.3.0"
5
+ __version__ = "2.5.0"
@@ -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 45
289
- return 25
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
- "nginx",
9
- "apache",
10
- "httpd",
11
- "lighttpd",
12
- "litespeed",
13
- "caddy",
14
- "openresty",
15
- "iis",
16
- "microsoft-iis",
17
- "cherokee",
18
- "h2o",
19
- "boa",
20
- "thttpd",
21
- "mini_httpd",
22
- "rejetto",
23
- "cowboy",
24
- "tornado",
25
- "gunicorn",
26
- "uwsgi",
27
- "jetty",
28
- "tomcat",
29
- "apache tomcat",
30
- "websphere",
31
- "glassfish",
32
- "play",
33
- "spray",
34
- "kestrel",
35
- "cassini",
36
- "kangle",
37
- "resin",
38
- "weblogic",
39
- "zope",
40
- "aolserver",
41
- "yaws",
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
- "http",
47
- "https",
48
- "www",
49
- "html",
50
- "css",
51
- "json",
52
- "xml",
53
- "js",
54
- "png",
55
- "jpg",
56
- "jpeg",
57
- "gif",
58
- "svg",
59
- "ico",
60
- "woff",
61
- "woff2",
62
- "ttf",
63
- "eot",
64
- "php",
65
- "asp",
66
- "aspx",
67
- "jsp",
68
- "cgi",
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\s_-]{1,40})\s+\(?([a-f0-9]{7,40})\b\)?", re.IGNORECASE
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 = _normalize_name(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 = _normalize_name(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)
@@ -106,7 +106,9 @@ def detect_os(report: ScanReport) -> list[OsFinding]:
106
106
  for os, src in pairs:
107
107
  by_source.setdefault(src, []).append(os)
108
108
  dominant_source = max(by_source, key=lambda s: len(by_source[s]))
109
- if dominant_source == "port-banner" or dominant_source.startswith(("header:", "meta:", "script")):
109
+ if dominant_source == "port-banner" or dominant_source.startswith(
110
+ ("header:", "meta:", "script")
111
+ ):
110
112
  category = "banner"
111
113
  elif dominant_source.startswith("port "):
112
114
  category = "network"
@@ -40,17 +40,51 @@ def _script_srcs(html: str) -> list[str]:
40
40
  _CLASS_ATTR_RE = re.compile(r'\bclass\s*=\s*"([^"]*)"|\bclass\s*=\s*\'([^\']*)\'', re.I)
41
41
 
42
42
 
43
- _CSS_UTILITY_EXACT: frozenset[str] = frozenset({
44
- "container", "flex", "grid", "block", "inline", "inline-block", "hidden",
45
- "table", "table-cell", "table-row", "flow-root", "contents",
46
- "float-left", "float-right", "float-none", "clear-left", "clear-right",
47
- "clear-both", "clear-none", "isolate", "isolation-auto",
48
- "object-contain", "object-cover", "object-fill", "object-none", "object-scale-down",
49
- "overflow-auto", "overflow-hidden", "overflow-visible", "overflow-scroll",
50
- "overscroll-auto", "overscroll-contain", "overscroll-none",
51
- "visible", "invisible", "collapse",
52
- "static", "fixed", "absolute", "relative", "sticky",
53
- })
43
+ _CSS_UTILITY_EXACT: frozenset[str] = frozenset(
44
+ {
45
+ "container",
46
+ "flex",
47
+ "grid",
48
+ "block",
49
+ "inline",
50
+ "inline-block",
51
+ "hidden",
52
+ "table",
53
+ "table-cell",
54
+ "table-row",
55
+ "flow-root",
56
+ "contents",
57
+ "float-left",
58
+ "float-right",
59
+ "float-none",
60
+ "clear-left",
61
+ "clear-right",
62
+ "clear-both",
63
+ "clear-none",
64
+ "isolate",
65
+ "isolation-auto",
66
+ "object-contain",
67
+ "object-cover",
68
+ "object-fill",
69
+ "object-none",
70
+ "object-scale-down",
71
+ "overflow-auto",
72
+ "overflow-hidden",
73
+ "overflow-visible",
74
+ "overflow-scroll",
75
+ "overscroll-auto",
76
+ "overscroll-contain",
77
+ "overscroll-none",
78
+ "visible",
79
+ "invisible",
80
+ "collapse",
81
+ "static",
82
+ "fixed",
83
+ "absolute",
84
+ "relative",
85
+ "sticky",
86
+ }
87
+ )
54
88
  # Tailwind-style utilities: px-4, w-full, bg-red-500, my-auto, backdrop-blur, etc.
55
89
  _CSS_UTILITY_RE = re.compile(
56
90
  r"^[a-z]+(-[a-z]+)?-(\d+|auto|full|screen|px|sm|md|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl|none|hidden|visible|inherit|current|transparent|black|white|blur|opacity|saturate|sepia|grayscale|contrast|brightness|invert|drop-shadow|hue-rotate|shadow|sm|md|lg|xl)$",
@@ -118,7 +152,9 @@ def _confidence(evidence: list[str]) -> int:
118
152
 
119
153
 
120
154
  _VERSION_RE = re.compile(r"(\d+\.\d+(?:\.\d+)?(?:[-+.]?[a-zA-Z0-9]+)?)")
121
- _CORE_COMMIT_RE = re.compile(r"([A-Za-z][A-Za-z0-9\s-]*?)\s+Core\s+\(([a-f0-9]{4,})\)", re.IGNORECASE)
155
+ _CORE_COMMIT_RE = re.compile(
156
+ r"([A-Za-z][A-Za-z0-9\s-]*?)\s+Core\s+\(([a-f0-9]{4,})\)", re.IGNORECASE
157
+ )
122
158
 
123
159
 
124
160
  def _version_key(version: str) -> tuple[int, int, int, int]:
@@ -274,9 +310,7 @@ class TechAnalyzer:
274
310
  self._add(acc, matcher.match(result.body), "body")
275
311
  self._add(acc, matcher.match(result.url), "url")
276
312
  for token in _framework_tokens(result.body):
277
- self._add(
278
- acc, matcher.match_search({"framework": token}), f"framework:{token}"
279
- )
313
+ self._add(acc, matcher.match_search({"framework": token}), f"framework:{token}")
280
314
  self._curated(acc, result)
281
315
  by_name: dict[str, _Hit] = {}
282
316
  for hit in acc.values():
@@ -336,4 +370,9 @@ class TechAnalyzer:
336
370
  add(name, "service", f"body:{name} Core ({commit})", version=commit)
337
371
 
338
372
  for tech in extract_generic_tech(result.body):
339
- add(tech.name, tech.categories[0] if tech.categories else "service", tech.evidence[0], tech.version)
373
+ add(
374
+ tech.name,
375
+ tech.categories[0] if tech.categories else "service",
376
+ tech.evidence[0],
377
+ tech.version,
378
+ )