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.
- {stackscan-2.2.2 → stackscan-2.3.0}/PKG-INFO +4 -3
- {stackscan-2.2.2 → stackscan-2.3.0}/README.md +3 -2
- {stackscan-2.2.2 → stackscan-2.3.0}/scripts/build_cve_db.py +12 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/__init__.py +1 -1
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/analyzers/cve.py +67 -12
- stackscan-2.3.0/src/stackscan/analyzers/generic.py +271 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/analyzers/services.py +5 -15
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/analyzers/tech.py +47 -3
- stackscan-2.3.0/src/stackscan/analyzers/vibe.py +248 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/cli.py +115 -74
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/core/core.py +14 -2
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/data/subdomains.txt +32 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/net/dns.py +16 -9
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/net/ipinfo.py +13 -6
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/net/subdomains.py +71 -4
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/net/tls.py +31 -19
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/render.py +170 -84
- stackscan-2.3.0/src/stackscan/scan.py +1039 -0
- stackscan-2.3.0/src/stackscan/scanners/__init__.py +0 -0
- stackscan-2.3.0/src/stackscan/scanners/isp_blocked.py +133 -0
- stackscan-2.3.0/src/stackscan/scanners/secrets.py +100 -0
- stackscan-2.3.0/src/stackscan/scanners/takeover.py +214 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/types/__init__.py +4 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/types/output.py +48 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_brute.py +4 -4
- {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_cli_helpers.py +3 -0
- stackscan-2.3.0/tests/test_cve.py +229 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_extra.py +5 -5
- stackscan-2.3.0/tests/test_scanners.py +97 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_services.py +14 -0
- stackscan-2.3.0/tests/test_tech.py +224 -0
- stackscan-2.3.0/tests/test_vhost.py +133 -0
- stackscan-2.3.0/tests/test_vibe.py +38 -0
- stackscan-2.2.2/src/stackscan/scan.py +0 -560
- stackscan-2.2.2/tests/test_cve.py +0 -120
- stackscan-2.2.2/tests/test_tech.py +0 -71
- {stackscan-2.2.2 → stackscan-2.3.0}/.gitignore +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/LICENSE +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/pyproject.toml +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/__main__.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/analyzers/__init__.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/analyzers/creds.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/analyzers/exposure.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/analyzers/infra.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/analyzers/osdetect.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/analyzers/security.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/analyzers/social.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/config/__init__.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/config/sigdb_loader.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/config/sources.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/core/__init__.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/data/builtin.sigdb +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/data/cve.json.gz +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/data/reekeer-logo.png +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/export.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/net/__init__.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/net/fingerprint.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/net/geo.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/net/ports.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/net/resolver.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/net/tld.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/theme.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/types/models.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/utils/__init__.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/utils/paths.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/src/stackscan/utils/urls.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_builtin_sigdb.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_creds.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_dns_records.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_export.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_infra.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_osdetect.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_ports.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_scan.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_social.py +0 -0
- {stackscan-2.2.2 → stackscan-2.3.0}/tests/test_sources.py +0 -0
- {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.
|
|
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
|
|
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` | `
|
|
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
|
|
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` | `
|
|
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
|
|
@@ -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[
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|