stackscan 2.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stackscan/__init__.py +5 -0
- stackscan/__main__.py +4 -0
- stackscan/analyzers/__init__.py +29 -0
- stackscan/analyzers/creds.py +218 -0
- stackscan/analyzers/cve.py +458 -0
- stackscan/analyzers/exposure.py +73 -0
- stackscan/analyzers/infra.py +114 -0
- stackscan/analyzers/security.py +26 -0
- stackscan/analyzers/services.py +285 -0
- stackscan/analyzers/tech.py +178 -0
- stackscan/cli.py +750 -0
- stackscan/config/__init__.py +19 -0
- stackscan/config/sigdb_loader.py +74 -0
- stackscan/config/sources.py +238 -0
- stackscan/core/__init__.py +3 -0
- stackscan/core/core.py +60 -0
- stackscan/data/builtin.sigdb +0 -0
- stackscan/data/cve.json.gz +0 -0
- stackscan/data/reekeer-logo.png +0 -0
- stackscan/data/subdomains.txt +522 -0
- stackscan/export.py +574 -0
- stackscan/net/__init__.py +22 -0
- stackscan/net/dns.py +168 -0
- stackscan/net/fingerprint.py +41 -0
- stackscan/net/geo.py +70 -0
- stackscan/net/ipinfo.py +94 -0
- stackscan/net/ports.py +219 -0
- stackscan/net/resolver.py +54 -0
- stackscan/net/subdomains.py +457 -0
- stackscan/net/tld.py +126 -0
- stackscan/net/tls.py +78 -0
- stackscan/render.py +541 -0
- stackscan/scan.py +545 -0
- stackscan/theme.py +23 -0
- stackscan/types/__init__.py +43 -0
- stackscan/types/models.py +18 -0
- stackscan/types/output.py +367 -0
- stackscan/utils/__init__.py +4 -0
- stackscan/utils/paths.py +10 -0
- stackscan/utils/urls.py +39 -0
- stackscan-2.1.0.dist-info/METADATA +341 -0
- stackscan-2.1.0.dist-info/RECORD +45 -0
- stackscan-2.1.0.dist-info/WHEEL +4 -0
- stackscan-2.1.0.dist-info/entry_points.txt +2 -0
- stackscan-2.1.0.dist-info/licenses/LICENSE +18 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import gzip
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from importlib import resources
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
from stackscan.types import CveMatch, Headers, PortScan, Software
|
|
13
|
+
|
|
14
|
+
CveEntry = dict[str, Any]
|
|
15
|
+
CveDb = dict[str, list[CveEntry]]
|
|
16
|
+
_NAME_MAP: dict[str, str] = {
|
|
17
|
+
"nginx": "nginx",
|
|
18
|
+
"apache": "apache",
|
|
19
|
+
"httpd": "apache",
|
|
20
|
+
"php": "php",
|
|
21
|
+
"openssl": "openssl",
|
|
22
|
+
"exim": "exim",
|
|
23
|
+
"tomcat": "tomcat",
|
|
24
|
+
"coyote": "tomcat",
|
|
25
|
+
"proftpd": "proftpd",
|
|
26
|
+
"openssh": "openssh",
|
|
27
|
+
"jquery": "jquery",
|
|
28
|
+
"wordpress": "wordpress",
|
|
29
|
+
"drupal": "drupal",
|
|
30
|
+
"joomla": "joomla",
|
|
31
|
+
"mysql": "mysql",
|
|
32
|
+
"mariadb": "mariadb",
|
|
33
|
+
"postgresql": "postgresql",
|
|
34
|
+
"postgres": "postgresql",
|
|
35
|
+
"redis": "redis",
|
|
36
|
+
"mongodb": "mongodb",
|
|
37
|
+
"elasticsearch": "elasticsearch",
|
|
38
|
+
"node": "nodejs",
|
|
39
|
+
"nodejs": "nodejs",
|
|
40
|
+
"lighttpd": "lighttpd",
|
|
41
|
+
"haproxy": "haproxy",
|
|
42
|
+
"iis": "iis",
|
|
43
|
+
"microsoft-iis": "iis",
|
|
44
|
+
"dovecot": "dovecot",
|
|
45
|
+
"postfix": "postfix",
|
|
46
|
+
"bind": "bind",
|
|
47
|
+
"squid": "squid",
|
|
48
|
+
"grafana": "grafana",
|
|
49
|
+
"gitlab": "gitlab",
|
|
50
|
+
"jenkins": "jenkins",
|
|
51
|
+
"phpmyadmin": "phpmyadmin",
|
|
52
|
+
"vsftpd": "vsftpd",
|
|
53
|
+
}
|
|
54
|
+
_CPE_MAP: dict[str, str] = {
|
|
55
|
+
"nginx": "f5:nginx",
|
|
56
|
+
"apache": "apache:http_server",
|
|
57
|
+
"tomcat": "apache:tomcat",
|
|
58
|
+
"openssh": "openbsd:openssh",
|
|
59
|
+
"php": "php:php",
|
|
60
|
+
"openssl": "openssl:openssl",
|
|
61
|
+
"exim": "exim:exim",
|
|
62
|
+
"proftpd": "proftpd:proftpd",
|
|
63
|
+
"jquery": "jquery:jquery",
|
|
64
|
+
"wordpress": "wordpress:wordpress",
|
|
65
|
+
"drupal": "drupal:drupal",
|
|
66
|
+
"joomla": "joomla:joomla",
|
|
67
|
+
"mysql": "oracle:mysql",
|
|
68
|
+
"mariadb": "mariadb:mariadb",
|
|
69
|
+
"postgresql": "postgresql:postgresql",
|
|
70
|
+
"redis": "redis:redis",
|
|
71
|
+
"mongodb": "mongodb:mongodb",
|
|
72
|
+
"elasticsearch": "elastic:elasticsearch",
|
|
73
|
+
"nodejs": "nodejs:node.js",
|
|
74
|
+
"lighttpd": "lighttpd:lighttpd",
|
|
75
|
+
"haproxy": "haproxy:haproxy",
|
|
76
|
+
"iis": "microsoft:internet_information_services",
|
|
77
|
+
"dovecot": "dovecot:dovecot",
|
|
78
|
+
"postfix": "postfix:postfix",
|
|
79
|
+
"bind": "isc:bind",
|
|
80
|
+
"squid": "squid-cache:squid",
|
|
81
|
+
"grafana": "grafana:grafana",
|
|
82
|
+
"gitlab": "gitlab:gitlab",
|
|
83
|
+
"jenkins": "jenkins:jenkins",
|
|
84
|
+
"phpmyadmin": "phpmyadmin:phpmyadmin",
|
|
85
|
+
"vsftpd": "vsftpd_project:vsftpd",
|
|
86
|
+
}
|
|
87
|
+
_TOKEN_RE = re.compile("([A-Za-z][A-Za-z0-9._+-]*?)[/ ]v?(\\d+(?:\\.\\d+){0,3})")
|
|
88
|
+
_JQUERY_RE = re.compile("jquery[-/]?v?(\\d+\\.\\d+(?:\\.\\d+)?)", re.IGNORECASE)
|
|
89
|
+
_GENERATOR_RE = re.compile(
|
|
90
|
+
"<meta[^>]+name=[\\\"']generator[\\\"'][^>]+content=[\\\"']([^\\\"']+)[\\\"']", re.IGNORECASE
|
|
91
|
+
)
|
|
92
|
+
_SSH_RE = re.compile("openssh[_/-]?(\\d+\\.\\d+(?:p\\d+)?)", re.IGNORECASE)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@lru_cache(maxsize=1)
|
|
96
|
+
def load_cve_db() -> CveDb:
|
|
97
|
+
empty: CveDb = {}
|
|
98
|
+
try:
|
|
99
|
+
raw = resources.files("stackscan.data").joinpath("cve.json.gz").read_bytes()
|
|
100
|
+
except (FileNotFoundError, ModuleNotFoundError, OSError):
|
|
101
|
+
return empty
|
|
102
|
+
try:
|
|
103
|
+
data = cast("dict[str, Any]", json.loads(gzip.decompress(raw)))
|
|
104
|
+
except (OSError, json.JSONDecodeError):
|
|
105
|
+
return empty
|
|
106
|
+
products = data.get("products")
|
|
107
|
+
if not isinstance(products, dict):
|
|
108
|
+
return empty
|
|
109
|
+
return cast("CveDb", products)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _parse_version(value: str) -> tuple[tuple[int, str], ...]:
|
|
113
|
+
parts: list[tuple[int, str]] = []
|
|
114
|
+
for chunk in re.split("[.\\-_]", value.strip()):
|
|
115
|
+
match = re.match("(\\d*)(.*)", chunk)
|
|
116
|
+
number = int(match.group(1)) if match and match.group(1) else 0
|
|
117
|
+
suffix = (match.group(2) if match else "").lower()
|
|
118
|
+
parts.append((number, suffix))
|
|
119
|
+
return tuple(parts)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _cmp(a: str, b: str) -> int:
|
|
123
|
+
va, vb = (_parse_version(a), _parse_version(b))
|
|
124
|
+
width = max(len(va), len(vb))
|
|
125
|
+
pad = ((0, ""),)
|
|
126
|
+
va += pad * (width - len(va))
|
|
127
|
+
vb += pad * (width - len(vb))
|
|
128
|
+
return (va > vb) - (va < vb)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _in_range(version: str, rng: dict[str, str]) -> bool:
|
|
132
|
+
if "start_incl" in rng and _cmp(version, rng["start_incl"]) < 0:
|
|
133
|
+
return False
|
|
134
|
+
if "start_excl" in rng and _cmp(version, rng["start_excl"]) <= 0:
|
|
135
|
+
return False
|
|
136
|
+
if "end_incl" in rng and _cmp(version, rng["end_incl"]) > 0:
|
|
137
|
+
return False
|
|
138
|
+
if "end_excl" in rng and _cmp(version, rng["end_excl"]) >= 0:
|
|
139
|
+
return False
|
|
140
|
+
return bool(rng)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _tokens(value: str, source: str, location: str) -> list[Software]:
|
|
144
|
+
found: list[Software] = []
|
|
145
|
+
for name, version in _TOKEN_RE.findall(value):
|
|
146
|
+
found.append(Software(name=name.lower(), version=version, source=source, location=location))
|
|
147
|
+
return found
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def extract_software(headers: Headers, body: str, location: str = "") -> list[Software]:
|
|
151
|
+
software: list[Software] = []
|
|
152
|
+
seen: set[tuple[str, str | None]] = set()
|
|
153
|
+
|
|
154
|
+
def add(item: Software) -> None:
|
|
155
|
+
key = (item.name, item.version)
|
|
156
|
+
if key not in seen:
|
|
157
|
+
seen.add(key)
|
|
158
|
+
software.append(item)
|
|
159
|
+
|
|
160
|
+
server = headers.get("server")
|
|
161
|
+
if server:
|
|
162
|
+
for item in _tokens(server, "header:server", location):
|
|
163
|
+
add(item)
|
|
164
|
+
powered = headers.get("x-powered-by")
|
|
165
|
+
if powered:
|
|
166
|
+
for item in _tokens(powered, "header:x-powered-by", location):
|
|
167
|
+
add(item)
|
|
168
|
+
jquery = _JQUERY_RE.search(body)
|
|
169
|
+
if jquery:
|
|
170
|
+
add(Software(name="jquery", version=jquery.group(1), source="script", location=location))
|
|
171
|
+
generator = _GENERATOR_RE.search(body)
|
|
172
|
+
if generator:
|
|
173
|
+
text = generator.group(1).strip()
|
|
174
|
+
gmatch = re.match("([A-Za-z][A-Za-z0-9 ]*?)\\s+(\\d+(?:\\.\\d+){0,3})", text)
|
|
175
|
+
if gmatch:
|
|
176
|
+
add(
|
|
177
|
+
Software(
|
|
178
|
+
name=gmatch.group(1).strip().lower().replace(" ", ""),
|
|
179
|
+
version=gmatch.group(2),
|
|
180
|
+
source="meta:generator",
|
|
181
|
+
location=location,
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
return software
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def software_from_ports(scan: PortScan | None) -> list[Software]:
|
|
188
|
+
if scan is None:
|
|
189
|
+
return []
|
|
190
|
+
out: list[Software] = []
|
|
191
|
+
for port in scan.ports:
|
|
192
|
+
blob = " ".join(filter(None, (port.product, port.version, port.service)))
|
|
193
|
+
if not blob:
|
|
194
|
+
continue
|
|
195
|
+
location = f"{port.host}:{port.port}" if port.host else f":{port.port}"
|
|
196
|
+
ssh = _SSH_RE.search(blob)
|
|
197
|
+
if ssh:
|
|
198
|
+
out.append(
|
|
199
|
+
Software(
|
|
200
|
+
name="openssh", version=ssh.group(1), source="port-banner", location=location
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
continue
|
|
204
|
+
if port.product and port.version:
|
|
205
|
+
out.append(
|
|
206
|
+
Software(
|
|
207
|
+
name=port.product.lower().split()[0],
|
|
208
|
+
version=port.version,
|
|
209
|
+
source="port-banner",
|
|
210
|
+
location=location,
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
return out
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
_AUTHORITATIVE = {"header:server", "header:x-powered-by", "port-banner"}
|
|
217
|
+
_SEVERITY_RANK = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _confidence(version: str, rng: dict[str, str], source: str) -> int:
|
|
221
|
+
score = 55
|
|
222
|
+
comps = len([c for c in re.split("[.\\-_]", version) if c[:1].isdigit()])
|
|
223
|
+
if comps >= 3:
|
|
224
|
+
score += 20
|
|
225
|
+
elif comps >= 2:
|
|
226
|
+
score += 12
|
|
227
|
+
bounded = ("start_incl" in rng or "start_excl" in rng) and (
|
|
228
|
+
"end_incl" in rng or "end_excl" in rng
|
|
229
|
+
)
|
|
230
|
+
score += 12 if bounded else 6
|
|
231
|
+
if source in _AUTHORITATIVE:
|
|
232
|
+
score += 12
|
|
233
|
+
return max(30, min(98, score))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@dataclass
|
|
237
|
+
class _CveAgg:
|
|
238
|
+
id: str
|
|
239
|
+
product: str
|
|
240
|
+
version: str | None
|
|
241
|
+
severity: str
|
|
242
|
+
cvss: float | None
|
|
243
|
+
confidence: int
|
|
244
|
+
summary: str
|
|
245
|
+
url: str | None
|
|
246
|
+
locations: set[str] = field(default_factory=set[str])
|
|
247
|
+
sources: set[str] = field(default_factory=set[str])
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _match_entries(
|
|
251
|
+
item: Software,
|
|
252
|
+
product_key: str,
|
|
253
|
+
entries: list[CveEntry],
|
|
254
|
+
agg: dict[str, _CveAgg],
|
|
255
|
+
) -> None:
|
|
256
|
+
version = item.version
|
|
257
|
+
if not version:
|
|
258
|
+
return
|
|
259
|
+
for entry in entries:
|
|
260
|
+
ranges = cast("list[dict[str, str]]", entry.get("ranges") or [])
|
|
261
|
+
hit_rng = next((r for r in ranges if _in_range(version, r)), None)
|
|
262
|
+
if hit_rng is None:
|
|
263
|
+
continue
|
|
264
|
+
cve_id = str(entry["id"])
|
|
265
|
+
confidence = _confidence(version, hit_rng, item.source)
|
|
266
|
+
record = agg.get(cve_id)
|
|
267
|
+
if record is None:
|
|
268
|
+
record = _CveAgg(
|
|
269
|
+
id=cve_id,
|
|
270
|
+
product=product_key,
|
|
271
|
+
version=version,
|
|
272
|
+
severity=str(entry.get("severity", "UNKNOWN")),
|
|
273
|
+
cvss=entry.get("cvss"),
|
|
274
|
+
confidence=confidence,
|
|
275
|
+
summary=str(entry.get("summary", "")),
|
|
276
|
+
url=entry.get("url"),
|
|
277
|
+
)
|
|
278
|
+
agg[cve_id] = record
|
|
279
|
+
if item.location:
|
|
280
|
+
record.locations.add(item.location)
|
|
281
|
+
if item.source:
|
|
282
|
+
record.sources.add(item.source)
|
|
283
|
+
if confidence > record.confidence:
|
|
284
|
+
record.confidence = confidence
|
|
285
|
+
record.version = version
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _agg_to_matches(agg: dict[str, _CveAgg]) -> list[CveMatch]:
|
|
289
|
+
matches = [
|
|
290
|
+
CveMatch(
|
|
291
|
+
id=r.id,
|
|
292
|
+
product=r.product,
|
|
293
|
+
version=r.version,
|
|
294
|
+
severity=r.severity,
|
|
295
|
+
cvss=r.cvss,
|
|
296
|
+
confidence=r.confidence,
|
|
297
|
+
summary=r.summary,
|
|
298
|
+
url=r.url,
|
|
299
|
+
locations=tuple(sorted(r.locations)),
|
|
300
|
+
sources=tuple(sorted(r.sources)),
|
|
301
|
+
)
|
|
302
|
+
for r in agg.values()
|
|
303
|
+
]
|
|
304
|
+
return _sort_matches(matches)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _sort_matches(matches: list[CveMatch]) -> list[CveMatch]:
|
|
308
|
+
matches.sort(
|
|
309
|
+
key=lambda m: (
|
|
310
|
+
_SEVERITY_RANK.get(m.severity.upper(), 4),
|
|
311
|
+
-(m.cvss or 0.0),
|
|
312
|
+
-m.confidence,
|
|
313
|
+
m.id,
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
return matches
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def merge_cve_matches(offline: list[CveMatch], online: list[CveMatch]) -> list[CveMatch]:
|
|
320
|
+
from dataclasses import replace
|
|
321
|
+
|
|
322
|
+
by_id: dict[str, CveMatch] = {}
|
|
323
|
+
for match in (*offline, *online):
|
|
324
|
+
existing = by_id.get(match.id)
|
|
325
|
+
if existing is None:
|
|
326
|
+
by_id[match.id] = match
|
|
327
|
+
else:
|
|
328
|
+
locations = tuple(sorted(set(existing.locations) | set(match.locations)))
|
|
329
|
+
sources = tuple(sorted(set(existing.sources) | set(match.sources)))
|
|
330
|
+
by_id[match.id] = replace(existing, locations=locations, sources=sources)
|
|
331
|
+
return _sort_matches(list(by_id.values()))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def match_cves(software: list[Software]) -> list[CveMatch]:
|
|
335
|
+
db = load_cve_db()
|
|
336
|
+
agg: dict[str, _CveAgg] = {}
|
|
337
|
+
for item in software:
|
|
338
|
+
if not item.version:
|
|
339
|
+
continue
|
|
340
|
+
product_key = _NAME_MAP.get(item.name, item.name)
|
|
341
|
+
entries = db.get(product_key)
|
|
342
|
+
if entries:
|
|
343
|
+
_match_entries(item, product_key, entries, agg)
|
|
344
|
+
return _agg_to_matches(agg)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
_NVD_URL = "https://services.nvd.nist.gov/rest/json/cves/2.0"
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _nvd_entries(payload: dict[str, Any], needle: str) -> list[CveEntry]:
|
|
351
|
+
entries: list[CveEntry] = []
|
|
352
|
+
vulns = cast("list[dict[str, Any]]", payload.get("vulnerabilities") or [])
|
|
353
|
+
for wrapper in vulns:
|
|
354
|
+
cve = cast("dict[str, Any]", wrapper.get("cve") or {})
|
|
355
|
+
ranges = _nvd_ranges(cve, needle)
|
|
356
|
+
if not ranges:
|
|
357
|
+
continue
|
|
358
|
+
score, severity = _nvd_cvss(cast("dict[str, Any]", cve.get("metrics") or {}))
|
|
359
|
+
entries.append(
|
|
360
|
+
{
|
|
361
|
+
"id": cve.get("id", ""),
|
|
362
|
+
"cvss": score,
|
|
363
|
+
"severity": severity or "UNKNOWN",
|
|
364
|
+
"summary": _nvd_summary(
|
|
365
|
+
cast("list[dict[str, str]]", cve.get("descriptions") or [])
|
|
366
|
+
),
|
|
367
|
+
"ranges": ranges,
|
|
368
|
+
"url": f"https://nvd.nist.gov/vuln/detail/{cve.get('id', '')}",
|
|
369
|
+
}
|
|
370
|
+
)
|
|
371
|
+
return entries
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _nvd_ranges(cve: dict[str, Any], needle: str) -> list[dict[str, str]]:
|
|
375
|
+
out: list[dict[str, str]] = []
|
|
376
|
+
for conf in cast("list[dict[str, Any]]", cve.get("configurations") or []):
|
|
377
|
+
for node in cast("list[dict[str, Any]]", conf.get("nodes") or []):
|
|
378
|
+
for cpe in cast("list[dict[str, Any]]", node.get("cpeMatch") or []):
|
|
379
|
+
criteria = str(cpe.get("criteria", ""))
|
|
380
|
+
if needle not in criteria:
|
|
381
|
+
continue
|
|
382
|
+
entry: dict[str, str] = {}
|
|
383
|
+
for src, dst in (
|
|
384
|
+
("versionStartIncluding", "start_incl"),
|
|
385
|
+
("versionStartExcluding", "start_excl"),
|
|
386
|
+
("versionEndIncluding", "end_incl"),
|
|
387
|
+
("versionEndExcluding", "end_excl"),
|
|
388
|
+
):
|
|
389
|
+
if cpe.get(src):
|
|
390
|
+
entry[dst] = str(cpe[src])
|
|
391
|
+
parts = criteria.split(":")
|
|
392
|
+
if not entry and len(parts) > 5 and (parts[5] not in ("*", "-")):
|
|
393
|
+
entry["start_incl"] = parts[5]
|
|
394
|
+
entry["end_incl"] = parts[5]
|
|
395
|
+
if entry and entry not in out:
|
|
396
|
+
out.append(entry)
|
|
397
|
+
return out
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _nvd_cvss(metrics: dict[str, Any]) -> tuple[float | None, str | None]:
|
|
401
|
+
for key in ("cvssMetricV31", "cvssMetricV30"):
|
|
402
|
+
entries = cast("list[dict[str, Any]]", metrics.get(key) or [])
|
|
403
|
+
if entries:
|
|
404
|
+
data = cast("dict[str, Any]", entries[0]["cvssData"])
|
|
405
|
+
return (float(data["baseScore"]), str(data["baseSeverity"]).upper())
|
|
406
|
+
v2 = cast("list[dict[str, Any]]", metrics.get("cvssMetricV2") or [])
|
|
407
|
+
if v2:
|
|
408
|
+
data = cast("dict[str, Any]", v2[0]["cvssData"])
|
|
409
|
+
return (float(data["baseScore"]), str(v2[0].get("baseSeverity", "")).upper() or None)
|
|
410
|
+
return (None, None)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _nvd_summary(descriptions: list[dict[str, str]]) -> str:
|
|
414
|
+
for desc in descriptions:
|
|
415
|
+
if desc.get("lang") == "en":
|
|
416
|
+
return " ".join(desc["value"].split())[:260]
|
|
417
|
+
return ""
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
async def match_cves_online(
|
|
421
|
+
software: list[Software], *, timeout: float = 25.0, workers: int = 3
|
|
422
|
+
) -> list[CveMatch]:
|
|
423
|
+
import aiohttp
|
|
424
|
+
|
|
425
|
+
products: dict[str, Software] = {}
|
|
426
|
+
for item in software:
|
|
427
|
+
if not item.version:
|
|
428
|
+
continue
|
|
429
|
+
key = _NAME_MAP.get(item.name, item.name)
|
|
430
|
+
if key in _CPE_MAP:
|
|
431
|
+
products.setdefault(key, item)
|
|
432
|
+
if not products:
|
|
433
|
+
return []
|
|
434
|
+
semaphore = asyncio.Semaphore(max(workers, 1))
|
|
435
|
+
client_timeout = aiohttp.ClientTimeout(total=timeout)
|
|
436
|
+
async with aiohttp.ClientSession(timeout=client_timeout) as session:
|
|
437
|
+
|
|
438
|
+
async def lookup(product_key: str, item: Software) -> list[CveEntry]:
|
|
439
|
+
vendor_product = _CPE_MAP[product_key]
|
|
440
|
+
params = {"virtualMatchString": f"cpe:2.3:a:{vendor_product}", "resultsPerPage": "2000"}
|
|
441
|
+
async with semaphore:
|
|
442
|
+
try:
|
|
443
|
+
async with session.get(
|
|
444
|
+
_NVD_URL, params=params, headers={"User-Agent": "stackscan"}
|
|
445
|
+
) as resp:
|
|
446
|
+
if resp.status != 200:
|
|
447
|
+
return []
|
|
448
|
+
payload = cast("dict[str, Any]", await resp.json())
|
|
449
|
+
except (aiohttp.ClientError, TimeoutError, ValueError, OSError):
|
|
450
|
+
return []
|
|
451
|
+
return _nvd_entries(payload, ":" + vendor_product + ":")
|
|
452
|
+
|
|
453
|
+
gathered = await asyncio.gather(*(lookup(key, item) for key, item in products.items()))
|
|
454
|
+
agg: dict[str, _CveAgg] = {}
|
|
455
|
+
for (product_key, item), entries in zip(products.items(), gathered, strict=True):
|
|
456
|
+
if entries:
|
|
457
|
+
_match_entries(item, product_key, entries, agg)
|
|
458
|
+
return _agg_to_matches(agg)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
from urllib.parse import urljoin
|
|
6
|
+
|
|
7
|
+
from stackscan.types import ExposureInfo
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from stackscan.core import StackscanSession
|
|
11
|
+
_PROBE_MAX_BYTES = 8192
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ExposureProbe:
|
|
16
|
+
timeout: float
|
|
17
|
+
user_agent: str
|
|
18
|
+
insecure: bool
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def _get(session: StackscanSession, url: str, probe: ExposureProbe) -> tuple[int | None, str]:
|
|
22
|
+
try:
|
|
23
|
+
result = await session.fetch(
|
|
24
|
+
url,
|
|
25
|
+
timeout=probe.timeout,
|
|
26
|
+
user_agent=probe.user_agent,
|
|
27
|
+
insecure=probe.insecure,
|
|
28
|
+
max_bytes=_PROBE_MAX_BYTES,
|
|
29
|
+
)
|
|
30
|
+
except Exception:
|
|
31
|
+
return (None, "")
|
|
32
|
+
return (result.status, result.body)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def analyze_exposure(
|
|
36
|
+
session: StackscanSession, base_url: str, probe: ExposureProbe
|
|
37
|
+
) -> ExposureInfo:
|
|
38
|
+
findings: list[str] = []
|
|
39
|
+
urls: dict[str, str] = {}
|
|
40
|
+
|
|
41
|
+
robots_url = urljoin(base_url, "/robots.txt")
|
|
42
|
+
robots_status, _ = await _get(session, robots_url, probe)
|
|
43
|
+
robots = robots_status == 200
|
|
44
|
+
if robots:
|
|
45
|
+
urls["robots.txt"] = robots_url
|
|
46
|
+
|
|
47
|
+
sitemap_url = urljoin(base_url, "/sitemap.xml")
|
|
48
|
+
sitemap_status, _ = await _get(session, sitemap_url, probe)
|
|
49
|
+
sitemap = sitemap_status == 200
|
|
50
|
+
if sitemap:
|
|
51
|
+
urls["sitemap.xml"] = sitemap_url
|
|
52
|
+
|
|
53
|
+
sec_url = urljoin(base_url, "/.well-known/security.txt")
|
|
54
|
+
sec_status, _ = await _get(session, sec_url, probe)
|
|
55
|
+
security_txt = sec_status == 200
|
|
56
|
+
if security_txt:
|
|
57
|
+
urls["security.txt"] = sec_url
|
|
58
|
+
|
|
59
|
+
git_url = urljoin(base_url, "/.git/HEAD")
|
|
60
|
+
git_status, git_body = await _get(session, git_url, probe)
|
|
61
|
+
git_exposed = git_status == 200 and git_body.strip().startswith("ref:")
|
|
62
|
+
if git_exposed:
|
|
63
|
+
urls[".git/HEAD"] = git_url
|
|
64
|
+
findings.append("Exposed .git/HEAD (source repository may be publicly readable)")
|
|
65
|
+
|
|
66
|
+
return ExposureInfo(
|
|
67
|
+
robots_txt=robots,
|
|
68
|
+
sitemap=sitemap,
|
|
69
|
+
security_txt=security_txt,
|
|
70
|
+
git_exposed=git_exposed,
|
|
71
|
+
findings=tuple(findings),
|
|
72
|
+
urls=urls,
|
|
73
|
+
)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from stackscan.types import Headers, InfraInfo
|
|
4
|
+
|
|
5
|
+
_Signature = tuple[str, str, str | None]
|
|
6
|
+
CDN_SIGNATURES: tuple[_Signature, ...] = (
|
|
7
|
+
("Cloudflare", "server", "cloudflare"),
|
|
8
|
+
("Cloudflare", "cf-ray", None),
|
|
9
|
+
("Fastly", "x-served-by", "cache-"),
|
|
10
|
+
("Fastly", "x-fastly-request-id", None),
|
|
11
|
+
("Akamai", "server", "akamaighost"),
|
|
12
|
+
("Akamai", "x-akamai-transformed", None),
|
|
13
|
+
("Amazon CloudFront", "server", "cloudfront"),
|
|
14
|
+
("Amazon CloudFront", "x-amz-cf-id", None),
|
|
15
|
+
("Google Cloud", "via", "1.1 google"),
|
|
16
|
+
("Vercel", "server", "vercel"),
|
|
17
|
+
("Vercel", "x-vercel-id", None),
|
|
18
|
+
("Netlify", "server", "netlify"),
|
|
19
|
+
("Netlify", "x-nf-request-id", None),
|
|
20
|
+
("BunnyCDN", "server", "bunnycdn"),
|
|
21
|
+
("KeyCDN", "server", "keycdn"),
|
|
22
|
+
("Sucuri", "x-sucuri-id", None),
|
|
23
|
+
("StackPath", "x-hw", None),
|
|
24
|
+
)
|
|
25
|
+
WAF_SIGNATURES: tuple[_Signature, ...] = (
|
|
26
|
+
("Cloudflare", "cf-ray", None),
|
|
27
|
+
("Sucuri", "x-sucuri-id", None),
|
|
28
|
+
("Sucuri", "server", "sucuri"),
|
|
29
|
+
("Imperva Incapsula", "x-iinfo", None),
|
|
30
|
+
("Imperva Incapsula", "x-cdn", "incapsula"),
|
|
31
|
+
("Akamai", "server", "akamaighost"),
|
|
32
|
+
("F5 BIG-IP", "server", "big-ip"),
|
|
33
|
+
("Barracuda", "server", "barracuda"),
|
|
34
|
+
("AWS WAF", "x-amzn-waf-action", None),
|
|
35
|
+
)
|
|
36
|
+
SERVER_SIGNATURES: tuple[_Signature, ...] = (
|
|
37
|
+
("nginx", "server", "nginx"),
|
|
38
|
+
("Apache", "server", "apache"),
|
|
39
|
+
("Apache Traffic Server", "server", "atsserver"),
|
|
40
|
+
("OpenResty", "server", "openresty"),
|
|
41
|
+
("Caddy", "server", "caddy"),
|
|
42
|
+
("LiteSpeed", "server", "litespeed"),
|
|
43
|
+
("Microsoft-IIS", "server", "microsoft-iis"),
|
|
44
|
+
("Envoy", "server", "envoy"),
|
|
45
|
+
("HAProxy", "server", "haproxy"),
|
|
46
|
+
("Traefik", "x-traefik-router", None),
|
|
47
|
+
("gunicorn", "server", "gunicorn"),
|
|
48
|
+
("uvicorn", "server", "uvicorn"),
|
|
49
|
+
("Werkzeug", "server", "werkzeug"),
|
|
50
|
+
("Jetty", "server", "jetty"),
|
|
51
|
+
("Tomcat", "server", "tomcat"),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _collect(headers: Headers, signatures: tuple[_Signature, ...]) -> list[str]:
|
|
56
|
+
found: list[str] = []
|
|
57
|
+
for label, name, needle in signatures:
|
|
58
|
+
value = headers.get(name.lower())
|
|
59
|
+
if value is None:
|
|
60
|
+
continue
|
|
61
|
+
if needle is None or needle in value.lower():
|
|
62
|
+
if label not in found:
|
|
63
|
+
found.append(label)
|
|
64
|
+
return found
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _cookie_names(cookies: tuple[str, ...]) -> list[str]:
|
|
68
|
+
names: list[str] = []
|
|
69
|
+
for raw in cookies:
|
|
70
|
+
first = raw.split(";", 1)[0].strip()
|
|
71
|
+
name = first.split("=", 1)[0].strip().lower()
|
|
72
|
+
if name:
|
|
73
|
+
names.append(name)
|
|
74
|
+
return names
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _proxy_notes(headers: Headers, host: str) -> list[str]:
|
|
78
|
+
notes: list[str] = []
|
|
79
|
+
server = (headers.get("server") or "").lower()
|
|
80
|
+
if "via" in headers:
|
|
81
|
+
notes.append(f"Via header present: {headers['via']}")
|
|
82
|
+
if "openresty" in server:
|
|
83
|
+
notes.append("OpenResty edge (commonly Nginx Proxy Manager)")
|
|
84
|
+
host = host.lower()
|
|
85
|
+
for name, value in headers.items():
|
|
86
|
+
if name == "_raw":
|
|
87
|
+
continue
|
|
88
|
+
low = value.lower()
|
|
89
|
+
if host and (low == host or low.endswith("." + host) or host in low.split()):
|
|
90
|
+
notes.append(f"Header '{name}' reflects requested host (reverse proxy likely)")
|
|
91
|
+
break
|
|
92
|
+
return notes
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def analyze_infra(headers: Headers, cookies: tuple[str, ...], host: str) -> InfraInfo:
|
|
96
|
+
cdn = _collect(headers, CDN_SIGNATURES)
|
|
97
|
+
waf = _collect(headers, WAF_SIGNATURES)
|
|
98
|
+
server = _collect(headers, SERVER_SIGNATURES)
|
|
99
|
+
proxy: list[str] = []
|
|
100
|
+
notes = _proxy_notes(headers, host)
|
|
101
|
+
cookie_names = _cookie_names(cookies)
|
|
102
|
+
if any(name.startswith("bigipserver") for name in cookie_names):
|
|
103
|
+
if "F5 BIG-IP" not in waf:
|
|
104
|
+
waf.append("F5 BIG-IP")
|
|
105
|
+
proxy.append("F5 BIG-IP")
|
|
106
|
+
if "__cfduid" in cookie_names or "__cf_bm" in cookie_names:
|
|
107
|
+
if "Cloudflare" not in cdn:
|
|
108
|
+
cdn.append("Cloudflare")
|
|
109
|
+
for label in ("OpenResty", "Envoy", "HAProxy", "Traefik", "Apache Traffic Server"):
|
|
110
|
+
if label in server and label not in proxy:
|
|
111
|
+
proxy.append(label)
|
|
112
|
+
return InfraInfo(
|
|
113
|
+
cdn=tuple(cdn), waf=tuple(waf), proxy=tuple(proxy), server=tuple(server), notes=tuple(notes)
|
|
114
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from stackscan.types import Headers, SecurityHeaders
|
|
4
|
+
|
|
5
|
+
SECURITY_HEADERS: tuple[str, ...] = (
|
|
6
|
+
"strict-transport-security",
|
|
7
|
+
"content-security-policy",
|
|
8
|
+
"x-frame-options",
|
|
9
|
+
"x-content-type-options",
|
|
10
|
+
"referrer-policy",
|
|
11
|
+
"permissions-policy",
|
|
12
|
+
"cross-origin-opener-policy",
|
|
13
|
+
"cross-origin-resource-policy",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def analyze_security_headers(headers: Headers) -> SecurityHeaders:
|
|
18
|
+
present: dict[str, str] = {}
|
|
19
|
+
missing: list[str] = []
|
|
20
|
+
for name in SECURITY_HEADERS:
|
|
21
|
+
value = headers.get(name)
|
|
22
|
+
if value is not None:
|
|
23
|
+
present[name] = value
|
|
24
|
+
else:
|
|
25
|
+
missing.append(name)
|
|
26
|
+
return SecurityHeaders(present=present, missing=tuple(missing))
|