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/env.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Detect the local PHP environment: version, SAPI, OS, architecture.
|
|
2
|
+
|
|
3
|
+
Everything is best-effort and honest: anything we cannot determine is left as
|
|
4
|
+
None, which the engine treats as "cannot rule out" rather than "safe".
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import platform
|
|
9
|
+
import shutil
|
|
10
|
+
import struct
|
|
11
|
+
import subprocess
|
|
12
|
+
|
|
13
|
+
from phpwarden import version as V
|
|
14
|
+
from phpwarden.engine import Environment
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _run(cmd):
|
|
18
|
+
try:
|
|
19
|
+
exe = shutil.which(cmd[0]) or cmd[0]
|
|
20
|
+
out = subprocess.run([exe, *cmd[1:]], capture_output=True, text=True,
|
|
21
|
+
timeout=10, check=False)
|
|
22
|
+
return (out.stdout or out.stderr)
|
|
23
|
+
except (OSError, subprocess.SubprocessError):
|
|
24
|
+
return ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def detect_os():
|
|
28
|
+
s = platform.system().lower()
|
|
29
|
+
if s.startswith("win"):
|
|
30
|
+
return "windows"
|
|
31
|
+
if s == "linux":
|
|
32
|
+
return "linux"
|
|
33
|
+
if s == "darwin":
|
|
34
|
+
return "darwin"
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def detect_arch():
|
|
39
|
+
return "64" if struct.calcsize("P") * 8 == 64 else "32"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def detect(php_binary: str = "php", prefer_fpm: bool = True) -> Environment:
|
|
43
|
+
"""Build an Environment from the local machine.
|
|
44
|
+
|
|
45
|
+
Tries php-fpm first (so SAPI is known to be fpm) unless told otherwise,
|
|
46
|
+
then falls back to the php CLI for the version.
|
|
47
|
+
"""
|
|
48
|
+
version = None
|
|
49
|
+
sapi = None
|
|
50
|
+
|
|
51
|
+
def _version_line(out: str):
|
|
52
|
+
# `php -v` may print startup warnings (a missing extension) BEFORE the
|
|
53
|
+
# "PHP 8.x.y (cli) ..." line; take the first line that is a PHP banner.
|
|
54
|
+
for ln in (out or "").splitlines():
|
|
55
|
+
if ln.startswith("PHP ") and V.parse(ln):
|
|
56
|
+
return V.parse(ln)
|
|
57
|
+
return V.parse(out) if out else None
|
|
58
|
+
|
|
59
|
+
# An explicit --php means "assess THIS binary": never silently substitute a
|
|
60
|
+
# php-fpm found on PATH for it.
|
|
61
|
+
explicit = php_binary != "php"
|
|
62
|
+
if prefer_fpm and not explicit:
|
|
63
|
+
fpm_out = _run(["php-fpm", "-v"])
|
|
64
|
+
if fpm_out and _version_line(fpm_out):
|
|
65
|
+
version = _version_line(fpm_out)
|
|
66
|
+
sapi = "fpm"
|
|
67
|
+
|
|
68
|
+
if version is None:
|
|
69
|
+
out = _run([php_binary, "-v"])
|
|
70
|
+
version = _version_line(out)
|
|
71
|
+
# ask the running SAPI if we can (last line: warnings may precede it)
|
|
72
|
+
sapi_out = _run([php_binary, "-r", "echo PHP_SAPI;"]).strip()
|
|
73
|
+
last = sapi_out.splitlines()[-1].strip() if sapi_out else ""
|
|
74
|
+
if last:
|
|
75
|
+
if "fpm" in last:
|
|
76
|
+
sapi = "fpm"
|
|
77
|
+
elif "cgi" in last:
|
|
78
|
+
sapi = "cgi"
|
|
79
|
+
elif last == "cli":
|
|
80
|
+
sapi = sapi or "cli"
|
|
81
|
+
|
|
82
|
+
return Environment(version=version, sapi=sapi, os=detect_os(), arch=detect_arch())
|
phpwarden/feed.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Feed subscription — the part a paying customer keeps current.
|
|
2
|
+
|
|
3
|
+
The engine and the base (community) feed are open and free. A subscription
|
|
4
|
+
provides a license key that unlocks the hosted feed channel, which is updated as
|
|
5
|
+
new PHP CVEs are triaged and fitted with version ranges and detection
|
|
6
|
+
signatures. This module is the client: it resolves the active feed (cache ->
|
|
7
|
+
bundled), and `update()` pulls a newer feed when a license key is present.
|
|
8
|
+
|
|
9
|
+
No network call happens without an explicit `update()`, and the tool works
|
|
10
|
+
fully offline on the bundled feed.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.request
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from phpwarden.vulndb import Feed
|
|
24
|
+
|
|
25
|
+
# The hosted maintained-feed channel (server/feedserver.py behind TLS). Override
|
|
26
|
+
# with PHPWARDEN_FEED_URL for self-hosted or staging feed servers.
|
|
27
|
+
DEFAULT_FEED_URL = "https://feed.phpwarden.io/v1/feed"
|
|
28
|
+
CACHE_DIR_DEFAULT = Path(os.environ.get("PHPWARDEN_HOME", str(Path.home() / ".phpwarden")))
|
|
29
|
+
CACHE_FILE = "php-cve-feed.cache.json"
|
|
30
|
+
# Outcome of the last `update()` that proved or disproved the license key, so
|
|
31
|
+
# `status()` can say license_valid True/False instead of guessing from the key
|
|
32
|
+
# being a non-empty string. Keyed by a hash of the key: a new key resets it.
|
|
33
|
+
LICENSE_STATUS_FILE = "license-status.json"
|
|
34
|
+
_REJECTED_HTTP = (401, 403)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def version_key(v) -> tuple:
|
|
38
|
+
"""Comparable key for a feed_version like '2026.10.24.1'. As STRINGS,
|
|
39
|
+
'2026.10.9.1' > '2026.10.24.1', which let a stale cache beat a newer
|
|
40
|
+
bundled feed; compare the dotted integers instead."""
|
|
41
|
+
return tuple(int(p) if p.isdigit() else 0 for p in str(v or "").split("."))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# A served feed may never drop more than this share of any category versus the
|
|
45
|
+
# feed currently in effect — mirrors deploy/feed-guard.py on the server side, so
|
|
46
|
+
# an empty or partial document can't blank out coverage on the client.
|
|
47
|
+
MAX_SHRINK = 0.10
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FeedManager:
|
|
51
|
+
def __init__(self, cache_dir: Path = None, license_key: str = None, feed_url: str = None):
|
|
52
|
+
self.cache_dir = Path(cache_dir or CACHE_DIR_DEFAULT)
|
|
53
|
+
self.license_key = license_key or os.environ.get("PHPWARDEN_LICENSE")
|
|
54
|
+
self.feed_url = feed_url or os.environ.get("PHPWARDEN_FEED_URL", DEFAULT_FEED_URL)
|
|
55
|
+
|
|
56
|
+
# --- resolve the active feed ----------------------------------------- #
|
|
57
|
+
def _cache_path(self) -> Path:
|
|
58
|
+
return self.cache_dir / CACHE_FILE
|
|
59
|
+
|
|
60
|
+
def current(self) -> Feed:
|
|
61
|
+
"""The feed in effect: a valid cached feed if newer than bundled, else bundled."""
|
|
62
|
+
bundled = Feed.bundled()
|
|
63
|
+
cache = self._cache_path()
|
|
64
|
+
if cache.exists():
|
|
65
|
+
try:
|
|
66
|
+
cached = Feed.load(cache)
|
|
67
|
+
if version_key(cached.feed_version) >= version_key(bundled.feed_version):
|
|
68
|
+
return cached
|
|
69
|
+
except (OSError, ValueError, KeyError):
|
|
70
|
+
pass
|
|
71
|
+
return bundled
|
|
72
|
+
|
|
73
|
+
def source(self) -> str:
|
|
74
|
+
cache = self._cache_path()
|
|
75
|
+
if cache.exists():
|
|
76
|
+
try:
|
|
77
|
+
if version_key(Feed.load(cache).feed_version) >= version_key(Feed.bundled().feed_version):
|
|
78
|
+
return "subscription-cache"
|
|
79
|
+
except Exception: # noqa: BLE001
|
|
80
|
+
pass
|
|
81
|
+
return "bundled-community"
|
|
82
|
+
|
|
83
|
+
# --- license validity memo ------------------------------------------- #
|
|
84
|
+
def _key_id(self) -> Optional[str]:
|
|
85
|
+
if not self.license_key:
|
|
86
|
+
return None
|
|
87
|
+
return hashlib.sha256(self.license_key.encode()).hexdigest()[:16]
|
|
88
|
+
|
|
89
|
+
def _license_status_path(self) -> Path:
|
|
90
|
+
return self.cache_dir / LICENSE_STATUS_FILE
|
|
91
|
+
|
|
92
|
+
def _remember_license(self, valid: bool, http_status: int) -> None:
|
|
93
|
+
try:
|
|
94
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
self._license_status_path().write_text(json.dumps({
|
|
96
|
+
"key_id": self._key_id(), "license_valid": valid,
|
|
97
|
+
"http_status": http_status, "checked": int(time.time())}))
|
|
98
|
+
except OSError:
|
|
99
|
+
pass # best-effort memo only
|
|
100
|
+
|
|
101
|
+
def license_valid(self) -> Optional[bool]:
|
|
102
|
+
"""True/False once an `update()` has proven the current key accepted or
|
|
103
|
+
rejected; None when no key is set or no update has been attempted yet."""
|
|
104
|
+
kid = self._key_id()
|
|
105
|
+
if not kid:
|
|
106
|
+
return None
|
|
107
|
+
try:
|
|
108
|
+
memo = json.loads(self._license_status_path().read_text())
|
|
109
|
+
except (OSError, ValueError):
|
|
110
|
+
return None
|
|
111
|
+
if not isinstance(memo, dict) or memo.get("key_id") != kid:
|
|
112
|
+
return None
|
|
113
|
+
v = memo.get("license_valid")
|
|
114
|
+
return v if isinstance(v, bool) else None
|
|
115
|
+
|
|
116
|
+
# --- subscription update --------------------------------------------- #
|
|
117
|
+
def update(self) -> dict:
|
|
118
|
+
"""Pull a newer feed if a license key is set. Returns a status dict.
|
|
119
|
+
|
|
120
|
+
status: updated | current | no_license | unlicensed | error.
|
|
121
|
+
"unlicensed" means the server answered 401/403 — the key was rejected
|
|
122
|
+
(invalid or revoked); "error" is a network failure, a non-auth HTTP
|
|
123
|
+
error, or a bad document. `http_status` carries the server's code when
|
|
124
|
+
there was one."""
|
|
125
|
+
if not self.license_key:
|
|
126
|
+
return {"status": "no_license",
|
|
127
|
+
"message": "No license key set. Running on the free community feed. "
|
|
128
|
+
"Set PHPWARDEN_LICENSE to subscribe to the maintained channel.",
|
|
129
|
+
"feed_version": self.current().feed_version}
|
|
130
|
+
try:
|
|
131
|
+
req = urllib.request.Request(
|
|
132
|
+
self.feed_url,
|
|
133
|
+
headers={"Authorization": f"Bearer {self.license_key}",
|
|
134
|
+
"User-Agent": "phpwarden-feed/1"})
|
|
135
|
+
with urllib.request.urlopen(req, timeout=20) as resp: # noqa: S310
|
|
136
|
+
raw = resp.read()
|
|
137
|
+
http_status = resp.status
|
|
138
|
+
except urllib.error.HTTPError as e:
|
|
139
|
+
if e.code in _REJECTED_HTTP:
|
|
140
|
+
self._remember_license(False, e.code)
|
|
141
|
+
return {"status": "unlicensed", "http_status": e.code, "license_valid": False,
|
|
142
|
+
"message": f"the feed server rejected the license key (HTTP {e.code}); "
|
|
143
|
+
f"check PHPWARDEN_LICENSE or renew the subscription. "
|
|
144
|
+
f"The community feed stays in effect.",
|
|
145
|
+
"feed_version": self.current().feed_version}
|
|
146
|
+
return {"status": "error", "http_status": e.code,
|
|
147
|
+
"message": f"feed fetch failed: HTTP {e.code} {e.reason}",
|
|
148
|
+
"feed_version": self.current().feed_version}
|
|
149
|
+
except Exception as e: # noqa: BLE001 — network failure
|
|
150
|
+
return {"status": "error", "message": f"feed fetch failed: {e.__class__.__name__}: {e}",
|
|
151
|
+
"feed_version": self.current().feed_version}
|
|
152
|
+
# a 2xx with the bearer key means the key is good, whatever the document says
|
|
153
|
+
self._remember_license(True, http_status)
|
|
154
|
+
|
|
155
|
+
# Validate the document fully BEFORE it can touch the cache: a server
|
|
156
|
+
# that returns `5`, a CVE without an id, or an empty feed must leave the
|
|
157
|
+
# feed in effect untouched (previously the bad doc was cached first and
|
|
158
|
+
# every later update crashed on it).
|
|
159
|
+
try:
|
|
160
|
+
doc = json.loads(raw)
|
|
161
|
+
if not isinstance(doc, dict) or "cves" not in doc or "feed_version" not in doc:
|
|
162
|
+
raise ValueError("feed missing required keys")
|
|
163
|
+
if not isinstance(doc["cves"], list) or not doc["cves"]:
|
|
164
|
+
raise ValueError("feed has no CVEs")
|
|
165
|
+
for c in doc["cves"]:
|
|
166
|
+
if not isinstance(c, dict) or not c.get("id"):
|
|
167
|
+
raise ValueError("feed contains a CVE without an id")
|
|
168
|
+
except (ValueError, json.JSONDecodeError, TypeError) as e:
|
|
169
|
+
return {"status": "error", "http_status": http_status, "license_valid": True,
|
|
170
|
+
"message": f"invalid feed document: {e}"}
|
|
171
|
+
|
|
172
|
+
before = self.current()
|
|
173
|
+
new_ver = doc["feed_version"]
|
|
174
|
+
if version_key(new_ver) <= version_key(before.feed_version):
|
|
175
|
+
return {"status": "current", "feed_version": before.feed_version,
|
|
176
|
+
"http_status": http_status, "license_valid": True,
|
|
177
|
+
"message": "Already up to date."}
|
|
178
|
+
|
|
179
|
+
# Parse into a Feed from a temp file first; only a feed that loads AND
|
|
180
|
+
# keeps coverage replaces the cache.
|
|
181
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
tmp = self._cache_path().with_suffix(".tmp")
|
|
183
|
+
tmp.write_text(json.dumps(doc, indent=2))
|
|
184
|
+
try:
|
|
185
|
+
after = Feed.load(tmp)
|
|
186
|
+
except Exception as e: # noqa: BLE001
|
|
187
|
+
tmp.unlink(missing_ok=True)
|
|
188
|
+
return {"status": "error", "message": f"feed failed to load: {e.__class__.__name__}: {e}"}
|
|
189
|
+
for name, now, then in (("cves", len(after.cves), len(before.cves)),
|
|
190
|
+
("composer", len(after.composer), len(before.composer)),
|
|
191
|
+
("wp", len(after.wp), len(before.wp))):
|
|
192
|
+
if then and now < then * (1 - MAX_SHRINK):
|
|
193
|
+
tmp.unlink(missing_ok=True)
|
|
194
|
+
return {"status": "error", "feed_version": before.feed_version,
|
|
195
|
+
"message": f"refusing feed {new_ver}: {name} advisories would drop "
|
|
196
|
+
f"{then} -> {now}; keeping {before.feed_version}"}
|
|
197
|
+
tmp.replace(self._cache_path())
|
|
198
|
+
new_ids = {c.id for c in after.cves} - {c.id for c in before.cves}
|
|
199
|
+
return {"status": "updated", "from": before.feed_version, "to": after.feed_version,
|
|
200
|
+
"added_cves": sorted(new_ids), "total_cves": len(after.cves),
|
|
201
|
+
"http_status": http_status, "license_valid": True}
|
|
202
|
+
|
|
203
|
+
def status(self) -> dict:
|
|
204
|
+
"""`license_key_set` is just "a key is configured"; `license_valid` is
|
|
205
|
+
None until an `update()` has had the server accept or reject that key."""
|
|
206
|
+
f = self.current()
|
|
207
|
+
return {"feed_version": f.feed_version, "channel": f.channel,
|
|
208
|
+
"generated": f.generated, "cve_count": len(f.cves),
|
|
209
|
+
"composer_count": len(f.composer), "wp_count": len(f.wp),
|
|
210
|
+
"total_advisories": len(f.cves) + len(f.composer) + len(f.wp),
|
|
211
|
+
"source": self.source(),
|
|
212
|
+
"license_key_set": bool(self.license_key),
|
|
213
|
+
"license_valid": self.license_valid()}
|