stackscan 2.2.1__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 (80) hide show
  1. {stackscan-2.2.1 → stackscan-2.3.0}/PKG-INFO +7 -4
  2. {stackscan-2.2.1 → stackscan-2.3.0}/README.md +6 -3
  3. {stackscan-2.2.1 → stackscan-2.3.0}/scripts/build_cve_db.py +12 -0
  4. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/__init__.py +1 -1
  5. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/analyzers/__init__.py +7 -2
  6. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/analyzers/creds.py +107 -35
  7. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/analyzers/cve.py +108 -21
  8. stackscan-2.3.0/src/stackscan/analyzers/generic.py +271 -0
  9. stackscan-2.3.0/src/stackscan/analyzers/osdetect.py +127 -0
  10. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/analyzers/services.py +38 -35
  11. stackscan-2.3.0/src/stackscan/analyzers/social.py +76 -0
  12. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/analyzers/tech.py +165 -4
  13. stackscan-2.3.0/src/stackscan/analyzers/vibe.py +248 -0
  14. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/cli.py +227 -80
  15. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/core/core.py +14 -2
  16. stackscan-2.3.0/src/stackscan/data/builtin.sigdb +0 -0
  17. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/data/subdomains.txt +32 -0
  18. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/export.py +34 -1
  19. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/net/dns.py +16 -9
  20. stackscan-2.3.0/src/stackscan/net/fingerprint.py +114 -0
  21. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/net/ipinfo.py +13 -6
  22. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/net/ports.py +47 -13
  23. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/net/subdomains.py +71 -4
  24. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/net/tls.py +45 -22
  25. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/render.py +218 -84
  26. stackscan-2.3.0/src/stackscan/scan.py +1039 -0
  27. stackscan-2.3.0/src/stackscan/scanners/__init__.py +0 -0
  28. stackscan-2.3.0/src/stackscan/scanners/isp_blocked.py +133 -0
  29. stackscan-2.3.0/src/stackscan/scanners/secrets.py +100 -0
  30. stackscan-2.3.0/src/stackscan/scanners/takeover.py +214 -0
  31. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/types/__init__.py +10 -0
  32. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/types/output.py +115 -0
  33. stackscan-2.3.0/tests/test_brute.py +86 -0
  34. {stackscan-2.2.1 → stackscan-2.3.0}/tests/test_cli_helpers.py +3 -0
  35. stackscan-2.3.0/tests/test_cve.py +229 -0
  36. {stackscan-2.2.1 → stackscan-2.3.0}/tests/test_extra.py +5 -5
  37. stackscan-2.3.0/tests/test_osdetect.py +54 -0
  38. stackscan-2.3.0/tests/test_ports.py +97 -0
  39. stackscan-2.3.0/tests/test_scan.py +54 -0
  40. stackscan-2.3.0/tests/test_scanners.py +97 -0
  41. {stackscan-2.2.1 → stackscan-2.3.0}/tests/test_services.py +27 -2
  42. stackscan-2.3.0/tests/test_social.py +30 -0
  43. stackscan-2.3.0/tests/test_tech.py +224 -0
  44. stackscan-2.3.0/tests/test_vhost.py +133 -0
  45. stackscan-2.3.0/tests/test_vibe.py +38 -0
  46. stackscan-2.2.1/src/stackscan/data/builtin.sigdb +0 -0
  47. stackscan-2.2.1/src/stackscan/net/fingerprint.py +0 -41
  48. stackscan-2.2.1/src/stackscan/scan.py +0 -545
  49. stackscan-2.2.1/tests/test_cve.py +0 -73
  50. stackscan-2.2.1/tests/test_ports.py +0 -41
  51. stackscan-2.2.1/tests/test_tech.py +0 -47
  52. {stackscan-2.2.1 → stackscan-2.3.0}/.gitignore +0 -0
  53. {stackscan-2.2.1 → stackscan-2.3.0}/LICENSE +0 -0
  54. {stackscan-2.2.1 → stackscan-2.3.0}/pyproject.toml +0 -0
  55. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/__main__.py +0 -0
  56. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/analyzers/exposure.py +0 -0
  57. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/analyzers/infra.py +0 -0
  58. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/analyzers/security.py +0 -0
  59. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/config/__init__.py +0 -0
  60. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/config/sigdb_loader.py +0 -0
  61. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/config/sources.py +0 -0
  62. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/core/__init__.py +0 -0
  63. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/data/cve.json.gz +0 -0
  64. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/data/reekeer-logo.png +0 -0
  65. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/net/__init__.py +0 -0
  66. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/net/geo.py +0 -0
  67. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/net/resolver.py +0 -0
  68. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/net/tld.py +0 -0
  69. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/theme.py +0 -0
  70. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/types/models.py +0 -0
  71. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/utils/__init__.py +0 -0
  72. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/utils/paths.py +0 -0
  73. {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/utils/urls.py +0 -0
  74. {stackscan-2.2.1 → stackscan-2.3.0}/tests/test_builtin_sigdb.py +0 -0
  75. {stackscan-2.2.1 → stackscan-2.3.0}/tests/test_creds.py +0 -0
  76. {stackscan-2.2.1 → stackscan-2.3.0}/tests/test_dns_records.py +0 -0
  77. {stackscan-2.2.1 → stackscan-2.3.0}/tests/test_export.py +0 -0
  78. {stackscan-2.2.1 → stackscan-2.3.0}/tests/test_infra.py +0 -0
  79. {stackscan-2.2.1 → stackscan-2.3.0}/tests/test_sources.py +0 -0
  80. {stackscan-2.2.1 → 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.1
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,10 +169,13 @@ 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). |
173
- | `--default-creds` | off | Bounded default-credential / open-device check. |
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). |
174
+ | `--default-creds` | off | Bounded default-credential / open-device check (prompts before brute-forcing). |
175
+ | `--full-auto` | off | Auto-accept every brute prompt on discovered devices (enables default-cred checks). |
174
176
  | `--cred-limit` | `50` | Max default-credential pairs per device (`0` = full SecLists list). |
175
177
  | `--cve-online` | off | Also query NVD live for detected products. |
178
+ | `--parse-social` | off | Extract social media and contact links from the page. |
176
179
  | `--workers` | `350` | Parallel workers for ports/subdomains/creds. |
177
180
  | `--concurrency` | `10` | Concurrent targets. |
178
181
  | `--sigdb` / `--no-builtin` / `--no-sources` | – | Signature database selection. |
@@ -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,10 +131,13 @@ 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). |
135
- | `--default-creds` | off | Bounded default-credential / open-device check. |
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). |
136
+ | `--default-creds` | off | Bounded default-credential / open-device check (prompts before brute-forcing). |
137
+ | `--full-auto` | off | Auto-accept every brute prompt on discovered devices (enables default-cred checks). |
136
138
  | `--cred-limit` | `50` | Max default-credential pairs per device (`0` = full SecLists list). |
137
139
  | `--cve-online` | off | Also query NVD live for detected products. |
140
+ | `--parse-social` | off | Extract social media and contact links from the page. |
138
141
  | `--workers` | `350` | Parallel workers for ports/subdomains/creds. |
139
142
  | `--concurrency` | `10` | Concurrent targets. |
140
143
  | `--sigdb` / `--no-builtin` / `--no-sources` | – | Signature database selection. |
@@ -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.1"
5
+ __version__ = "2.3.0"
@@ -1,4 +1,4 @@
1
- from stackscan.analyzers.creds import check_default_creds
1
+ from stackscan.analyzers.creds import brute_devices, detect_devices
2
2
  from stackscan.analyzers.cve import (
3
3
  extract_software,
4
4
  match_cves,
@@ -8,8 +8,10 @@ from stackscan.analyzers.cve import (
8
8
  )
9
9
  from stackscan.analyzers.exposure import ExposureProbe, analyze_exposure
10
10
  from stackscan.analyzers.infra import analyze_infra
11
+ from stackscan.analyzers.osdetect import detect_os
11
12
  from stackscan.analyzers.security import analyze_security_headers
12
13
  from stackscan.analyzers.services import classify_services, port_category
14
+ from stackscan.analyzers.social import parse_social
13
15
  from stackscan.analyzers.tech import TechAnalyzer
14
16
 
15
17
  __all__ = [
@@ -18,9 +20,12 @@ __all__ = [
18
20
  "analyze_exposure",
19
21
  "analyze_infra",
20
22
  "analyze_security_headers",
21
- "check_default_creds",
23
+ "brute_devices",
24
+ "detect_devices",
25
+ "detect_os",
22
26
  "classify_services",
23
27
  "port_category",
28
+ "parse_social",
24
29
  "extract_software",
25
30
  "match_cves",
26
31
  "match_cves_online",
@@ -7,7 +7,7 @@ from functools import lru_cache
7
7
 
8
8
  import aiohttp
9
9
 
10
- from stackscan.types import CredFinding, PortScan
10
+ from stackscan.types import BruteTarget, CredFinding, PortScan
11
11
  from stackscan.utils import db_dir
12
12
 
13
13
  _CREDS_URL = "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Passwords/Default-Credentials/default-passwords.csv"
@@ -40,6 +40,27 @@ _DEVICE_KEYWORDS = (
40
40
  "network camera",
41
41
  "surveillance",
42
42
  "uc-httpd",
43
+ "go2rtc",
44
+ "rtsp",
45
+ )
46
+ _CAMERA_KEYWORDS = (
47
+ "camera",
48
+ "ipcam",
49
+ "netcam",
50
+ "webcam",
51
+ "dvr",
52
+ "nvr",
53
+ "cctv",
54
+ "hikvision",
55
+ "dahua",
56
+ "axis",
57
+ "foscam",
58
+ "vivotek",
59
+ "webcamxp",
60
+ "network camera",
61
+ "surveillance",
62
+ "go2rtc",
63
+ "rtsp",
43
64
  )
44
65
  _BUILTIN_CREDS: tuple[tuple[str, str], ...] = (
45
66
  ("admin", "admin"),
@@ -105,6 +126,9 @@ def load_default_creds() -> tuple[tuple[str, str], ...]:
105
126
  return tuple(combined)
106
127
 
107
128
 
129
+ _RTSP_PORTS: frozenset[int] = frozenset({554, 8554})
130
+
131
+
108
132
  def _http_ports(scan: PortScan | None) -> list[tuple[int, bool]]:
109
133
  if scan is None:
110
134
  return []
@@ -113,7 +137,12 @@ def _http_ports(scan: PortScan | None) -> list[tuple[int, bool]]:
113
137
  service = (port.service or "").lower()
114
138
  if port.port in (443, 8443, 2083) or "https" in service or "ssl" in service:
115
139
  out.append((port.port, True))
116
- elif "http" in service or port.port in (80, 8080, 8000, 8081, 8888, 9000, 631, 7547):
140
+ elif (
141
+ "http" in service
142
+ or port.port in (80, 8080, 8000, 8081, 8888, 9000, 631, 7547)
143
+ or port.port in _RTSP_PORTS
144
+ or "rtsp" in service
145
+ ):
117
146
  out.append((port.port, False))
118
147
  return out
119
148
 
@@ -123,45 +152,49 @@ def _looks_like_device(realm: str, server: str) -> bool:
123
152
  return any(keyword in blob for keyword in _DEVICE_KEYWORDS)
124
153
 
125
154
 
126
- async def check_default_creds(
155
+ def _is_camera(realm: str, server: str) -> bool:
156
+ blob = f"{realm} {server}".lower()
157
+ return any(keyword in blob for keyword in _CAMERA_KEYWORDS)
158
+
159
+
160
+ async def detect_devices(
127
161
  host: str,
128
162
  scan: PortScan | None,
129
163
  *,
130
164
  timeout: float = 6.0,
131
165
  insecure: bool = True,
132
166
  workers: int = 10,
133
- cred_limit: int = 100,
134
- ) -> list[CredFinding]:
167
+ ) -> tuple[list[CredFinding], list[BruteTarget]]:
135
168
  ports = _http_ports(scan)
136
169
  if not ports:
137
- return []
138
- creds = await asyncio.to_thread(load_default_creds)
139
- if cred_limit > 0:
140
- creds = creds[:cred_limit]
170
+ return ([], [])
141
171
  connector = aiohttp.TCPConnector(ssl=False, limit=max(workers, 1))
142
172
  client_timeout = aiohttp.ClientTimeout(total=timeout)
143
173
  semaphore = asyncio.Semaphore(max(workers, 1))
144
174
  findings: list[CredFinding] = []
175
+ candidates: list[BruteTarget] = []
145
176
  async with aiohttp.ClientSession(connector=connector, timeout=client_timeout) as session:
146
177
 
147
- async def check(port: int, tls: bool) -> None:
178
+ async def probe(port: int, tls: bool) -> None:
148
179
  async with semaphore:
149
- finding = await _check_endpoint(session, host, port, tls, creds)
150
- if finding is not None:
151
- findings.append(finding)
180
+ result = await _probe_endpoint(session, host, port, tls)
181
+ if isinstance(result, BruteTarget):
182
+ candidates.append(result)
183
+ elif result is not None:
184
+ findings.append(result)
152
185
 
153
- await asyncio.gather(*(check(port, tls) for port, tls in ports))
154
- findings.sort(key=lambda f: (f.kind != "default-creds", f.kind != "open-no-auth", f.target))
155
- return findings
186
+ await asyncio.gather(*(probe(port, tls) for port, tls in ports))
187
+ findings.sort(key=lambda f: f.target)
188
+ candidates.sort(key=lambda c: (c.host, c.port))
189
+ return (findings, candidates)
156
190
 
157
191
 
158
- async def _check_endpoint(
192
+ async def _probe_endpoint(
159
193
  session: aiohttp.ClientSession,
160
194
  host: str,
161
195
  port: int,
162
196
  tls: bool,
163
- creds: tuple[tuple[str, str], ...],
164
- ) -> CredFinding | None:
197
+ ) -> CredFinding | BruteTarget | None:
165
198
  scheme = "https" if tls else "http"
166
199
  url = f"{scheme}://{host}:{port}/"
167
200
  target = f"{host}:{port}"
@@ -172,7 +205,7 @@ async def _check_endpoint(
172
205
  realm = resp.headers.get("WWW-Authenticate", "")
173
206
  except (aiohttp.ClientError, TimeoutError, OSError):
174
207
  return None
175
- device = _looks_like_device(realm, server)
208
+ device = _looks_like_device(realm, server) or port in _RTSP_PORTS
176
209
  if status != 401:
177
210
  if device or _looks_like_device("", server):
178
211
  return CredFinding(
@@ -184,25 +217,64 @@ async def _check_endpoint(
184
217
  return None
185
218
  if not device:
186
219
  return None
187
- hit = await _try_defaults(session, url, creds)
188
- if hit is not None:
189
- username, password = hit
190
- return CredFinding(
191
- target=target,
192
- service=f"{scheme} ({server or realm or 'device'})",
193
- kind="default-creds",
194
- detail="default credentials accepted via HTTP Basic auth",
195
- username=username,
196
- password=password,
197
- )
198
- return CredFinding(
199
- target=target,
220
+ return BruteTarget(
221
+ host=host,
222
+ port=port,
223
+ tls=tls,
200
224
  service=f"{scheme} ({server or realm or 'device'})",
201
- kind="auth-required",
202
- detail="device auth required; no default credential matched",
225
+ is_camera=_is_camera(realm, server) or port in _RTSP_PORTS,
203
226
  )
204
227
 
205
228
 
229
+ async def brute_devices(
230
+ targets: list[BruteTarget],
231
+ *,
232
+ timeout: float = 6.0,
233
+ insecure: bool = True,
234
+ workers: int = 10,
235
+ cred_limit: int = 100,
236
+ ) -> list[CredFinding]:
237
+ if not targets:
238
+ return []
239
+ creds = await asyncio.to_thread(load_default_creds)
240
+ if cred_limit > 0:
241
+ creds = creds[:cred_limit]
242
+ connector = aiohttp.TCPConnector(ssl=False, limit=max(workers, 1))
243
+ client_timeout = aiohttp.ClientTimeout(total=timeout)
244
+ semaphore = asyncio.Semaphore(max(workers, 1))
245
+ findings: list[CredFinding] = []
246
+ async with aiohttp.ClientSession(connector=connector, timeout=client_timeout) as session:
247
+
248
+ async def run(target: BruteTarget) -> None:
249
+ async with semaphore:
250
+ hit = await _try_defaults(session, target.url, creds)
251
+ if hit is not None:
252
+ username, password = hit
253
+ findings.append(
254
+ CredFinding(
255
+ target=target.target,
256
+ service=target.service,
257
+ kind="default-creds",
258
+ detail="default credentials accepted via HTTP Basic auth",
259
+ username=username,
260
+ password=password,
261
+ )
262
+ )
263
+ else:
264
+ findings.append(
265
+ CredFinding(
266
+ target=target.target,
267
+ service=target.service,
268
+ kind="auth-required",
269
+ detail="device auth required; no default credential matched",
270
+ )
271
+ )
272
+
273
+ await asyncio.gather(*(run(target) for target in targets))
274
+ findings.sort(key=lambda f: (f.kind != "default-creds", f.target))
275
+ return findings
276
+
277
+
206
278
  async def _try_defaults(
207
279
  session: aiohttp.ClientSession, url: str, creds: tuple[tuple[str, str], ...]
208
280
  ) -> tuple[str, str] | None:
@@ -9,9 +9,20 @@ 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]
16
+ _BACKPORT_DISTRO_RE = re.compile(
17
+ r"(0?ubuntu0?[._]\d[\d.]+)"
18
+ r"|\b(ubuntu|debian|centos|rhel|red\s*hat|fedora|amzn|amazon|rocky|almalinux|alpine|raspbian|suse|opensuse|oracle|sles)\b"
19
+ r"|(\+deb\d+u?\d*)"
20
+ r"|(-\d+ubuntu\d+(?![\d._]))"
21
+ r"|(el\d+)"
22
+ r"|(\.fc\d+)"
23
+ r"|(~bpo\d+\+[\w]+)",
24
+ re.I,
25
+ )
15
26
  CveDb = dict[str, list[CveEntry]]
16
27
  _NAME_MAP: dict[str, str] = {
17
28
  "nginx": "nginx",
@@ -89,7 +100,10 @@ _JQUERY_RE = re.compile("jquery[-/]?v?(\\d+\\.\\d+(?:\\.\\d+)?)", re.IGNORECASE)
89
100
  _GENERATOR_RE = re.compile(
90
101
  "<meta[^>]+name=[\\\"']generator[\\\"'][^>]+content=[\\\"']([^\\\"']+)[\\\"']", re.IGNORECASE
91
102
  )
92
- _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
+ )
93
107
 
94
108
 
95
109
  @lru_cache(maxsize=1)
@@ -109,6 +123,11 @@ def load_cve_db() -> CveDb:
109
123
  return cast("CveDb", products)
110
124
 
111
125
 
126
+ def _distro_tag(text: str) -> str:
127
+ match = _BACKPORT_DISTRO_RE.search(text)
128
+ return match.group(0) if match else ""
129
+
130
+
112
131
  def _parse_version(value: str) -> tuple[tuple[int, str], ...]:
113
132
  parts: list[tuple[int, str]] = []
114
133
  for chunk in re.split("[.\\-_]", value.strip()):
@@ -140,10 +159,12 @@ def _in_range(version: str, rng: dict[str, str]) -> bool:
140
159
  return bool(rng)
141
160
 
142
161
 
143
- def _tokens(value: str, source: str, location: str) -> list[Software]:
162
+ def _tokens(value: str, source: str, location: str, os: str = "") -> list[Software]:
144
163
  found: list[Software] = []
145
164
  for name, version in _TOKEN_RE.findall(value):
146
- found.append(Software(name=name.lower(), version=version, source=source, location=location))
165
+ found.append(
166
+ Software(name=name.lower(), version=version, source=source, location=location, os=os)
167
+ )
147
168
  return found
148
169
 
149
170
 
@@ -159,8 +180,27 @@ def extract_software(headers: Headers, body: str, location: str = "") -> list[So
159
180
 
160
181
  server = headers.get("server")
161
182
  if server:
162
- for item in _tokens(server, "header:server", location):
183
+ server_os = _distro_tag(server)
184
+ server_items = _tokens(server, "header:server", location, os=server_os)
185
+ for item in server_items:
163
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
164
204
  powered = headers.get("x-powered-by")
165
205
  if powered:
166
206
  for item in _tokens(powered, "header:x-powered-by", location):
@@ -181,6 +221,22 @@ def extract_software(headers: Headers, body: str, location: str = "") -> list[So
181
221
  location=location,
182
222
  )
183
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)
184
240
  return software
185
241
 
186
242
 
@@ -193,11 +249,16 @@ def software_from_ports(scan: PortScan | None) -> list[Software]:
193
249
  if not blob:
194
250
  continue
195
251
  location = f"{port.host}:{port.port}" if port.host else f":{port.port}"
252
+ port_os = port.os or _distro_tag(blob)
196
253
  ssh = _SSH_RE.search(blob)
197
254
  if ssh:
198
255
  out.append(
199
256
  Software(
200
- name="openssh", version=ssh.group(1), source="port-banner", location=location
257
+ name="openssh",
258
+ version=ssh.group(1),
259
+ source="port-banner",
260
+ location=location,
261
+ os=port_os,
201
262
  )
202
263
  )
203
264
  continue
@@ -208,6 +269,7 @@ def software_from_ports(scan: PortScan | None) -> list[Software]:
208
269
  version=port.version,
209
270
  source="port-banner",
210
271
  location=location,
272
+ os=port_os,
211
273
  )
212
274
  )
213
275
  return out
@@ -217,20 +279,24 @@ _AUTHORITATIVE = {"header:server", "header:x-powered-by", "port-banner"}
217
279
  _SEVERITY_RANK = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
218
280
 
219
281
 
220
- def _confidence(version: str, rng: dict[str, str], source: str) -> int:
221
- score = 55
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
222
290
  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
291
  bounded = ("start_incl" in rng or "start_excl" in rng) and (
228
292
  "end_incl" in rng or "end_excl" in rng
229
293
  )
230
- score += 12 if bounded else 6
231
- if source in _AUTHORITATIVE:
232
- score += 12
233
- return max(30, min(98, score))
294
+ authoritative = source in _AUTHORITATIVE
295
+ if authoritative and bounded and comps >= 3:
296
+ return 98
297
+ if (authoritative or bounded) and comps >= 2:
298
+ return 91
299
+ return 85
234
300
 
235
301
 
236
302
  @dataclass
@@ -245,6 +311,8 @@ class _CveAgg:
245
311
  url: str | None
246
312
  locations: set[str] = field(default_factory=set[str])
247
313
  sources: set[str] = field(default_factory=set[str])
314
+ unconfirmed: bool = False
315
+ caveat: str = ""
248
316
 
249
317
 
250
318
  def _match_entries(
@@ -256,13 +324,17 @@ def _match_entries(
256
324
  version = item.version
257
325
  if not version:
258
326
  return
327
+ if is_commit_hash(version):
328
+ return
329
+ backported = bool(item.os)
330
+ caveat = "distro backport likely — patchlevel not in banner" if backported else ""
259
331
  for entry in entries:
260
332
  ranges = cast("list[dict[str, str]]", entry.get("ranges") or [])
261
333
  hit_rng = next((r for r in ranges if _in_range(version, r)), None)
262
334
  if hit_rng is None:
263
335
  continue
264
336
  cve_id = str(entry["id"])
265
- confidence = _confidence(version, hit_rng, item.source)
337
+ confidence = _confidence(version, hit_rng, item.source, backported=backported)
266
338
  record = agg.get(cve_id)
267
339
  if record is None:
268
340
  record = _CveAgg(
@@ -274,13 +346,20 @@ def _match_entries(
274
346
  confidence=confidence,
275
347
  summary=str(entry.get("summary", "")),
276
348
  url=entry.get("url"),
349
+ unconfirmed=backported,
350
+ caveat=caveat,
277
351
  )
278
352
  agg[cve_id] = record
279
353
  if item.location:
280
354
  record.locations.add(item.location)
281
355
  if item.source:
282
356
  record.sources.add(item.source)
283
- if confidence > record.confidence:
357
+ if backported:
358
+ record.unconfirmed = True
359
+ record.caveat = caveat
360
+ if confidence < record.confidence:
361
+ record.confidence = confidence
362
+ elif confidence > record.confidence:
284
363
  record.confidence = confidence
285
364
  record.version = version
286
365
 
@@ -298,6 +377,8 @@ def _agg_to_matches(agg: dict[str, _CveAgg]) -> list[CveMatch]:
298
377
  url=r.url,
299
378
  locations=tuple(sorted(r.locations)),
300
379
  sources=tuple(sorted(r.sources)),
380
+ unconfirmed=r.unconfirmed,
381
+ caveat=r.caveat,
301
382
  )
302
383
  for r in agg.values()
303
384
  ]
@@ -331,7 +412,7 @@ def merge_cve_matches(offline: list[CveMatch], online: list[CveMatch]) -> list[C
331
412
  return _sort_matches(list(by_id.values()))
332
413
 
333
414
 
334
- def match_cves(software: list[Software]) -> list[CveMatch]:
415
+ def match_cves(software: list[Software], *, min_confidence: int = 0) -> list[CveMatch]:
335
416
  db = load_cve_db()
336
417
  agg: dict[str, _CveAgg] = {}
337
418
  for item in software:
@@ -341,7 +422,10 @@ def match_cves(software: list[Software]) -> list[CveMatch]:
341
422
  entries = db.get(product_key)
342
423
  if entries:
343
424
  _match_entries(item, product_key, entries, agg)
344
- 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
345
429
 
346
430
 
347
431
  _NVD_URL = "https://services.nvd.nist.gov/rest/json/cves/2.0"
@@ -418,7 +502,7 @@ def _nvd_summary(descriptions: list[dict[str, str]]) -> str:
418
502
 
419
503
 
420
504
  async def match_cves_online(
421
- 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
422
506
  ) -> list[CveMatch]:
423
507
  import aiohttp
424
508
 
@@ -455,4 +539,7 @@ async def match_cves_online(
455
539
  for (product_key, item), entries in zip(products.items(), gathered, strict=True):
456
540
  if entries:
457
541
  _match_entries(item, product_key, entries, agg)
458
- 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