phpwarden 2.0.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.
phpwarden/deps.py ADDED
@@ -0,0 +1,283 @@
1
+ """Composer dependency scanning — the Snyk-gap closer for PHP shops.
2
+
3
+ Reads a composer.lock (exact pinned versions) and matches every package against
4
+ the feed's Composer advisories, using composer-style version constraints
5
+ (comma = AND, pipe = OR, operators >= > <= < =).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import List, Optional
14
+
15
+ from phpwarden import version as V
16
+ from phpwarden.vulndb import ComposerAdvisory, Feed
17
+
18
+ # One comparator: optional operator, then a version or a wildcard (11.0.*, 1.x, *).
19
+ _TOKEN = re.compile(r"(>=|<=|==|!=|=|>|<)?\s*([0-9][0-9A-Za-z.\-+_*]*|\*)")
20
+ _WILD = re.compile(r"^(\d+(?:\.\d+)*)\.(?:\*|x|X)$")
21
+
22
+
23
+ def _satisfies_one(ver_tuple, comparator: str) -> bool:
24
+ comparator = comparator.strip()
25
+ if comparator in ("*", "x", "X"):
26
+ return True
27
+ m = _TOKEN.fullmatch(comparator)
28
+ if not m:
29
+ return False
30
+ op, rhs_s = (m.group(1) or "="), m.group(2)
31
+ w = _WILD.match(rhs_s)
32
+ if w:
33
+ # "11.0.*" means >=11.0,<11.1 regardless of any operator in front of it
34
+ lower = V.parse_loose(w.group(1))
35
+ upper = lower[:-1] + (lower[-1] + 1,)
36
+ return V.compare_loose(ver_tuple, lower) >= 0 and V.compare_loose(ver_tuple, upper) < 0
37
+ rhs = V.parse_loose(rhs_s)
38
+ if rhs is None:
39
+ return False
40
+ c = V.compare_loose(ver_tuple, rhs)
41
+ return {">=": c >= 0, "<=": c <= 0, ">": c > 0, "<": c < 0,
42
+ "=": c == 0, "==": c == 0, "!=": c != 0}[op]
43
+
44
+
45
+ def _comparators(group: str):
46
+ """Comparators of one AND-group. Composer joins them with ',' OR whitespace
47
+ (">=6.3.0 <6.3.1" is common in advisories), and allows ">= 1.0" with a space,
48
+ so tokenise instead of splitting on a single separator."""
49
+ return [m.group(0).strip() for m in _TOKEN.finditer(group) if m.group(0).strip()]
50
+
51
+
52
+ def constraint_matches(ver_str: str, affected: str) -> bool:
53
+ """True if version string `ver_str` satisfies any OR-group of `affected`.
54
+
55
+ Uses loose dotted-version comparison so 2-, 3- and 4-segment ecosystem
56
+ versions (Composer, WordPress) all work; "|" or "||" separates OR-groups.
57
+ """
58
+ vt = V.parse_loose(ver_str)
59
+ if vt is None:
60
+ return False
61
+ for group in re.split(r"\|\|?", affected):
62
+ comps = _comparators(group)
63
+ if comps and all(_satisfies_one(vt, c) for c in comps):
64
+ return True
65
+ return False
66
+
67
+
68
+ @dataclass
69
+ class DepFinding:
70
+ package: str
71
+ installed: str
72
+ advisory: ComposerAdvisory
73
+ fixed: List[str]
74
+
75
+
76
+ def parse_composer_lock(path) -> List[tuple]:
77
+ """Return [(package, version), ...] from a composer.lock."""
78
+ try:
79
+ doc = json.loads(Path(path).read_text())
80
+ except (OSError, ValueError):
81
+ return []
82
+ out = []
83
+ for section in ("packages", "packages-dev"):
84
+ for pkg in doc.get(section, []) or []:
85
+ name = pkg.get("name")
86
+ ver = (pkg.get("version") or "").lstrip("v")
87
+ if name and ver:
88
+ out.append((name, ver))
89
+ return out
90
+
91
+
92
+ # --- reachability: is a vulnerable package actually used in your code? ------- #
93
+ # Dependency scanners flag every CVE in the lockfile — most of the noise is in
94
+ # transitive or dev-only packages you never call. We read each package's declared
95
+ # PSR-4/PSR-0 namespaces from the lock and check whether the project's own source
96
+ # (comments stripped) references them. The result feeds the ranking that
97
+ # `rank_findings` applies and that the CLI / dashboard order follows:
98
+ #
99
+ # 1. known_exploited (KEV) first
100
+ # 2. reach: used > unknown > unreferenced > dev-only
101
+ # 3. EPSS descending
102
+ # 4. CVSS descending
103
+ #
104
+ # "unknown" ranks ABOVE "unreferenced" on purpose: a package we could not check
105
+ # (no autoload namespace, or no project source to scan) must not be demoted as
106
+ # if we had proven it unused. When the project directory does not exist or has
107
+ # no PHP sources, every package is "unknown".
108
+
109
+ REACH_ORDER = {"used": 0, "unknown": 1, "unreferenced": 2, "dev-only": 3}
110
+ _SEV_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3}
111
+
112
+
113
+ def strip_php_comments(src: str) -> str:
114
+ """Remove //, # and /* */ comments (docblocks included) from PHP source,
115
+ leaving string literal contents untouched so `'// not a comment'` survives.
116
+ Comment bodies are replaced by a single space so tokens never fuse."""
117
+ out = []
118
+ i, n = 0, len(src)
119
+ while i < n:
120
+ c = src[i]
121
+ if c in "'\"": # string literal: copy verbatim
122
+ quote = c
123
+ j = i + 1
124
+ while j < n:
125
+ if src[j] == "\\":
126
+ j += 2
127
+ continue
128
+ if src[j] == quote:
129
+ break
130
+ j += 1
131
+ out.append(src[i:j + 1])
132
+ i = j + 1
133
+ elif c == "/" and i + 1 < n and src[i + 1] == "*":
134
+ j = src.find("*/", i + 2)
135
+ i = n if j < 0 else j + 2
136
+ out.append(" ")
137
+ elif (c == "#" and not (i + 1 < n and src[i + 1] == "[")) or \
138
+ (c == "/" and i + 1 < n and src[i + 1] == "/"):
139
+ # `#[Attr]` is a PHP 8 attribute, not a comment — keep it
140
+ j = src.find("\n", i)
141
+ i = n if j < 0 else j # keep the newline itself
142
+ out.append(" ")
143
+ else:
144
+ out.append(c)
145
+ i += 1
146
+ return "".join(out)
147
+
148
+
149
+ def _package_meta(lock_doc: dict) -> dict:
150
+ """{package: {"ns": [namespace prefixes], "dev": bool}} from a composer.lock."""
151
+ meta = {}
152
+ for section in ("packages", "packages-dev"):
153
+ for pkg in lock_doc.get(section, []) or []:
154
+ name = pkg.get("name")
155
+ if not name:
156
+ continue
157
+ ns = set()
158
+ au = pkg.get("autoload", {}) or {}
159
+ for key in ("psr-4", "psr-0"):
160
+ for prefix in (au.get(key, {}) or {}).keys():
161
+ p = prefix.rstrip("\\")
162
+ if p:
163
+ ns.add(p)
164
+ meta[name] = {"ns": sorted(ns), "dev": section == "packages-dev"}
165
+ return meta
166
+
167
+
168
+ def _project_source(project_dir, max_files=4000, max_bytes=24_000_000):
169
+ """(concatenated comment-stripped PHP source, file count) for the project's
170
+ own code, excluding vendor/ and caches. A missing directory yields ("", 0)."""
171
+ import os
172
+ skip = {"vendor", "node_modules", ".git", "var", "cache", "storage", "tests", "test"}
173
+ buf, nbytes, nfiles = [], 0, 0
174
+ if not Path(project_dir).is_dir():
175
+ return "", 0
176
+ for root, dirs, files in os.walk(project_dir):
177
+ dirs[:] = [d for d in dirs if d.lower() not in skip and not d.startswith(".")]
178
+ for fn in files:
179
+ if not fn.endswith((".php", ".inc", ".phtml")):
180
+ continue
181
+ try:
182
+ data = Path(root, fn).read_text(errors="ignore")
183
+ except OSError:
184
+ continue
185
+ buf.append(strip_php_comments(data))
186
+ buf.append("\n")
187
+ nbytes += len(data)
188
+ nfiles += 1
189
+ if nfiles >= max_files or nbytes >= max_bytes:
190
+ return "".join(buf), nfiles
191
+ return "".join(buf), nfiles
192
+
193
+
194
+ def analyze_reachability(lock_path, project_dir) -> dict:
195
+ """{package: reach} with reach in used | dev-only | unreferenced | unknown.
196
+
197
+ Every package is "unknown" when there is nothing to scan (the project dir is
198
+ missing or has no PHP sources): absence of evidence is not "unreferenced".
199
+ Namespaces that appear only inside comments do not count as used."""
200
+ try:
201
+ doc = json.loads(Path(lock_path).read_text())
202
+ except (OSError, ValueError):
203
+ return {}
204
+ meta = _package_meta(doc)
205
+ src, nfiles = _project_source(project_dir)
206
+ if nfiles == 0:
207
+ return {name: "unknown" for name in meta}
208
+ out = {}
209
+ for name, m in meta.items():
210
+ if not m["ns"]:
211
+ out[name] = "unknown" # no autoload namespace to look for
212
+ continue
213
+ used = any((ns + "\\") in src for ns in m["ns"])
214
+ out[name] = "used" if used else ("dev-only" if m["dev"] else "unreferenced")
215
+ return out
216
+
217
+
218
+ def rank_findings(findings: List[DepFinding], reach: dict = None) -> List[DepFinding]:
219
+ """Sort findings by the documented order: KEV first, then reach
220
+ (used > unknown > unreferenced > dev-only), then EPSS desc, then CVSS desc.
221
+ Packages absent from `reach` count as "unknown"."""
222
+ reach = reach or {}
223
+ return sorted(findings, key=lambda f: (
224
+ 0 if f.advisory.known_exploited else 1,
225
+ REACH_ORDER.get(reach.get(f.package, "unknown"), REACH_ORDER["unknown"]),
226
+ -(f.advisory.epss or 0),
227
+ -(f.advisory.cvss or 0),
228
+ _SEV_ORDER.get(f.advisory.severity, 9)))
229
+
230
+
231
+ def _installed_from_doc(doc: dict) -> List[tuple]:
232
+ out = []
233
+ for section in ("packages", "packages-dev"):
234
+ for pkg in doc.get(section, []) or []:
235
+ name = pkg.get("name")
236
+ ver = (pkg.get("version") or "").lstrip("v")
237
+ if name and ver:
238
+ out.append((name, ver))
239
+ return out
240
+
241
+
242
+ def _scan_installed(installed, feed: Feed) -> List[DepFinding]:
243
+ by_pkg = {}
244
+ for adv in feed.composer:
245
+ by_pkg.setdefault(adv.package, []).append(adv)
246
+ findings = []
247
+ for name, ver_str in installed:
248
+ if V.parse_loose(ver_str) is None:
249
+ continue
250
+ for adv in by_pkg.get(name, []):
251
+ if constraint_matches(ver_str, adv.affected):
252
+ findings.append(DepFinding(name, ver_str, adv, adv.fixed))
253
+ sev = {"critical": 0, "high": 1, "medium": 2, "low": 3}
254
+ findings.sort(key=lambda f: (
255
+ 0 if f.advisory.known_exploited else 1,
256
+ -(f.advisory.epss or 0),
257
+ sev.get(f.advisory.severity, 9), -f.advisory.cvss))
258
+ return findings
259
+
260
+
261
+ def scan_lock_doc(doc: dict, feed: Feed) -> List[DepFinding]:
262
+ """Scan an already-parsed composer.lock dict (e.g. pasted into the web UI)."""
263
+ return _scan_installed(_installed_from_doc(doc), feed)
264
+
265
+
266
+ def scan_lock(path, feed: Feed) -> List[DepFinding]:
267
+ installed = parse_composer_lock(path)
268
+ by_pkg = {}
269
+ for adv in feed.composer:
270
+ by_pkg.setdefault(adv.package, []).append(adv)
271
+ findings = []
272
+ for name, ver_str in installed:
273
+ if V.parse_loose(ver_str) is None:
274
+ continue
275
+ for adv in by_pkg.get(name, []):
276
+ if constraint_matches(ver_str, adv.affected):
277
+ findings.append(DepFinding(name, ver_str, adv, adv.fixed))
278
+ sev = {"critical": 0, "high": 1, "medium": 2, "low": 3}
279
+ findings.sort(key=lambda f: (
280
+ 0 if f.advisory.known_exploited else 1, # actively exploited first
281
+ -(f.advisory.epss or 0), # then by exploitation probability
282
+ sev.get(f.advisory.severity, 9), -f.advisory.cvss))
283
+ return findings
phpwarden/detect.py ADDED
@@ -0,0 +1,194 @@
1
+ """Signature detection: scan logs for the marks of attempted exploitation.
2
+
3
+ Each CVE in the feed may carry detectors. This module handles the stateless
4
+ ones — request-signature (an exploit marker in an access-log line) and
5
+ response-signature (a leak marker in a response). Each detector declares the
6
+ stream it applies to ("access" or "response"), so scanning an access log never
7
+ fires a response-only signature (which would false-positive on normal logging).
8
+
9
+ Matching is done against the REQUEST TARGET (path + query) of a parsed
10
+ access-log line, not the whole line, so a Referer or User-Agent never triggers
11
+ a hit. Three pattern classes are told apart because naive substring matching
12
+ flagged ordinary traffic:
13
+
14
+ * "%xx" single-byte patterns (e.g. "%ad" for CVE-2024-4577, "%0a" for
15
+ CVE-2019-11043): the percent-decoded bytes of the part they appear in must
16
+ be INVALID UTF-8 (a lone 0xAD) or a control byte in the PATH. The "%ad"
17
+ inside the valid UTF-8 "%C3%AD" ("í") is not an exploit, and "%0A" in a
18
+ search query is form input, not a PATH_INFO injection.
19
+ * bare ini-directive words ("allow_url_include", "auto_prepend_file"): only
20
+ when used as an assignment ("...=" / "...%3d"), which is how the php-cgi
21
+ argument-injection exploits pass them. A blog title or "#allow_url_include"
22
+ anchor in a docs URL is not.
23
+ * anything else ("php://input", "/cgi-bin/..."): plain substring.
24
+
25
+ Raw bytes that nginx/apache log as "\\xAD" are normalised to "%ad" first, so the
26
+ unencoded variant of an exploit is caught too. One Hit is produced per CVE per
27
+ line, listing every pattern that matched.
28
+
29
+ The stateful worker-corruption detector (OOM on a worker, then that worker
30
+ reused) is the job of the continuous guard daemon and is referenced here, not
31
+ reimplemented.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import re
36
+ from dataclasses import dataclass
37
+ from datetime import datetime
38
+ from pathlib import Path
39
+ from typing import List, Optional
40
+
41
+ from phpwarden.vulndb import Feed
42
+
43
+ _KIND_STREAM = {"request-signature": "access", "response-signature": "response"}
44
+
45
+ # Common/Combined access-log line: IP - - [time] "request" status size "ref" "ua"
46
+ # An optional leading "vhost:port " prefix (nginx/apache vhost_combined) is accepted.
47
+ _ACCESS_RE = re.compile(
48
+ r'^(?:[\w.\-]+(?::\d+)?\s+)?(?P<ip>\d[\d.]*|[0-9a-fA-F:]+)\s+\S+\s+\S+\s+\[(?P<when>[^\]]+)\]\s+'
49
+ r'"(?P<req>[^"]*)"\s+(?P<status>\d{3})'
50
+ r'(?:\s+\S+(?:\s+"[^"]*"\s+"(?P<ua>[^"]*)")?)?')
51
+
52
+ _HEXESC = re.compile(r"\\x([0-9a-fA-F]{2})") # how servers log raw bytes
53
+ _PCT = re.compile(r"%([0-9a-fA-F]{2})")
54
+ _PCT_BYTE = re.compile(r"^%[0-9a-f]{2}$")
55
+ _WORD = re.compile(r"^[a-z_][a-z0-9_]*$")
56
+
57
+
58
+ def parse_access_line(line: str) -> dict:
59
+ """Best-effort structured fields from a common/combined access-log line."""
60
+ m = _ACCESS_RE.match(line.strip())
61
+ if not m:
62
+ return {}
63
+ out = {k: (m.group(k) or "") for k in ("ip", "when", "req", "status", "ua")}
64
+ try:
65
+ out["log_ts"] = int(datetime.strptime(out["when"], "%d/%b/%Y:%H:%M:%S %z").timestamp())
66
+ except (ValueError, OverflowError):
67
+ out["log_ts"] = None
68
+ return out
69
+
70
+
71
+ def _normalize(s: str) -> str:
72
+ return _HEXESC.sub(lambda m: "%" + m.group(1), s).lower()
73
+
74
+
75
+ def _decoded(s: str) -> bytes:
76
+ return _PCT.sub(lambda m: chr(int(m.group(1), 16)), s).encode("latin-1", "replace")
77
+
78
+
79
+ def _valid_utf8(b: bytes) -> bool:
80
+ try:
81
+ b.decode("utf-8")
82
+ return True
83
+ except UnicodeDecodeError:
84
+ return False
85
+
86
+
87
+ def _target(req: str):
88
+ """("/path", "query") from 'GET /path?q=1 HTTP/1.1' (or the raw string)."""
89
+ parts = req.split(" ")
90
+ tgt = parts[1] if len(parts) >= 2 and parts[1].startswith("/") else req
91
+ path, _, query = tgt.partition("?")
92
+ return path, query
93
+
94
+
95
+ def _pattern_hits(pat: str, path: str, query: str, whole: str) -> bool:
96
+ """Does one (lower-cased, normalised) pattern fire on this request?"""
97
+ if _PCT_BYTE.match(pat):
98
+ byte = int(pat[1:], 16)
99
+ if pat in path and (byte < 0x20 or not _valid_utf8(_decoded(path))):
100
+ return True
101
+ if pat in query and not _valid_utf8(_decoded(query)):
102
+ return True
103
+ return False
104
+ if _WORD.match(pat):
105
+ return re.search(re.escape(pat) + r"\s*(=|%3d)", whole) is not None
106
+ return pat in whole
107
+
108
+
109
+ @dataclass
110
+ class Hit:
111
+ cve_id: str
112
+ kind: str
113
+ pattern: str
114
+ line_no: int
115
+ excerpt: str
116
+ note: str
117
+ ip: str = ""
118
+ when: str = ""
119
+ request: str = ""
120
+ status: str = ""
121
+ ua: str = ""
122
+ log_ts: Optional[int] = None
123
+
124
+
125
+ class SignatureScanner:
126
+ def __init__(self, feed: Feed):
127
+ self.sigs = [] # (cve_id, kind, stream, pattern, note)
128
+ seen = set()
129
+ for cve in feed.cves:
130
+ for det in cve.detectors:
131
+ kind = det.get("kind")
132
+ if kind not in _KIND_STREAM:
133
+ continue
134
+ stream = det.get("on", _KIND_STREAM[kind])
135
+ for pat in det.get("patterns", []):
136
+ key = (cve.id, kind, pat.lower())
137
+ if key in seen:
138
+ continue
139
+ seen.add(key)
140
+ self.sigs.append((cve.id, kind, stream, pat, det.get("note", "")))
141
+ self.worker_corruption_cves = [
142
+ c.id for c in feed.cves if "worker-corruption" in c.detector_kinds()]
143
+ # set by scan_file
144
+ self.scanned_lines = 0
145
+ self.total_hits = 0
146
+ self.truncated = False
147
+
148
+ def scan_line(self, line: str, stream: str = "access", line_no: int = 0) -> List[Hit]:
149
+ parsed = parse_access_line(line) if stream == "access" else {}
150
+ if parsed.get("req"):
151
+ path, query = _target(_normalize(parsed["req"]))
152
+ else:
153
+ # not a recognisable access line (raw request dump, other formats):
154
+ # fall back to the whole line, treated as a path
155
+ path, query = _normalize(line.strip()), ""
156
+ whole = path + ("?" + query if query else "")
157
+ per_cve = {} # cve_id -> (kind, [patterns], note)
158
+ for cve_id, kind, sig_stream, pat, note in self.sigs:
159
+ if sig_stream != stream:
160
+ continue
161
+ if _pattern_hits(_normalize(pat), path, query, whole):
162
+ entry = per_cve.setdefault(cve_id, (kind, [], note))
163
+ entry[1].append(pat)
164
+ hits = []
165
+ for cve_id, (kind, pats, note) in per_cve.items():
166
+ hits.append(Hit(cve_id, kind, ", ".join(pats), line_no, line.strip()[:300], note,
167
+ ip=parsed.get("ip", ""), when=parsed.get("when", ""),
168
+ request=parsed.get("req", ""), status=parsed.get("status", ""),
169
+ ua=parsed.get("ua", ""), log_ts=parsed.get("log_ts")))
170
+ return hits
171
+
172
+ def scan_file(self, path, stream: str = "access", max_hits: int = 500) -> List[Hit]:
173
+ """Scan a whole log. Keeps at most `max_hits` Hit objects but scans EVERY
174
+ line (so total_hits is accurate and nothing is silently skipped); sets
175
+ self.truncated when more hits existed than were kept. Raises
176
+ FileNotFoundError / IsADirectoryError / OSError for unusable input —
177
+ callers must not report a missing file as "clean"."""
178
+ p = Path(path)
179
+ if p.is_dir():
180
+ raise IsADirectoryError(f"{path} is a directory, not a log file")
181
+ if not p.is_file():
182
+ raise FileNotFoundError(f"log file not found: {path}")
183
+ out = []
184
+ self.scanned_lines = self.total_hits = 0
185
+ with p.open(errors="replace") as fh:
186
+ for i, line in enumerate(fh, 1):
187
+ self.scanned_lines = i
188
+ hits = self.scan_line(line, stream, i)
189
+ self.total_hits += len(hits)
190
+ room = max_hits - len(out)
191
+ if room > 0:
192
+ out.extend(hits[:room])
193
+ self.truncated = self.total_hits > len(out)
194
+ return out
phpwarden/engine.py ADDED
@@ -0,0 +1,137 @@
1
+ """Exposure engine: assess a PHP environment against the whole CVE feed.
2
+
3
+ This is the deterministic backbone — given what we know about a host (PHP
4
+ version, which SAPI, OS, and any confirmed config facts), it returns one finding
5
+ per applicable CVE with a calibrated confidence. It never guesses a version is
6
+ safe: unknowns stay unknown.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import List, Optional, Set
12
+
13
+ from phpwarden import version as V
14
+ from phpwarden.vulndb import CVE, Feed
15
+
16
+
17
+ @dataclass
18
+ class Environment:
19
+ version: Optional[V.Version] = None
20
+ sapi: Optional[str] = None # "fpm" | "cgi" | "cli" | None(unknown)
21
+ os: Optional[str] = None # "linux" | "windows" | None(unknown)
22
+ arch: Optional[str] = None # "32" | "64" | None
23
+ config_true: Set[str] = field(default_factory=set) # confirmed-true exposure checks
24
+
25
+ @classmethod
26
+ def from_version_string(cls, text, **kw):
27
+ return cls(version=V.parse(text), **kw)
28
+
29
+
30
+ @dataclass
31
+ class Finding:
32
+ cve: CVE
33
+ verdict: str # EXPOSED | POSSIBLY_EXPOSED | CLEARED
34
+ confidence: str # high | medium | low
35
+ reasons: List[str]
36
+ fixed: Optional[str]
37
+
38
+ @property
39
+ def exposed(self):
40
+ return self.verdict in ("EXPOSED", "POSSIBLY_EXPOSED")
41
+
42
+
43
+ def _sapi_match(cve: CVE, env: Environment):
44
+ """(matches, confident). 'any' always matches with confidence."""
45
+ if cve.sapi == ["any"]:
46
+ return True, True
47
+ if env.sapi is None:
48
+ return True, False # could match; we can't confirm
49
+ return (env.sapi in cve.sapi), True
50
+
51
+
52
+ def _os_match(cve: CVE, env: Environment):
53
+ if cve.os == ["any"]:
54
+ return True, True
55
+ if env.os is None:
56
+ return True, False
57
+ return (env.os in cve.os), True
58
+
59
+
60
+ def assess_one(cve: CVE, env: Environment) -> Finding:
61
+ fixed = cve.fixed_for(env.version) if env.version else None
62
+ if env.version is None:
63
+ # Version unknown — but a CVE that needs a SAPI or OS we KNOW this host
64
+ # doesn't have is still ruled out (a Windows/CGI-only bug on linux/cli).
65
+ sapi_ok, sapi_conf = _sapi_match(cve, env)
66
+ os_ok, os_conf = _os_match(cve, env)
67
+ if (sapi_conf and not sapi_ok) or (os_conf and not os_ok):
68
+ why = ("SAPI not in the vulnerable set" if (sapi_conf and not sapi_ok)
69
+ else "OS not in the vulnerable set")
70
+ return Finding(cve, "CLEARED", "medium", [why + " (PHP version unknown)"], fixed)
71
+ return Finding(cve, "POSSIBLY_EXPOSED", "low",
72
+ ["PHP version unknown; cannot rule this out"], fixed)
73
+
74
+ vs = cve.version_status(env.version)
75
+ if vs in ("PATCHED", "NOT_APPLICABLE"):
76
+ reason = ("at or above the fixed release on this branch" if vs == "PATCHED"
77
+ else "this PHP branch is not in the vulnerable set")
78
+ return Finding(cve, "CLEARED", "high", [reason], fixed)
79
+
80
+ reasons = []
81
+ if vs == "EOL_AFFECTED":
82
+ reasons.append("PHP branch is end-of-life with no upstream fix for this CVE")
83
+ else:
84
+ reasons.append(f"version is below the fixed release {fixed} on this branch")
85
+
86
+ sapi_ok, sapi_conf = _sapi_match(cve, env)
87
+ os_ok, os_conf = _os_match(cve, env)
88
+ if not sapi_ok:
89
+ return Finding(cve, "CLEARED", "high",
90
+ [f"this CVE affects only {'/'.join(cve.sapi)} SAPI; host runs {env.sapi}"], fixed)
91
+ if not os_ok:
92
+ return Finding(cve, "CLEARED", "high",
93
+ [f"this CVE affects only {'/'.join(cve.os)}; host runs {env.os}"], fixed)
94
+
95
+ confident = sapi_conf and os_conf
96
+ if cve.sapi != ["any"] and not sapi_conf:
97
+ reasons.append(f"applies only to {'/'.join(cve.sapi)} SAPI (not confirmed on this host)")
98
+ if cve.os != ["any"] and not os_conf:
99
+ reasons.append(f"applies only on {'/'.join(cve.os)} (not confirmed on this host)")
100
+
101
+ # Config-gated CVEs: note unconfirmed conditions, downgrade confidence.
102
+ unconfirmed_conf = [c for c in cve.exposure if c.get("check") not in env.config_true]
103
+ if unconfirmed_conf:
104
+ for c in unconfirmed_conf:
105
+ reasons.append("conditional: " + c.get("desc", c.get("check", "")))
106
+
107
+ if confident and not unconfirmed_conf:
108
+ return Finding(cve, "EXPOSED", "high", reasons, fixed)
109
+ verdict = "POSSIBLY_EXPOSED"
110
+ conf = "medium" if confident or not unconfirmed_conf else "low"
111
+ return Finding(cve, verdict, conf, reasons, fixed)
112
+
113
+
114
+ def assess(env: Environment, feed: Feed) -> List[Finding]:
115
+ """Assess every CVE; return findings sorted worst-first."""
116
+ findings = [assess_one(c, env) for c in feed.cves]
117
+ sev = {"critical": 0, "high": 1, "medium": 2, "low": 3, "unknown": 4}
118
+ verd = {"EXPOSED": 0, "POSSIBLY_EXPOSED": 1, "CLEARED": 2}
119
+ findings.sort(key=lambda f: (
120
+ verd[f.verdict],
121
+ 0 if f.cve.exploited_in_wild else 1,
122
+ -(f.cve.epss or 0),
123
+ sev.get(f.cve.severity, 9), -f.cve.cvss))
124
+ return findings
125
+
126
+
127
+ def summarize(findings: List[Finding]) -> dict:
128
+ exposed = [f for f in findings if f.verdict == "EXPOSED"]
129
+ possible = [f for f in findings if f.verdict == "POSSIBLY_EXPOSED"]
130
+ return {
131
+ "exposed": len(exposed),
132
+ "possibly_exposed": len(possible),
133
+ "cleared": sum(1 for f in findings if f.verdict == "CLEARED"),
134
+ "worst_severity": exposed[0].cve.severity if exposed else (
135
+ possible[0].cve.severity if possible else "none"),
136
+ "exploited_in_wild": sorted({f.cve.id for f in exposed + possible if f.cve.exploited_in_wild}),
137
+ }