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