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/__init__.py +8 -0
- phpwarden/cli.py +448 -0
- phpwarden/data/php-cve-feed.json +427709 -0
- phpwarden/deps.py +283 -0
- phpwarden/detect.py +194 -0
- phpwarden/engine.py +137 -0
- phpwarden/env.py +82 -0
- phpwarden/feed.py +213 -0
- phpwarden/guard.py +792 -0
- phpwarden/report.py +206 -0
- phpwarden/rules.py +152 -0
- phpwarden/version.py +110 -0
- phpwarden/vulndb.py +186 -0
- phpwarden/wordpress.py +155 -0
- phpwarden-2.0.0.dist-info/METADATA +177 -0
- phpwarden-2.0.0.dist-info/RECORD +20 -0
- phpwarden-2.0.0.dist-info/WHEEL +5 -0
- phpwarden-2.0.0.dist-info/entry_points.txt +3 -0
- phpwarden-2.0.0.dist-info/licenses/LICENSE +202 -0
- phpwarden-2.0.0.dist-info/top_level.txt +1 -0
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
|
+
}
|