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/report.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Fleet dashboard — aggregate per-host JSON results into one HTML report.
|
|
2
|
+
|
|
3
|
+
Reads the JSON emitted by `scan`, `deps` and `wordpress` (any mix), normalises
|
|
4
|
+
every finding into one row, and renders a self-contained HTML page: summary
|
|
5
|
+
tiles plus a severity-sorted table. This is the CNAPP bridge — a fleet-wide
|
|
6
|
+
view of PHP exposure over time — not a cloud platform.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import html
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import List
|
|
16
|
+
|
|
17
|
+
_SEV_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "unknown": 4}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Row:
|
|
22
|
+
host: str
|
|
23
|
+
source: str
|
|
24
|
+
ident: str
|
|
25
|
+
severity: str
|
|
26
|
+
cvss: float
|
|
27
|
+
status: str
|
|
28
|
+
title: str
|
|
29
|
+
fixed: str
|
|
30
|
+
wild: bool
|
|
31
|
+
epss: float = None
|
|
32
|
+
reach: str = ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _rows_from_doc(host: str, doc: dict) -> List[Row]:
|
|
36
|
+
"""One Row per finding. Every numeric field goes through `_num` / `_fnum`
|
|
37
|
+
and `fixed` through `_fixed`, for ALL three document types: a `cvss: null`,
|
|
38
|
+
a string epss or a null fixed list in any one finding must never raise."""
|
|
39
|
+
rows = []
|
|
40
|
+
# scan output
|
|
41
|
+
if "findings" in doc and "summary" in doc:
|
|
42
|
+
for f in doc["findings"]:
|
|
43
|
+
rows.append(Row(host, "engine", str(f["cve"]), _sev(f.get("severity")),
|
|
44
|
+
_num(f.get("cvss")), str(f.get("verdict") or ""),
|
|
45
|
+
str(f.get("title") or ""), _fixed(f.get("fixed")),
|
|
46
|
+
bool(f.get("exploited_in_wild")), _fnum(f.get("epss"))))
|
|
47
|
+
# deps output
|
|
48
|
+
if "vulnerable" in doc:
|
|
49
|
+
for f in doc["vulnerable"]:
|
|
50
|
+
rows.append(Row(host, "composer", f"{f['package']} {f.get('installed', '')} / {f['cve']}",
|
|
51
|
+
_sev(f.get("severity")), _num(f.get("cvss")), "EXPOSED",
|
|
52
|
+
str(f.get("title") or ""), _fixed(f.get("fixed")),
|
|
53
|
+
bool(f.get("known_exploited")), _fnum(f.get("epss")),
|
|
54
|
+
str(f.get("reach") or "")))
|
|
55
|
+
# wordpress output
|
|
56
|
+
if "wordpress" in doc and "findings" in doc:
|
|
57
|
+
for f in doc["findings"]:
|
|
58
|
+
if "slug" in f:
|
|
59
|
+
rows.append(Row(host, f"wp-{f.get('kind') or '?'}",
|
|
60
|
+
f"{f['slug']} {f.get('version') or ''} / {f['cve']}",
|
|
61
|
+
_sev(f.get("severity")), _num(f.get("cvss")), "EXPOSED",
|
|
62
|
+
str(f.get("title") or ""), _fixed(f.get("fixed")),
|
|
63
|
+
bool(f.get("known_exploited")), _fnum(f.get("epss"))))
|
|
64
|
+
return rows
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_HOST_SUFFIX = re.compile(r"[-_.](deps|scan|wp|wordpress|detect)$", re.I)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _num(v, default=0.0) -> float:
|
|
71
|
+
"""cvss as a float; None / "" / junk -> default (one bad field must not
|
|
72
|
+
take the whole file out of the report)."""
|
|
73
|
+
try:
|
|
74
|
+
return float(v)
|
|
75
|
+
except (TypeError, ValueError):
|
|
76
|
+
return default
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _fnum(v):
|
|
80
|
+
"""epss as float or None."""
|
|
81
|
+
try:
|
|
82
|
+
return None if v in (None, "") else float(v)
|
|
83
|
+
except (TypeError, ValueError):
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _sev(v) -> str:
|
|
88
|
+
"""severity as a lowercase label; None / "" -> "unknown"."""
|
|
89
|
+
return str(v).lower() if v else "unknown"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _fixed(v) -> str:
|
|
93
|
+
"""fixed-in versions as one display string: a list (deps/wp), a bare string
|
|
94
|
+
(scan), or None / junk -> "-"."""
|
|
95
|
+
if isinstance(v, (list, tuple)):
|
|
96
|
+
return ", ".join(str(x) for x in v if x) or "-"
|
|
97
|
+
return str(v) if v else "-"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _host_of(doc, fp: Path) -> str:
|
|
101
|
+
"""Host label: the doc's own `host`, else the file stem with a tool suffix
|
|
102
|
+
stripped so web1.json + web1-deps.json are ONE host, not two."""
|
|
103
|
+
h = doc.get("host") if isinstance(doc, dict) else None
|
|
104
|
+
return h or _HOST_SUFFIX.sub("", fp.stem)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load_rows(paths, warn=None) -> List[Row]:
|
|
108
|
+
"""Rows from scan/deps/wordpress JSON files. Unreadable or wrong-shape inputs
|
|
109
|
+
are reported through `warn(msg)` (default: stderr) and skipped — never
|
|
110
|
+
silently, and never by crashing the whole report."""
|
|
111
|
+
import sys
|
|
112
|
+
warn = warn or (lambda m: sys.stderr.write(f"report: {m}\n"))
|
|
113
|
+
rows = []
|
|
114
|
+
for p in paths:
|
|
115
|
+
fp = Path(p)
|
|
116
|
+
try:
|
|
117
|
+
doc = json.loads(fp.read_text())
|
|
118
|
+
except OSError as e:
|
|
119
|
+
warn(f"skipping {p}: {e.strerror or e}")
|
|
120
|
+
continue
|
|
121
|
+
except ValueError:
|
|
122
|
+
warn(f"skipping {p}: not valid JSON")
|
|
123
|
+
continue
|
|
124
|
+
if not isinstance(doc, dict):
|
|
125
|
+
warn(f"skipping {p}: not a phpwarden result document")
|
|
126
|
+
continue
|
|
127
|
+
try:
|
|
128
|
+
rows.extend(_rows_from_doc(_host_of(doc, fp), doc))
|
|
129
|
+
except (KeyError, TypeError, ValueError, AttributeError) as e:
|
|
130
|
+
warn(f"skipping {p}: unexpected document shape ({e.__class__.__name__}: {e})")
|
|
131
|
+
continue
|
|
132
|
+
rows.sort(key=lambda r: (0 if r.wild else 1, -(r.epss or 0),
|
|
133
|
+
_SEV_ORDER.get(r.severity, 9), -r.cvss))
|
|
134
|
+
return rows
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def render_html(rows: List[Row], feed_version: str = "") -> str:
|
|
138
|
+
hosts = sorted({r.host for r in rows})
|
|
139
|
+
crit = sum(1 for r in rows if r.severity == "critical")
|
|
140
|
+
high = sum(1 for r in rows if r.severity == "high")
|
|
141
|
+
wild = sum(1 for r in rows if r.wild)
|
|
142
|
+
sev_color = {"critical": "#dc2626", "high": "#ea580c", "medium": "#ca8a04",
|
|
143
|
+
"low": "#65a30d", "unknown": "#6b7280"}
|
|
144
|
+
|
|
145
|
+
def esc(s):
|
|
146
|
+
return html.escape(str(s))
|
|
147
|
+
|
|
148
|
+
def risk_cell(r):
|
|
149
|
+
bits = []
|
|
150
|
+
if r.wild:
|
|
151
|
+
bits.append('<span class="kev">KEV</span>')
|
|
152
|
+
if r.epss is not None:
|
|
153
|
+
bits.append(f'{r.epss * 100:.0f}%')
|
|
154
|
+
return " ".join(bits) or "-"
|
|
155
|
+
|
|
156
|
+
trs = "\n".join(
|
|
157
|
+
f'<tr><td>{esc(r.host)}</td><td><span class="src">{esc(r.source)}</span></td>'
|
|
158
|
+
f'<td class="id">{esc(r.ident)}</td>'
|
|
159
|
+
f'<td><span class="sev" style="background:{sev_color.get(r.severity,"#6b7280")}">'
|
|
160
|
+
f'{esc(r.severity)}</span></td><td>{r.cvss:g}</td>'
|
|
161
|
+
f'<td class="risk">{risk_cell(r)}</td>'
|
|
162
|
+
f'<td>{esc(r.status)}</td>'
|
|
163
|
+
f'<td>{esc(r.title)}</td><td class="fix">{esc(r.fixed)}</td></tr>'
|
|
164
|
+
for r in rows)
|
|
165
|
+
|
|
166
|
+
return f"""<!doctype html>
|
|
167
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
168
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
169
|
+
<title>phpwarden fleet report</title>
|
|
170
|
+
<style>
|
|
171
|
+
:root {{ --bg:#ffffff; --fg:#111827; --muted:#6b7280; --card:#f3f4f6; --border:#e5e7eb; }}
|
|
172
|
+
@media (prefers-color-scheme: dark) {{ :root:not([data-theme=light]) {{
|
|
173
|
+
--bg:#0b0f17; --fg:#e5e7eb; --muted:#9ca3af; --card:#111827; --border:#1f2937; }} }}
|
|
174
|
+
* {{ box-sizing:border-box; }}
|
|
175
|
+
body {{ margin:0; background:var(--bg); color:var(--fg);
|
|
176
|
+
font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; padding:24px 16px; }}
|
|
177
|
+
.wrap {{ max-width:1040px; margin:0 auto; }}
|
|
178
|
+
h1 {{ font-size:22px; margin:0 0 4px; }}
|
|
179
|
+
.sub {{ color:var(--muted); margin:0 0 20px; font-size:13px; }}
|
|
180
|
+
.tiles {{ display:flex; flex-wrap:wrap; gap:12px; margin-bottom:20px; }}
|
|
181
|
+
.tile {{ background:var(--card); border:1px solid var(--border); border-radius:10px;
|
|
182
|
+
padding:14px 18px; min-width:120px; }}
|
|
183
|
+
.tile b {{ display:block; font-size:26px; line-height:1.1; }}
|
|
184
|
+
.tile span {{ color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.04em; }}
|
|
185
|
+
table {{ width:100%; border-collapse:collapse; font-size:13px; }}
|
|
186
|
+
th,td {{ text-align:left; padding:8px 10px; border-bottom:1px solid var(--border); vertical-align:top; }}
|
|
187
|
+
th {{ color:var(--muted); font-weight:600; text-transform:uppercase; font-size:11px; letter-spacing:.04em; }}
|
|
188
|
+
.id {{ font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; }}
|
|
189
|
+
.fix {{ font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; color:var(--muted); }}
|
|
190
|
+
.src {{ background:var(--border); border-radius:5px; padding:1px 7px; font-size:11px; }}
|
|
191
|
+
.sev {{ color:#fff; border-radius:5px; padding:1px 8px; font-size:11px; font-weight:600; }}
|
|
192
|
+
.risk {{ font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; white-space:nowrap; }}
|
|
193
|
+
.kev {{ background:#dc2626; color:#fff; border-radius:5px; padding:1px 6px; font-size:10px; font-weight:700; }}
|
|
194
|
+
.empty {{ color:var(--muted); padding:30px 0; text-align:center; }}
|
|
195
|
+
</style></head><body><div class="wrap">
|
|
196
|
+
<h1>phpwarden fleet report</h1>
|
|
197
|
+
<p class="sub">{len(rows)} findings across {len(hosts)} host(s){' · feed ' + esc(feed_version) if feed_version else ''}</p>
|
|
198
|
+
<div class="tiles">
|
|
199
|
+
<div class="tile"><b>{len(rows)}</b><span>findings</span></div>
|
|
200
|
+
<div class="tile"><b style="color:{sev_color['critical']}">{crit}</b><span>critical</span></div>
|
|
201
|
+
<div class="tile"><b style="color:{sev_color['high']}">{high}</b><span>high</span></div>
|
|
202
|
+
<div class="tile"><b style="color:{sev_color['critical']}">{wild}</b><span>actively exploited (KEV)</span></div>
|
|
203
|
+
<div class="tile"><b>{len(hosts)}</b><span>hosts</span></div>
|
|
204
|
+
</div>
|
|
205
|
+
{"<table><thead><tr><th>Host</th><th>Source</th><th>Finding</th><th>Severity</th><th>CVSS</th><th>Risk</th><th>Status</th><th>Detail</th><th>Fixed in</th></tr></thead><tbody>" + trs + "</tbody></table>" if rows else '<div class="empty">No findings. Every host assessed is clear against the current feed.</div>'}
|
|
206
|
+
</div></body></html>"""
|
phpwarden/rules.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Generate WAF blocking rules from the feed's request signatures.
|
|
2
|
+
|
|
3
|
+
phpwarden does not replace an edge WAF; it supplies the PHP-specific rules a
|
|
4
|
+
generic WAF lacks, in the formats real WAFs enforce (ModSecurity, nginx). The
|
|
5
|
+
same signatures power `phpwarden detect` (observe) and these rules (block).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import List
|
|
11
|
+
|
|
12
|
+
from phpwarden.vulndb import Feed
|
|
13
|
+
|
|
14
|
+
RULE_ID_BASE = 9100000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _request_sigs(feed: Feed):
|
|
18
|
+
"""[(cve_id, pattern, note), ...] for request-signature detectors only."""
|
|
19
|
+
out, seen = [], set()
|
|
20
|
+
for cve in feed.cves:
|
|
21
|
+
for det in cve.detectors:
|
|
22
|
+
if det.get("kind") != "request-signature":
|
|
23
|
+
continue
|
|
24
|
+
for pat in det.get("patterns", []):
|
|
25
|
+
key = (cve.id, pat.lower())
|
|
26
|
+
if key not in seen:
|
|
27
|
+
seen.add(key)
|
|
28
|
+
out.append((cve.id, pat, det.get("note", "")))
|
|
29
|
+
return out
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _modsec_lines(entries, rid_base=RULE_ID_BASE):
|
|
33
|
+
"""entries: [(cve_id, pattern, note), ...] -> ModSecurity SecRule lines."""
|
|
34
|
+
out, rid = [], rid_base
|
|
35
|
+
for cve_id, pat, note in entries:
|
|
36
|
+
rx = re.escape(pat)
|
|
37
|
+
# (?i): @rx is case-sensitive and real probes use "%AD"/"%0A"; the
|
|
38
|
+
# signature list is de-duplicated case-insensitively, so without this
|
|
39
|
+
# the upper-case variants were simply never blocked.
|
|
40
|
+
# REQUEST_URI_RAW: ARGS/REQUEST_URI are URL-decoded by the engine, so a
|
|
41
|
+
# percent-encoded pattern can only ever match the raw URI.
|
|
42
|
+
safe_note = note.replace("'", "`").replace('"', "`")
|
|
43
|
+
out.append(
|
|
44
|
+
f'SecRule REQUEST_URI_RAW|REQUEST_URI|REQUEST_HEADERS:Content-Type|ARGS '
|
|
45
|
+
f'"@rx (?i){rx}" '
|
|
46
|
+
f'"id:{rid},phase:2,deny,status:403,'
|
|
47
|
+
f'msg:\'phpwarden: {cve_id} exploitation signature\','
|
|
48
|
+
f'logdata:\'{safe_note}\',tag:\'phpwarden\',tag:\'{cve_id}\'"')
|
|
49
|
+
rid += 1
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _nginx_lines(entries):
|
|
54
|
+
out = []
|
|
55
|
+
for cve_id, pat, note in entries:
|
|
56
|
+
rx = re.escape(pat)
|
|
57
|
+
out.append(f'# {cve_id}: {note}')
|
|
58
|
+
out.append(f'if ($request_uri ~* "{rx}") {{ return 403; }}')
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def modsecurity(feed: Feed) -> str:
|
|
63
|
+
lines = [
|
|
64
|
+
"# phpwarden-generated ModSecurity rules — PHP exploitation signatures.",
|
|
65
|
+
f"# feed {feed.feed_version}. Place in your CRS custom rules; tune IDs to avoid clashes.",
|
|
66
|
+
"",
|
|
67
|
+
]
|
|
68
|
+
lines += _modsec_lines(_request_sigs(feed))
|
|
69
|
+
lines.append("")
|
|
70
|
+
return "\n".join(lines)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def nginx(feed: Feed) -> str:
|
|
74
|
+
lines = [
|
|
75
|
+
"# phpwarden-generated nginx rules — PHP exploitation signatures.",
|
|
76
|
+
f"# feed {feed.feed_version}. Include inside the server/location that proxies to PHP-FPM.",
|
|
77
|
+
"",
|
|
78
|
+
]
|
|
79
|
+
lines += _nginx_lines(_request_sigs(feed))
|
|
80
|
+
lines.append("")
|
|
81
|
+
return "\n".join(lines)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
FORMATS = {"modsecurity": modsecurity, "nginx": nginx}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def generate(feed: Feed, fmt: str) -> str:
|
|
88
|
+
gen = FORMATS.get(fmt)
|
|
89
|
+
if not gen:
|
|
90
|
+
raise ValueError(f"unknown format {fmt!r}; choose from {', '.join(FORMATS)}")
|
|
91
|
+
return gen(feed)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# --- per-CVE virtual patch ("shields-up") ---------------------------------- #
|
|
95
|
+
# Contain a single CVE *before* you can upgrade. The blocking rule is built from
|
|
96
|
+
# (1) the feed's authoritative request-signature for that CVE, and (2) patterns
|
|
97
|
+
# we actually observed probing this host (the attack log) — so the shield blocks
|
|
98
|
+
# exactly what is being thrown at you. When neither exists, we emit a runtime
|
|
99
|
+
# guard policy that recycles a poisoned PHP-FPM worker for that CVE instead.
|
|
100
|
+
|
|
101
|
+
def _cve_entries(feed: Feed, cve_id: str, observed=None):
|
|
102
|
+
"""Blocking entries for one CVE: feed signatures + observed attack patterns."""
|
|
103
|
+
entries, seen = [], set()
|
|
104
|
+
for cid, pat, note in _request_sigs(feed):
|
|
105
|
+
if cid == cve_id and pat.lower() not in seen:
|
|
106
|
+
seen.add(pat.lower())
|
|
107
|
+
entries.append((cid, pat, note or "feed exploitation signature"))
|
|
108
|
+
for pat in (observed or []):
|
|
109
|
+
if pat and pat.lower() not in seen:
|
|
110
|
+
seen.add(pat.lower())
|
|
111
|
+
entries.append((cve_id, pat, "observed in your logs"))
|
|
112
|
+
return entries
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def guard_policy(cve_id: str) -> str:
|
|
116
|
+
"""A phpwarden runtime-guard policy fragment that enforces (recycles a
|
|
117
|
+
poisoned PHP-FPM worker) for this CVE — the containment layer a WAF can't do."""
|
|
118
|
+
return (
|
|
119
|
+
"# phpwarden runtime guard — enforce containment for this CVE.\n"
|
|
120
|
+
"# Add to your guard config and run the guard in ENFORCE mode:\n"
|
|
121
|
+
"# phpwarden-guard --config guard.json --mode ENFORCE\n"
|
|
122
|
+
"{\n"
|
|
123
|
+
' "mode": "ENFORCE",\n'
|
|
124
|
+
f' "enforce_cves": ["{cve_id}"],\n'
|
|
125
|
+
' "action": "recycle_worker"\n'
|
|
126
|
+
"}\n"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def virtual_patch(feed: Feed, cve_id: str, fmt: str = "nginx", observed=None) -> dict:
|
|
131
|
+
"""Build a one-CVE virtual patch. Returns {text, has_waf, kind}."""
|
|
132
|
+
entries = _cve_entries(feed, cve_id, observed)
|
|
133
|
+
header = [
|
|
134
|
+
f"# phpwarden virtual patch — {cve_id}",
|
|
135
|
+
f"# feed {feed.feed_version}. Contain this CVE now; remove once you've upgraded.",
|
|
136
|
+
"",
|
|
137
|
+
]
|
|
138
|
+
if entries:
|
|
139
|
+
body = _nginx_lines(entries) if fmt == "nginx" else _modsec_lines(entries)
|
|
140
|
+
where = ("Include inside the server/location that proxies to PHP-FPM."
|
|
141
|
+
if fmt == "nginx" else "Place in your CRS custom rules; tune IDs to avoid clashes.")
|
|
142
|
+
text = "\n".join(header + [f"# {where}", ""] + body + ["",
|
|
143
|
+
"# Runtime containment (defense in depth):", ""] + guard_policy(cve_id).splitlines())
|
|
144
|
+
return {"text": text + "\n", "has_waf": True, "kind": fmt}
|
|
145
|
+
# no signature and nothing observed yet -> guard policy only
|
|
146
|
+
text = "\n".join(header + [
|
|
147
|
+
"# No request-signature is available for this CVE, and nothing matching it",
|
|
148
|
+
"# has been seen in your logs yet, so an edge WAF rule can't target it precisely.",
|
|
149
|
+
"# Contain it at runtime instead — the guard recycles a poisoned PHP-FPM worker:",
|
|
150
|
+
"",
|
|
151
|
+
] + guard_policy(cve_id).splitlines())
|
|
152
|
+
return {"text": text + "\n", "has_waf": False, "kind": "guard"}
|
phpwarden/version.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""PHP version parsing and branch-aware range matching.
|
|
2
|
+
|
|
3
|
+
A PHP version is (major, minor, patch) plus an optional prerelease marker. The
|
|
4
|
+
"branch" is (major, minor) — PHP backports security fixes per branch, so a CVE
|
|
5
|
+
is expressed as a fixed patch level per branch, not a single global threshold.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import NamedTuple, Optional
|
|
11
|
+
|
|
12
|
+
_VER = re.compile(r"(\d+)\.(\d+)\.(\d+)")
|
|
13
|
+
# Anchored to the start of the text that follows X.Y.Z: "8.3.10RC1", "8.3.10-beta2".
|
|
14
|
+
# Unanchored, "Architecture x64" (contains "rc") marked a patched build prerelease.
|
|
15
|
+
_PRERELEASE = re.compile(r"(?i)^[\s\-~_.]?(alpha|beta|rc|dev|snapshot)(?![a-z])")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Version(NamedTuple):
|
|
19
|
+
major: int
|
|
20
|
+
minor: int
|
|
21
|
+
patch: int
|
|
22
|
+
prerelease: bool = False
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def branch(self):
|
|
26
|
+
return (self.major, self.minor)
|
|
27
|
+
|
|
28
|
+
def __str__(self):
|
|
29
|
+
s = f"{self.major}.{self.minor}.{self.patch}"
|
|
30
|
+
return s + " (prerelease)" if self.prerelease else s
|
|
31
|
+
|
|
32
|
+
def _cmp_key(self):
|
|
33
|
+
# A prerelease of X.Y.Z sorts just below the final X.Y.Z.
|
|
34
|
+
return (self.major, self.minor, self.patch, 0 if self.prerelease else 1)
|
|
35
|
+
|
|
36
|
+
def __lt__(self, other):
|
|
37
|
+
return self._cmp_key() < other._cmp_key()
|
|
38
|
+
|
|
39
|
+
def __ge__(self, other):
|
|
40
|
+
return self._cmp_key() >= other._cmp_key()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse(text: str) -> Optional[Version]:
|
|
44
|
+
"""Extract a Version from any string containing X.Y.Z, or None."""
|
|
45
|
+
if not text:
|
|
46
|
+
return None
|
|
47
|
+
m = _VER.search(text)
|
|
48
|
+
if not m:
|
|
49
|
+
return None
|
|
50
|
+
tail = text[m.end():]
|
|
51
|
+
return Version(int(m.group(1)), int(m.group(2)), int(m.group(3)),
|
|
52
|
+
bool(_PRERELEASE.search(tail)))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_fixed(text: str) -> Optional[Version]:
|
|
56
|
+
"""Parse a fixed-version string (always treated as a final release)."""
|
|
57
|
+
m = _VER.search(text or "")
|
|
58
|
+
if not m:
|
|
59
|
+
return None
|
|
60
|
+
return Version(int(m.group(1)), int(m.group(2)), int(m.group(3)), False)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_below_fixed(ver: Version, fixed: Version) -> bool:
|
|
64
|
+
"""True if `ver` is strictly below the fixed release on the same branch."""
|
|
65
|
+
return ver < fixed
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def compare(a: Version, b: Version) -> int:
|
|
69
|
+
"""-1 if a<b, 0 if equal, 1 if a>b (prerelease-aware)."""
|
|
70
|
+
ka, kb = a._cmp_key(), b._cmp_key()
|
|
71
|
+
return (ka > kb) - (ka < kb)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --- loose dotted-version handling (Composer / WordPress ecosystems) -------- #
|
|
75
|
+
# These can be 2, 3 or 4 segments (e.g. "6.9", "5.3.2", "6.3.0.1"); compare by
|
|
76
|
+
# zero-padding to equal length. Build/prerelease suffixes are ignored.
|
|
77
|
+
_OLDSTYLE = re.compile(r"^\d+\.x-(\d.*)$") # Drupal contrib "8.x-5.20" -> "5.20"
|
|
78
|
+
_LOOSE_NUM = re.compile(r"\d+(?:\.\d+)*")
|
|
79
|
+
_LOOSE_PRE = re.compile(r"(?i)^[-~_.]?(alpha|beta|rc|dev|snapshot|pre|a|b)\d*")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_loose(s: str):
|
|
83
|
+
"""Tuple key for a Composer/WordPress-style version.
|
|
84
|
+
|
|
85
|
+
Only the leading dotted numbers count ("5.3.2RC1" -> 5.3.2, not 5.3.2.1).
|
|
86
|
+
A prerelease suffix appends -1 so it sorts just below the final release
|
|
87
|
+
(compare_loose pads with 0): "5.3.2-beta1" < "5.3.2", so it matches "<5.3.2".
|
|
88
|
+
"""
|
|
89
|
+
if not s:
|
|
90
|
+
return None
|
|
91
|
+
s = str(s).strip().lstrip("vV")
|
|
92
|
+
m = _OLDSTYLE.match(s)
|
|
93
|
+
if m:
|
|
94
|
+
s = m.group(1)
|
|
95
|
+
head = s.split("+")[0]
|
|
96
|
+
core, dash, suffix = head.partition("-")
|
|
97
|
+
mnum = _LOOSE_NUM.match(core)
|
|
98
|
+
if not mnum:
|
|
99
|
+
return None
|
|
100
|
+
nums = tuple(int(n) for n in mnum.group(0).split("."))
|
|
101
|
+
tail = core[mnum.end():] + (("-" + suffix) if dash else "")
|
|
102
|
+
pre = bool(tail) and bool(_LOOSE_PRE.match(tail))
|
|
103
|
+
return nums + ((-1,) if pre else ())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def compare_loose(a, b) -> int:
|
|
107
|
+
n = max(len(a), len(b))
|
|
108
|
+
a = tuple(a) + (0,) * (n - len(a))
|
|
109
|
+
b = tuple(b) + (0,) * (n - len(b))
|
|
110
|
+
return (a > b) - (a < b)
|
phpwarden/vulndb.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""The CVE knowledge base: load the feed, model each CVE, match a version.
|
|
2
|
+
|
|
3
|
+
The feed is a JSON document (see data/php-cve-feed.json). It is the product's
|
|
4
|
+
evolving content — the part a subscription keeps current. The engine that reads
|
|
5
|
+
it is static and open; the feed is what grows over time.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import List, Optional
|
|
13
|
+
|
|
14
|
+
from phpwarden import version as V
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class CVE:
|
|
19
|
+
id: str
|
|
20
|
+
title: str
|
|
21
|
+
cls: str
|
|
22
|
+
cvss: float
|
|
23
|
+
severity: str
|
|
24
|
+
published: str
|
|
25
|
+
exploited_in_wild: bool
|
|
26
|
+
sapi: List[str]
|
|
27
|
+
os: List[str]
|
|
28
|
+
branches: dict # "8.3" -> {"fixed": "8.3.36"}
|
|
29
|
+
eol_affected: bool
|
|
30
|
+
exposure: list
|
|
31
|
+
detectors: list
|
|
32
|
+
remediation: str
|
|
33
|
+
references: List[str]
|
|
34
|
+
aka: List[str] = field(default_factory=list)
|
|
35
|
+
epss: Optional[float] = None
|
|
36
|
+
epss_percentile: Optional[float] = None
|
|
37
|
+
ransomware: bool = False
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_dict(cls, d: dict) -> "CVE":
|
|
41
|
+
return cls(
|
|
42
|
+
id=d["id"], title=d.get("title", ""), cls=d.get("class", "unknown"),
|
|
43
|
+
cvss=float(d.get("cvss", 0)), severity=d.get("severity", "unknown"),
|
|
44
|
+
published=d.get("published", ""), exploited_in_wild=bool(d.get("exploited_in_wild")),
|
|
45
|
+
sapi=d.get("sapi") or ["any"], os=d.get("os") or ["any"],
|
|
46
|
+
branches=d.get("branches", {}), eol_affected=bool(d.get("eol_affected")),
|
|
47
|
+
exposure=d.get("exposure", []), detectors=d.get("detectors", []),
|
|
48
|
+
remediation=d.get("remediation", ""), references=d.get("references", []),
|
|
49
|
+
aka=d.get("aka", []),
|
|
50
|
+
epss=d.get("epss"), epss_percentile=d.get("epss_percentile"),
|
|
51
|
+
ransomware=bool(d.get("ransomware")),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# --- version logic ---------------------------------------------------- #
|
|
55
|
+
def _listed_branches(self):
|
|
56
|
+
return sorted((tuple(int(x) for x in b.split(".")) for b in self.branches), reverse=False)
|
|
57
|
+
|
|
58
|
+
def version_status(self, ver: V.Version) -> str:
|
|
59
|
+
"""VULNERABLE | PATCHED | EOL_AFFECTED | NOT_APPLICABLE for a version.
|
|
60
|
+
|
|
61
|
+
- branch is listed: below its fixed release -> VULNERABLE, else PATCHED.
|
|
62
|
+
- branch older than every listed branch: EOL_AFFECTED if the CVE marks
|
|
63
|
+
end-of-life branches affected (no upstream fix), else NOT_APPLICABLE.
|
|
64
|
+
- branch newer than every listed branch: NOT_APPLICABLE (the fix is
|
|
65
|
+
already in that branch's first release, or the bug post-dates it).
|
|
66
|
+
"""
|
|
67
|
+
if not self.branches:
|
|
68
|
+
return "NOT_APPLICABLE"
|
|
69
|
+
bkey = f"{ver.major}.{ver.minor}"
|
|
70
|
+
if bkey in self.branches:
|
|
71
|
+
fixed = V.parse_fixed(self.branches[bkey]["fixed"])
|
|
72
|
+
return "VULNERABLE" if V.is_below_fixed(ver, fixed) else "PATCHED"
|
|
73
|
+
listed = self._listed_branches()
|
|
74
|
+
b = ver.branch
|
|
75
|
+
if b < listed[0]:
|
|
76
|
+
return "EOL_AFFECTED" if self.eol_affected else "NOT_APPLICABLE"
|
|
77
|
+
return "NOT_APPLICABLE" # newer than any listed branch
|
|
78
|
+
|
|
79
|
+
def fixed_for(self, ver: V.Version) -> Optional[str]:
|
|
80
|
+
bkey = f"{ver.major}.{ver.minor}"
|
|
81
|
+
return self.branches.get(bkey, {}).get("fixed")
|
|
82
|
+
|
|
83
|
+
def detector_kinds(self):
|
|
84
|
+
return {d.get("kind") for d in self.detectors}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class ComposerAdvisory:
|
|
89
|
+
package: str
|
|
90
|
+
cve: str
|
|
91
|
+
title: str
|
|
92
|
+
cvss: float
|
|
93
|
+
severity: str
|
|
94
|
+
affected: str # OR-groups ("A|B") of comma-AND comparators
|
|
95
|
+
fixed: List[str]
|
|
96
|
+
known_exploited: bool
|
|
97
|
+
references: List[str]
|
|
98
|
+
epss: Optional[float] = None
|
|
99
|
+
epss_percentile: Optional[float] = None
|
|
100
|
+
ransomware: bool = False
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def from_dict(cls, d: dict) -> "ComposerAdvisory":
|
|
104
|
+
return cls(
|
|
105
|
+
package=d["package"], cve=d.get("cve", ""), title=d.get("title", ""),
|
|
106
|
+
cvss=float(d.get("cvss", 0)), severity=d.get("severity", "unknown"),
|
|
107
|
+
affected=d.get("affected", ""), fixed=d.get("fixed", []),
|
|
108
|
+
known_exploited=bool(d.get("known_exploited")), references=d.get("references", []),
|
|
109
|
+
epss=d.get("epss"), epss_percentile=d.get("epss_percentile"),
|
|
110
|
+
ransomware=bool(d.get("ransomware")),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class WpAdvisory:
|
|
116
|
+
slug: str
|
|
117
|
+
type: str # plugin | theme | core
|
|
118
|
+
cve: str
|
|
119
|
+
title: str
|
|
120
|
+
cvss: float
|
|
121
|
+
severity: str
|
|
122
|
+
affected: str
|
|
123
|
+
fixed: List[str]
|
|
124
|
+
known_exploited: bool
|
|
125
|
+
references: List[str]
|
|
126
|
+
epss: Optional[float] = None
|
|
127
|
+
epss_percentile: Optional[float] = None
|
|
128
|
+
ransomware: bool = False
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def from_dict(cls, d: dict) -> "WpAdvisory":
|
|
132
|
+
return cls(
|
|
133
|
+
slug=d["slug"], type=d.get("type", "plugin"), cve=d.get("cve", ""),
|
|
134
|
+
title=d.get("title", ""), cvss=float(d.get("cvss", 0)),
|
|
135
|
+
severity=d.get("severity", "unknown"), affected=d.get("affected", ""),
|
|
136
|
+
fixed=d.get("fixed", []), known_exploited=bool(d.get("known_exploited")),
|
|
137
|
+
references=d.get("references", []),
|
|
138
|
+
epss=d.get("epss"), epss_percentile=d.get("epss_percentile"),
|
|
139
|
+
ransomware=bool(d.get("ransomware")),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class Feed:
|
|
145
|
+
feed_version: str
|
|
146
|
+
channel: str
|
|
147
|
+
generated: str
|
|
148
|
+
cves: List[CVE]
|
|
149
|
+
composer: List[ComposerAdvisory] = field(default_factory=list)
|
|
150
|
+
wp: List[WpAdvisory] = field(default_factory=list)
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def from_doc(cls, d: dict) -> "Feed":
|
|
154
|
+
return cls(
|
|
155
|
+
feed_version=d.get("feed_version", "?"),
|
|
156
|
+
channel=d.get("channel", "community"),
|
|
157
|
+
generated=d.get("generated", ""),
|
|
158
|
+
cves=[CVE.from_dict(c) for c in d.get("cves", [])],
|
|
159
|
+
composer=[ComposerAdvisory.from_dict(c) for c in d.get("composer_advisories", [])],
|
|
160
|
+
wp=[WpAdvisory.from_dict(w) for w in d.get("wp_advisories", [])],
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def load(cls, path) -> "Feed":
|
|
165
|
+
return cls.from_doc(json.loads(Path(path).read_text()))
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def bundled(cls) -> "Feed":
|
|
169
|
+
"""Load the feed shipped inside the package.
|
|
170
|
+
|
|
171
|
+
Uses pkgutil.get_data so it works both on disk and inside a zipapp/zip,
|
|
172
|
+
where the data file is not a real filesystem path.
|
|
173
|
+
"""
|
|
174
|
+
import pkgutil
|
|
175
|
+
raw = pkgutil.get_data("phpwarden", "data/php-cve-feed.json")
|
|
176
|
+
if raw:
|
|
177
|
+
return cls.from_doc(json.loads(raw.decode("utf-8")))
|
|
178
|
+
# dev fallback: source tree not installed as a package
|
|
179
|
+
here = Path(__file__).resolve().parent
|
|
180
|
+
cand = here / "data" / "php-cve-feed.json"
|
|
181
|
+
if cand.exists():
|
|
182
|
+
return cls.load(cand)
|
|
183
|
+
raise FileNotFoundError("bundled php-cve-feed.json not found")
|
|
184
|
+
|
|
185
|
+
def by_id(self, cve_id):
|
|
186
|
+
return next((c for c in self.cves if c.id == cve_id), None)
|