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.
Files changed (45) hide show
  1. stackscan/__init__.py +5 -0
  2. stackscan/__main__.py +4 -0
  3. stackscan/analyzers/__init__.py +29 -0
  4. stackscan/analyzers/creds.py +218 -0
  5. stackscan/analyzers/cve.py +458 -0
  6. stackscan/analyzers/exposure.py +73 -0
  7. stackscan/analyzers/infra.py +114 -0
  8. stackscan/analyzers/security.py +26 -0
  9. stackscan/analyzers/services.py +285 -0
  10. stackscan/analyzers/tech.py +178 -0
  11. stackscan/cli.py +750 -0
  12. stackscan/config/__init__.py +19 -0
  13. stackscan/config/sigdb_loader.py +74 -0
  14. stackscan/config/sources.py +238 -0
  15. stackscan/core/__init__.py +3 -0
  16. stackscan/core/core.py +60 -0
  17. stackscan/data/builtin.sigdb +0 -0
  18. stackscan/data/cve.json.gz +0 -0
  19. stackscan/data/reekeer-logo.png +0 -0
  20. stackscan/data/subdomains.txt +522 -0
  21. stackscan/export.py +574 -0
  22. stackscan/net/__init__.py +22 -0
  23. stackscan/net/dns.py +168 -0
  24. stackscan/net/fingerprint.py +41 -0
  25. stackscan/net/geo.py +70 -0
  26. stackscan/net/ipinfo.py +94 -0
  27. stackscan/net/ports.py +219 -0
  28. stackscan/net/resolver.py +54 -0
  29. stackscan/net/subdomains.py +457 -0
  30. stackscan/net/tld.py +126 -0
  31. stackscan/net/tls.py +78 -0
  32. stackscan/render.py +541 -0
  33. stackscan/scan.py +545 -0
  34. stackscan/theme.py +23 -0
  35. stackscan/types/__init__.py +43 -0
  36. stackscan/types/models.py +18 -0
  37. stackscan/types/output.py +367 -0
  38. stackscan/utils/__init__.py +4 -0
  39. stackscan/utils/paths.py +10 -0
  40. stackscan/utils/urls.py +39 -0
  41. stackscan-2.1.0.dist-info/METADATA +341 -0
  42. stackscan-2.1.0.dist-info/RECORD +45 -0
  43. stackscan-2.1.0.dist-info/WHEEL +4 -0
  44. stackscan-2.1.0.dist-info/entry_points.txt +2 -0
  45. 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))