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/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)