stackscan 2.2.2__tar.gz → 2.3.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 (77) hide show
  1. {stackscan-2.2.2 → stackscan-2.3.0}/PKG-INFO +4 -3
  2. {stackscan-2.2.2 → stackscan-2.3.0}/README.md +3 -2
  3. {stackscan-2.2.2 → stackscan-2.3.0}/scripts/build_cve_db.py +12 -0
  4. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/__init__.py +1 -1
  5. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/analyzers/cve.py +67 -12
  6. stackscan-2.3.0/src/stackscan/analyzers/generic.py +271 -0
  7. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/analyzers/services.py +5 -15
  8. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/analyzers/tech.py +47 -3
  9. stackscan-2.3.0/src/stackscan/analyzers/vibe.py +248 -0
  10. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/cli.py +115 -74
  11. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/core/core.py +14 -2
  12. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/data/subdomains.txt +32 -0
  13. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/net/dns.py +16 -9
  14. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/net/ipinfo.py +13 -6
  15. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/net/subdomains.py +71 -4
  16. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/net/tls.py +31 -19
  17. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/render.py +170 -84
  18. stackscan-2.3.0/src/stackscan/scan.py +1039 -0
  19. stackscan-2.3.0/src/stackscan/scanners/__init__.py +0 -0
  20. stackscan-2.3.0/src/stackscan/scanners/isp_blocked.py +133 -0
  21. stackscan-2.3.0/src/stackscan/scanners/secrets.py +100 -0
  22. stackscan-2.3.0/src/stackscan/scanners/takeover.py +214 -0
  23. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/types/__init__.py +4 -0
  24. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/types/output.py +48 -0
  25. {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_brute.py +4 -4
  26. {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_cli_helpers.py +3 -0
  27. stackscan-2.3.0/tests/test_cve.py +229 -0
  28. {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_extra.py +5 -5
  29. stackscan-2.3.0/tests/test_scanners.py +97 -0
  30. {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_services.py +14 -0
  31. stackscan-2.3.0/tests/test_tech.py +224 -0
  32. stackscan-2.3.0/tests/test_vhost.py +133 -0
  33. stackscan-2.3.0/tests/test_vibe.py +38 -0
  34. stackscan-2.2.2/src/stackscan/scan.py +0 -560
  35. stackscan-2.2.2/tests/test_cve.py +0 -120
  36. stackscan-2.2.2/tests/test_tech.py +0 -71
  37. {stackscan-2.2.2 → stackscan-2.3.0}/.gitignore +0 -0
  38. {stackscan-2.2.2 → stackscan-2.3.0}/LICENSE +0 -0
  39. {stackscan-2.2.2 → stackscan-2.3.0}/pyproject.toml +0 -0
  40. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/__main__.py +0 -0
  41. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/analyzers/__init__.py +0 -0
  42. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/analyzers/creds.py +0 -0
  43. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/analyzers/exposure.py +0 -0
  44. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/analyzers/infra.py +0 -0
  45. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/analyzers/osdetect.py +0 -0
  46. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/analyzers/security.py +0 -0
  47. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/analyzers/social.py +0 -0
  48. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/config/__init__.py +0 -0
  49. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/config/sigdb_loader.py +0 -0
  50. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/config/sources.py +0 -0
  51. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/core/__init__.py +0 -0
  52. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/data/builtin.sigdb +0 -0
  53. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/data/cve.json.gz +0 -0
  54. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/data/reekeer-logo.png +0 -0
  55. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/export.py +0 -0
  56. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/net/__init__.py +0 -0
  57. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/net/fingerprint.py +0 -0
  58. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/net/geo.py +0 -0
  59. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/net/ports.py +0 -0
  60. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/net/resolver.py +0 -0
  61. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/net/tld.py +0 -0
  62. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/theme.py +0 -0
  63. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/types/models.py +0 -0
  64. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/utils/__init__.py +0 -0
  65. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/utils/paths.py +0 -0
  66. {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/utils/urls.py +0 -0
  67. {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_builtin_sigdb.py +0 -0
  68. {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_creds.py +0 -0
  69. {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_dns_records.py +0 -0
  70. {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_export.py +0 -0
  71. {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_infra.py +0 -0
  72. {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_osdetect.py +0 -0
  73. {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_ports.py +0 -0
  74. {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_scan.py +0 -0
  75. {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_social.py +0 -0
  76. {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_sources.py +0 -0
  77. {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_subdomains.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackscan
3
- Version: 2.2.2
3
+ Version: 2.3.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
@@ -117,7 +117,7 @@ stackscan --cve-online example.com
117
117
 
118
118
  # Speed / coverage knobs
119
119
  stackscan --full --workers 500 --subdomain-limit 10000 example.com
120
- stackscan --full --site-limit 10 --workers 200 example.com
120
+ stackscan --full --site-limit 20 --workers 200 example.com
121
121
 
122
122
  # JSON (full detail, includes timing) or a compact table
123
123
  stackscan --json example.com
@@ -169,7 +169,8 @@ Below are performance benchmarks conducted on two production targets using diffe
169
169
  | `--ports` / `--no-nmap` | off | Active port scan (nmap, else Python) / force the Python scanner. |
170
170
  | `--subdomains` | off | Enumerate subdomains (AXFR + wordlist + TLS SANs). |
171
171
  | `--subdomain-limit` | `5000` | Max ranked labels to resolve (`0` = full list). |
172
- | `--site-limit` | `20` | Max derived sites to analyze from discovered open ports (`0` = unlimited). |
172
+ | `--site-limit` | `50` | Max derived sites to analyze from discovered open ports (`0` = unlimited). |
173
+ | `--cve-min-confidence` | `50` | Hide CVE matches with confidence below N (`0` shows all). |
173
174
  | `--default-creds` | off | Bounded default-credential / open-device check (prompts before brute-forcing). |
174
175
  | `--full-auto` | off | Auto-accept every brute prompt on discovered devices (enables default-cred checks). |
175
176
  | `--cred-limit` | `50` | Max default-credential pairs per device (`0` = full SecLists list). |
@@ -79,7 +79,7 @@ stackscan --cve-online example.com
79
79
 
80
80
  # Speed / coverage knobs
81
81
  stackscan --full --workers 500 --subdomain-limit 10000 example.com
82
- stackscan --full --site-limit 10 --workers 200 example.com
82
+ stackscan --full --site-limit 20 --workers 200 example.com
83
83
 
84
84
  # JSON (full detail, includes timing) or a compact table
85
85
  stackscan --json example.com
@@ -131,7 +131,8 @@ Below are performance benchmarks conducted on two production targets using diffe
131
131
  | `--ports` / `--no-nmap` | off | Active port scan (nmap, else Python) / force the Python scanner. |
132
132
  | `--subdomains` | off | Enumerate subdomains (AXFR + wordlist + TLS SANs). |
133
133
  | `--subdomain-limit` | `5000` | Max ranked labels to resolve (`0` = full list). |
134
- | `--site-limit` | `20` | Max derived sites to analyze from discovered open ports (`0` = unlimited). |
134
+ | `--site-limit` | `50` | Max derived sites to analyze from discovered open ports (`0` = unlimited). |
135
+ | `--cve-min-confidence` | `50` | Hide CVE matches with confidence below N (`0` shows all). |
135
136
  | `--default-creds` | off | Bounded default-credential / open-device check (prompts before brute-forcing). |
136
137
  | `--full-auto` | off | Auto-accept every brute prompt on discovered devices (enables default-cred checks). |
137
138
  | `--cred-limit` | `50` | Max default-credential pairs per device (`0` = full SecLists list). |
@@ -138,6 +138,16 @@ def ranges_for(cve: dict[str, Any], needle: str) -> list[dict[str, str]]:
138
138
  return out
139
139
 
140
140
 
141
+ def _skip_entry(cve: dict[str, Any]) -> bool:
142
+ status = str(cve.get("vulnStatus", "")).upper()
143
+ if status in {"REJECTED", "REPLACED", "DEPRECATED"}:
144
+ return True
145
+ descriptions = cve.get("descriptions") or []
146
+ if not english_summary(descriptions):
147
+ return True
148
+ return False
149
+
150
+
141
151
  def collect(product: str, vendor_product: str, throttle: float) -> list[dict[str, Any]]:
142
152
  needle = ":" + vendor_product + ":"
143
153
  entries: list[dict[str, Any]] = []
@@ -149,6 +159,8 @@ def collect(product: str, vendor_product: str, throttle: float) -> list[dict[str
149
159
  vulns = payload.get("vulnerabilities") or []
150
160
  for wrapper in vulns:
151
161
  cve = wrapper["cve"]
162
+ if _skip_entry(cve):
163
+ continue
152
164
  ranges = ranges_for(cve, needle)
153
165
  if not ranges:
154
166
  continue
@@ -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.2.2"
5
+ __version__ = "2.3.0"
@@ -9,12 +9,13 @@ from functools import lru_cache
9
9
  from importlib import resources
10
10
  from typing import Any, cast
11
11
 
12
+ from stackscan.analyzers.generic import SERVER_NAMES, extract_generic_software, is_commit_hash
12
13
  from stackscan.types import CveMatch, Headers, PortScan, Software
13
14
 
14
15
  CveEntry = dict[str, Any]
15
16
  _BACKPORT_DISTRO_RE = re.compile(
16
17
  r"(0?ubuntu0?[._]\d[\d.]+)"
17
- r"|\b(ubuntu|debian|centos|rhel|red\s*hat|fedora|amzn|amazon|rocky|almalinux|alpine|raspbian|suse|opensuse)\b"
18
+ r"|\b(ubuntu|debian|centos|rhel|red\s*hat|fedora|amzn|amazon|rocky|almalinux|alpine|raspbian|suse|opensuse|oracle|sles)\b"
18
19
  r"|(\+deb\d+u?\d*)"
19
20
  r"|(-\d+ubuntu\d+(?![\d._]))"
20
21
  r"|(el\d+)"
@@ -99,7 +100,10 @@ _JQUERY_RE = re.compile("jquery[-/]?v?(\\d+\\.\\d+(?:\\.\\d+)?)", re.IGNORECASE)
99
100
  _GENERATOR_RE = re.compile(
100
101
  "<meta[^>]+name=[\\\"']generator[\\\"'][^>]+content=[\\\"']([^\\\"']+)[\\\"']", re.IGNORECASE
101
102
  )
102
- _SSH_RE = re.compile("openssh[_/-]?(\\d+\\.\\d+(?:p\\d+)?)", re.IGNORECASE)
103
+ _SSH_RE = re.compile(r"openssh[\s_/:-](\d+\.\d+(?:p\d+)?)", re.IGNORECASE)
104
+ _CORE_COMMIT_RE = re.compile(
105
+ r"([A-Za-z][A-Za-z0-9\s-]*?)\s+Core\s+\(([a-f0-9]{4,})\)", re.IGNORECASE
106
+ )
103
107
 
104
108
 
105
109
  @lru_cache(maxsize=1)
@@ -177,8 +181,26 @@ def extract_software(headers: Headers, body: str, location: str = "") -> list[So
177
181
  server = headers.get("server")
178
182
  if server:
179
183
  server_os = _distro_tag(server)
180
- for item in _tokens(server, "header:server", location, os=server_os):
184
+ server_items = _tokens(server, "header:server", location, os=server_os)
185
+ for item in server_items:
181
186
  add(item)
187
+ # If the Server banner names a known product without a version (e.g.
188
+ # "nginx" or "Apache"), still record it so it shows up in the software
189
+ # table even when no CVE can be matched yet.
190
+ if not server_items:
191
+ server_lower = server.lower()
192
+ for name in sorted(SERVER_NAMES, key=len, reverse=True):
193
+ if name in server_lower:
194
+ add(
195
+ Software(
196
+ name=name.lower().replace(" ", ""),
197
+ version=None,
198
+ source="header:server",
199
+ location=location,
200
+ os=server_os,
201
+ )
202
+ )
203
+ break
182
204
  powered = headers.get("x-powered-by")
183
205
  if powered:
184
206
  for item in _tokens(powered, "header:x-powered-by", location):
@@ -199,6 +221,22 @@ def extract_software(headers: Headers, body: str, location: str = "") -> list[So
199
221
  location=location,
200
222
  )
201
223
  )
224
+ core_match = _CORE_COMMIT_RE.search(body)
225
+ if core_match:
226
+ product = core_match.group(1).strip()
227
+ commit = core_match.group(2).lower()
228
+ if product:
229
+ add(
230
+ Software(
231
+ name=product.lower().replace(" ", ""),
232
+ version=commit,
233
+ source="body:core-commit",
234
+ location=location,
235
+ )
236
+ )
237
+
238
+ for item in extract_generic_software(body, location=location):
239
+ add(item)
202
240
  return software
203
241
 
204
242
 
@@ -211,6 +249,7 @@ def software_from_ports(scan: PortScan | None) -> list[Software]:
211
249
  if not blob:
212
250
  continue
213
251
  location = f"{port.host}:{port.port}" if port.host else f":{port.port}"
252
+ port_os = port.os or _distro_tag(blob)
214
253
  ssh = _SSH_RE.search(blob)
215
254
  if ssh:
216
255
  out.append(
@@ -219,7 +258,7 @@ def software_from_ports(scan: PortScan | None) -> list[Software]:
219
258
  version=ssh.group(1),
220
259
  source="port-banner",
221
260
  location=location,
222
- os=port.os,
261
+ os=port_os,
223
262
  )
224
263
  )
225
264
  continue
@@ -230,7 +269,7 @@ def software_from_ports(scan: PortScan | None) -> list[Software]:
230
269
  version=port.version,
231
270
  source="port-banner",
232
271
  location=location,
233
- os=port.os,
272
+ os=port_os,
234
273
  )
235
274
  )
236
275
  return out
@@ -240,7 +279,14 @@ _AUTHORITATIVE = {"header:server", "header:x-powered-by", "port-banner"}
240
279
  _SEVERITY_RANK = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
241
280
 
242
281
 
243
- def _confidence(version: str, rng: dict[str, str], source: str) -> int:
282
+ def _confidence(version: str, rng: dict[str, str], source: str, backported: bool = False) -> int:
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
+ if "start_incl" in rng or "start_excl" in rng:
288
+ return 45
289
+ return 25
244
290
  comps = len([c for c in re.split("[.\\-_]", version) if c[:1].isdigit()])
245
291
  bounded = ("start_incl" in rng or "start_excl" in rng) and (
246
292
  "end_incl" in rng or "end_excl" in rng
@@ -278,6 +324,8 @@ def _match_entries(
278
324
  version = item.version
279
325
  if not version:
280
326
  return
327
+ if is_commit_hash(version):
328
+ return
281
329
  backported = bool(item.os)
282
330
  caveat = "distro backport likely — patchlevel not in banner" if backported else ""
283
331
  for entry in entries:
@@ -286,7 +334,7 @@ def _match_entries(
286
334
  if hit_rng is None:
287
335
  continue
288
336
  cve_id = str(entry["id"])
289
- confidence = 40 if backported else _confidence(version, hit_rng, item.source)
337
+ confidence = _confidence(version, hit_rng, item.source, backported=backported)
290
338
  record = agg.get(cve_id)
291
339
  if record is None:
292
340
  record = _CveAgg(
@@ -309,7 +357,8 @@ def _match_entries(
309
357
  if backported:
310
358
  record.unconfirmed = True
311
359
  record.caveat = caveat
312
- record.confidence = min(record.confidence, 40)
360
+ if confidence < record.confidence:
361
+ record.confidence = confidence
313
362
  elif confidence > record.confidence:
314
363
  record.confidence = confidence
315
364
  record.version = version
@@ -363,7 +412,7 @@ def merge_cve_matches(offline: list[CveMatch], online: list[CveMatch]) -> list[C
363
412
  return _sort_matches(list(by_id.values()))
364
413
 
365
414
 
366
- def match_cves(software: list[Software]) -> list[CveMatch]:
415
+ def match_cves(software: list[Software], *, min_confidence: int = 0) -> list[CveMatch]:
367
416
  db = load_cve_db()
368
417
  agg: dict[str, _CveAgg] = {}
369
418
  for item in software:
@@ -373,7 +422,10 @@ def match_cves(software: list[Software]) -> list[CveMatch]:
373
422
  entries = db.get(product_key)
374
423
  if entries:
375
424
  _match_entries(item, product_key, entries, agg)
376
- return _agg_to_matches(agg)
425
+ matches = _agg_to_matches(agg)
426
+ if min_confidence > 0:
427
+ matches = [m for m in matches if m.confidence >= min_confidence]
428
+ return matches
377
429
 
378
430
 
379
431
  _NVD_URL = "https://services.nvd.nist.gov/rest/json/cves/2.0"
@@ -450,7 +502,7 @@ def _nvd_summary(descriptions: list[dict[str, str]]) -> str:
450
502
 
451
503
 
452
504
  async def match_cves_online(
453
- software: list[Software], *, timeout: float = 25.0, workers: int = 3
505
+ software: list[Software], *, timeout: float = 25.0, workers: int = 3, min_confidence: int = 0
454
506
  ) -> list[CveMatch]:
455
507
  import aiohttp
456
508
 
@@ -487,4 +539,7 @@ async def match_cves_online(
487
539
  for (product_key, item), entries in zip(products.items(), gathered, strict=True):
488
540
  if entries:
489
541
  _match_entries(item, product_key, entries, agg)
490
- return _agg_to_matches(agg)
542
+ matches = _agg_to_matches(agg)
543
+ if min_confidence > 0:
544
+ matches = [m for m in matches if m.confidence >= min_confidence]
545
+ return matches
@@ -0,0 +1,271 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from stackscan.types import Software, Technology
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
+ })
43
+
44
+ # 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
+ })
70
+
71
+ _CORE_COMMIT_RE = re.compile(
72
+ r"([A-Za-z][A-Za-z0-9\s_-]{1,40})\s+Core\s+\(([a-f0-9]{4,})\b\)", re.IGNORECASE
73
+ )
74
+
75
+ _POWERED_BY_VERSION_RE = re.compile(
76
+ r"(?:powered\s+by|running\s+on|built\s+with|made\s+with)\s+"
77
+ r"([A-Za-z][A-Za-z0-9_-]*(?:\s+[A-Za-z][A-Za-z0-9_-]*){0,4})"
78
+ r"\s+(?:v\.?|version\s*)?(\d+\.\d+(?:\.\d+)?)",
79
+ re.IGNORECASE,
80
+ )
81
+
82
+ _POWERED_BY_PLAIN_RE = re.compile(
83
+ r"(?:powered\s+by|running\s+on|built\s+with|made\s+with)\s+"
84
+ r"([A-Za-z][A-Za-z0-9_-]*(?:\s+[A-Za-z][A-Za-z0-9_-]*){0,4})"
85
+ r"(?!\s+(?:v\.?|version\s*)?\d)",
86
+ re.IGNORECASE,
87
+ )
88
+
89
+ _SERVER_VERSION_RE = re.compile(
90
+ r"\b("
91
+ + "|".join(re.escape(name) for name in sorted(SERVER_NAMES, key=len, reverse=True))
92
+ + r")[/ ]v?(\d+\.\d+(?:\.\d+){0,2})",
93
+ re.IGNORECASE,
94
+ )
95
+
96
+ _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
98
+ )
99
+
100
+
101
+ def _normalize_name(name: str) -> str:
102
+ return " ".join(name.split()).strip()
103
+
104
+
105
+ def _software_name(name: str) -> str:
106
+ return _normalize_name(name).lower().replace(" ", "")
107
+
108
+
109
+ def _category(name: str) -> str:
110
+ if _software_name(name) in {n.replace(" ", "") for n in SERVER_NAMES}:
111
+ return "infrastructure"
112
+ return "service"
113
+
114
+
115
+ def _is_noise(name: str) -> bool:
116
+ return _software_name(name) in _NOISE_NAMES
117
+
118
+
119
+ def _is_plausible_name(name: str) -> bool:
120
+ """Reject single-letter-plus-digit noise (e.g. SVG path commands like M368)."""
121
+ return sum(1 for ch in name if ch.isalpha()) >= 2
122
+
123
+
124
+ def is_commit_hash(value: str) -> bool:
125
+ """Return True when value looks like a Git commit hash rather than a version."""
126
+ if len(value) < 7:
127
+ return False
128
+ if value.startswith("v"):
129
+ return False
130
+ return bool(re.fullmatch(r"[a-f0-9]{7,40}", value))
131
+
132
+
133
+ 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
+ hits: list[tuple[str, str, str, str | None]] = []
141
+ seen: set[tuple[str, str | None]] = set()
142
+
143
+ def remember(name: str, evidence: str, version: str | None) -> None:
144
+ name = _normalize_name(name)
145
+ if len(name) < 2 or _is_noise(name) or not _is_plausible_name(name):
146
+ return
147
+ key = (name.lower(), version)
148
+ if key in seen:
149
+ return
150
+ seen.add(key)
151
+ hits.append((name, evidence, _category(name), version))
152
+
153
+ for match in _SERVER_VERSION_RE.finditer(body):
154
+ name = _normalize_name(match.group(1))
155
+ version = match.group(2)
156
+ remember(name, f"body:{name}/{version}", version)
157
+
158
+ for match in _POWERED_BY_VERSION_RE.finditer(body):
159
+ name = _normalize_name(match.group(1))
160
+ version = match.group(2)
161
+ if name and not _is_noise(name):
162
+ remember(name, f"body:powered-by {name} {version}", version)
163
+
164
+ for match in _POWERED_BY_PLAIN_RE.finditer(body):
165
+ name = _normalize_name(match.group(1))
166
+ if name and not _is_noise(name):
167
+ remember(name, f"body:powered-by {name}", None)
168
+
169
+ core_spans: set[tuple[int, int]] = set()
170
+ for match in _CORE_COMMIT_RE.finditer(body):
171
+ name = _normalize_name(match.group(1))
172
+ commit = match.group(2).lower()
173
+ if name and not _is_noise(name):
174
+ remember(name, f"body:{name} Core ({commit})", commit)
175
+ core_spans.add(match.span())
176
+
177
+ def _overlaps_core(span: tuple[int, int]) -> bool:
178
+ start, end = span
179
+ for c_start, c_end in core_spans:
180
+ if start < c_end and end > c_start:
181
+ return True
182
+ return False
183
+
184
+ # Product name directly followed by a short hex commit (no "Core" keyword).
185
+ for match in _COMMIT_AFTER_NAME_RE.finditer(body):
186
+ if _overlaps_core(match.span()):
187
+ continue
188
+ name = _normalize_name(match.group(1))
189
+ commit = match.group(2).lower()
190
+ 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
+ if (name.lower(), commit) not in seen:
193
+ remember(name, f"body:{name} ({commit})", commit)
194
+
195
+ return [
196
+ Technology(
197
+ name=name,
198
+ categories=(category,),
199
+ evidence=(evidence,),
200
+ confidence=70,
201
+ version=version,
202
+ )
203
+ for name, evidence, category, version in hits
204
+ ]
205
+
206
+
207
+ def extract_generic_software(body: str, location: str = "") -> list[Software]:
208
+ """Return Software records for generic product/version/commit patterns."""
209
+ out: list[Software] = []
210
+ seen: set[tuple[str, str | None]] = set()
211
+
212
+ def add(name: str, version: str | None, evidence: str) -> None:
213
+ name = _normalize_name(name)
214
+ if len(name) < 2 or not _is_plausible_name(name):
215
+ return
216
+ sname = _software_name(name)
217
+ if sname in _NOISE_NAMES:
218
+ return
219
+ key = (sname, version)
220
+ if key in seen:
221
+ return
222
+ seen.add(key)
223
+ out.append(
224
+ Software(
225
+ name=sname,
226
+ version=version,
227
+ source=evidence,
228
+ location=location,
229
+ )
230
+ )
231
+
232
+ for match in _SERVER_VERSION_RE.finditer(body):
233
+ name = _normalize_name(match.group(1))
234
+ version = match.group(2)
235
+ add(name, version, f"body:{name}/{version}")
236
+
237
+ for match in _POWERED_BY_VERSION_RE.finditer(body):
238
+ name = _normalize_name(match.group(1))
239
+ version = match.group(2)
240
+ if name:
241
+ add(name, version, f"body:powered-by {name} {version}")
242
+
243
+ for match in _POWERED_BY_PLAIN_RE.finditer(body):
244
+ name = _normalize_name(match.group(1))
245
+ if name:
246
+ add(name, None, f"body:powered-by {name}")
247
+
248
+ core_spans: set[tuple[int, int]] = set()
249
+ for match in _CORE_COMMIT_RE.finditer(body):
250
+ name = _normalize_name(match.group(1))
251
+ commit = match.group(2).lower()
252
+ if name:
253
+ add(name, commit, f"body:core-commit {name} ({commit})")
254
+ core_spans.add(match.span())
255
+
256
+ def _overlaps_core(span: tuple[int, int]) -> bool:
257
+ start, end = span
258
+ for c_start, c_end in core_spans:
259
+ if start < c_end and end > c_start:
260
+ return True
261
+ return False
262
+
263
+ for match in _COMMIT_AFTER_NAME_RE.finditer(body):
264
+ if _overlaps_core(match.span()):
265
+ continue
266
+ name = _normalize_name(match.group(1))
267
+ commit = match.group(2).lower()
268
+ if name and is_commit_hash(commit):
269
+ add(name, commit, f"body:commit {name} ({commit})")
270
+
271
+ return out
@@ -143,6 +143,11 @@ def _tech_name(tech: Technology) -> str:
143
143
 
144
144
 
145
145
  def _service_from_tech(tech: Technology) -> ServiceFinding | None:
146
+ """Map meaningful technologies to service findings.
147
+
148
+ Generic infrastructure/proxy/service hits (nginx, Cloudflare, HTTP/3, …)
149
+ live in the Technologies table; they are intentionally not duplicated here.
150
+ """
146
151
  name = _tech_name(tech)
147
152
  if name in _ADMIN_TECHS:
148
153
  return ServiceFinding(
@@ -158,21 +163,6 @@ def _service_from_tech(tech: Technology) -> ServiceFinding | None:
158
163
  evidence=" ".join(tech.evidence) or f"tech: {tech.name}",
159
164
  severity=_SEVERITY["database"],
160
165
  )
161
- if name in _SECURITY_TECHS:
162
- return ServiceFinding(
163
- name=_SECURITY_TECHS[name],
164
- kind="service",
165
- evidence=" ".join(tech.evidence) or f"tech: {tech.name}",
166
- severity=_SEVERITY["service"],
167
- )
168
- for category in tech.categories or ():
169
- if category in {"database", "service", "infrastructure", "security"}:
170
- return ServiceFinding(
171
- name=tech.name,
172
- kind=category if category != "service" else "service",
173
- evidence=" ".join(tech.evidence) or f"category: {category}",
174
- severity=_SEVERITY.get(category, "INFO"),
175
- )
176
166
  return None
177
167
 
178
168
 
@@ -4,6 +4,7 @@ import re
4
4
  from dataclasses import dataclass, field
5
5
  from typing import TYPE_CHECKING, Any, cast
6
6
 
7
+ from stackscan.analyzers.generic import extract_generic_tech
7
8
  from stackscan.types import FetchResult, Technology
8
9
  from stackscan.utils import host_of
9
10
 
@@ -39,6 +40,36 @@ def _script_srcs(html: str) -> list[str]:
39
40
  _CLASS_ATTR_RE = re.compile(r'\bclass\s*=\s*"([^"]*)"|\bclass\s*=\s*\'([^\']*)\'', re.I)
40
41
 
41
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
+ })
54
+ # Tailwind-style utilities: px-4, w-full, bg-red-500, my-auto, backdrop-blur, etc.
55
+ _CSS_UTILITY_RE = re.compile(
56
+ 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)$",
57
+ re.IGNORECASE,
58
+ )
59
+
60
+
61
+ def _is_likely_css_utility(token: str) -> bool:
62
+ if token in _CSS_UTILITY_EXACT:
63
+ return True
64
+ lowered = token.lower()
65
+ if _CSS_UTILITY_RE.match(token):
66
+ return True
67
+ # Tailwind arbitrary values such as bg-[#123], w-[100px], top-[1px]
68
+ if "-[" in lowered and lowered.endswith("]"):
69
+ return True
70
+ return False
71
+
72
+
42
73
  def _framework_tokens(html: str) -> tuple[str, ...]:
43
74
  tokens: set[str] = set()
44
75
  for match in _CLASS_ATTR_RE.finditer(html):
@@ -47,7 +78,7 @@ def _framework_tokens(html: str) -> tuple[str, ...]:
47
78
  continue
48
79
  for token in value.split():
49
80
  token = token.strip()
50
- if len(token) >= 3:
81
+ if len(token) >= 3 and not _is_likely_css_utility(token):
51
82
  tokens.add(token)
52
83
  return tuple(tokens)
53
84
 
@@ -58,6 +89,7 @@ class _Hit:
58
89
  category: str | None = None
59
90
  evidence: list[str] = field(default_factory=list[str])
60
91
  item: SigDBItem | None = None
92
+ version: str | None = None
61
93
 
62
94
 
63
95
  _EVIDENCE_WEIGHTS: tuple[tuple[str, int], ...] = (
@@ -86,6 +118,7 @@ def _confidence(evidence: list[str]) -> int:
86
118
 
87
119
 
88
120
  _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)
89
122
 
90
123
 
91
124
  def _version_key(version: str) -> tuple[int, int, int, int]:
@@ -262,7 +295,7 @@ class TechAnalyzer:
262
295
  evidence=tuple(hit.evidence),
263
296
  location=location,
264
297
  confidence=_confidence(hit.evidence),
265
- version=_infer_version(hit.item, hit.evidence),
298
+ version=hit.version or _infer_version(hit.item, hit.evidence),
266
299
  )
267
300
  for hit in by_name.values()
268
301
  ]
@@ -271,7 +304,7 @@ class TechAnalyzer:
271
304
 
272
305
  def _curated(self, acc: dict[str, _Hit], result: FetchResult) -> None:
273
306
 
274
- def add(name: str, category: str, evidence: str) -> None:
307
+ def add(name: str, category: str, evidence: str, version: str | None = None) -> None:
275
308
  key = f"curated:{name.lower()}"
276
309
  hit = acc.get(key)
277
310
  if hit is None:
@@ -279,6 +312,8 @@ class TechAnalyzer:
279
312
  acc[key] = hit
280
313
  if evidence not in hit.evidence:
281
314
  hit.evidence.append(evidence)
315
+ if version and not hit.version:
316
+ hit.version = version
282
317
 
283
318
  for header, needle, name, category in _HEADER_TECH:
284
319
  value = result.headers.get(header)
@@ -293,3 +328,12 @@ class TechAnalyzer:
293
328
  for prefix, name, category in _COOKIE_TECH:
294
329
  if cname.startswith(prefix):
295
330
  add(name, category, f"cookie:{cname}")
331
+
332
+ for match in _CORE_COMMIT_RE.finditer(result.body):
333
+ name = match.group(1).strip()
334
+ commit = match.group(2).lower()
335
+ if name:
336
+ add(name, "service", f"body:{name} Core ({commit})", version=commit)
337
+
338
+ 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)