phpwarden 2.0.3__tar.gz → 2.1.0__tar.gz

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.
Files changed (62) hide show
  1. {phpwarden-2.0.3 → phpwarden-2.1.0}/PKG-INFO +2 -1
  2. {phpwarden-2.0.3 → phpwarden-2.1.0}/README.md +1 -0
  3. {phpwarden-2.0.3 → phpwarden-2.1.0}/pyproject.toml +1 -1
  4. {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/__init__.py +1 -1
  5. {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/cli.py +41 -0
  6. phpwarden-2.1.0/src/phpwarden/integrity.py +254 -0
  7. {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden.egg-info/PKG-INFO +2 -1
  8. {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden.egg-info/SOURCES.txt +4 -0
  9. phpwarden-2.1.0/tests/test_agency.py +259 -0
  10. phpwarden-2.1.0/tests/test_integrity.py +109 -0
  11. phpwarden-2.1.0/tests/test_store_connections.py +40 -0
  12. {phpwarden-2.0.3 → phpwarden-2.1.0}/LICENSE +0 -0
  13. {phpwarden-2.0.3 → phpwarden-2.1.0}/setup.cfg +0 -0
  14. {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/data/php-cve-feed.json +0 -0
  15. {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/deps.py +0 -0
  16. {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/detect.py +0 -0
  17. {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/engine.py +0 -0
  18. {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/env.py +0 -0
  19. {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/feed.py +0 -0
  20. {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/guard.py +0 -0
  21. {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/report.py +0 -0
  22. {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/rules.py +0 -0
  23. {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/version.py +0 -0
  24. {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/vulndb.py +0 -0
  25. {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/wordpress.py +0 -0
  26. {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden.egg-info/dependency_links.txt +0 -0
  27. {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden.egg-info/entry_points.txt +0 -0
  28. {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden.egg-info/top_level.txt +0 -0
  29. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_accounts.py +0 -0
  30. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_advisories.py +0 -0
  31. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_assignment.py +0 -0
  32. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_audit.py +0 -0
  33. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_billing.py +0 -0
  34. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_cli_edges.py +0 -0
  35. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_connect_flow.py +0 -0
  36. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_dashboard_states.py +0 -0
  37. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_deps_and_rules.py +0 -0
  38. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_detection_validation.py +0 -0
  39. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_developer.py +0 -0
  40. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_discussion.py +0 -0
  41. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_e2e_readiness.py +0 -0
  42. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_email.py +0 -0
  43. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_engine.py +0 -0
  44. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_engine_feed.py +0 -0
  45. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_feed_license.py +0 -0
  46. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_feed_subscription.py +0 -0
  47. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_fleet.py +0 -0
  48. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_google_oauth.py +0 -0
  49. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_guard_live.py +0 -0
  50. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_mentions.py +0 -0
  51. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_reachability.py +0 -0
  52. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_saas.py +0 -0
  53. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_saas_billing.py +0 -0
  54. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_server.py +0 -0
  55. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_settings.py +0 -0
  56. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_shield.py +0 -0
  57. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_slack.py +0 -0
  58. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_sso.py +0 -0
  59. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_team.py +0 -0
  60. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_triage.py +0 -0
  61. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_webhook_idempotency.py +0 -0
  62. {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_wordpress_and_report.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phpwarden
3
- Version: 2.0.3
3
+ Version: 2.1.0
4
4
  Summary: PHP vulnerability scanner for Composer, WordPress and the PHP engine — findings ranked by real-world exploitation (CISA KEV, then EPSS), not CVSS. Offline, zero deps.
5
5
  Author: oroma-alfred
6
6
  License-Expression: Apache-2.0
@@ -100,6 +100,7 @@ phpwarden scan # assess this host (auto-detects version/SA
100
100
  phpwarden scan --version 8.3.10 --sapi fpm --os linux
101
101
  phpwarden deps composer.lock # Composer dependencies, KEV → reachability → EPSS → CVSS
102
102
  phpwarden wordpress /var/www/html # WordPress core, plugins, themes
103
+ phpwarden integrity /var/www/html # tampered or injected files (vs wordpress.org checksums)
103
104
  phpwarden detect /var/log/nginx/access.log # exploit signatures in a log
104
105
  phpwarden rules --format nginx --out php-block.conf # PHP-specific WAF rules (or modsecurity)
105
106
  phpwarden report web01.json web02.json --out fleet.html # aggregate a fleet view
@@ -78,6 +78,7 @@ phpwarden scan # assess this host (auto-detects version/SA
78
78
  phpwarden scan --version 8.3.10 --sapi fpm --os linux
79
79
  phpwarden deps composer.lock # Composer dependencies, KEV → reachability → EPSS → CVSS
80
80
  phpwarden wordpress /var/www/html # WordPress core, plugins, themes
81
+ phpwarden integrity /var/www/html # tampered or injected files (vs wordpress.org checksums)
81
82
  phpwarden detect /var/log/nginx/access.log # exploit signatures in a log
82
83
  phpwarden rules --format nginx --out php-block.conf # PHP-specific WAF rules (or modsecurity)
83
84
  phpwarden report web01.json web02.json --out fleet.html # aggregate a fleet view
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "phpwarden"
7
- version = "2.0.3"
7
+ version = "2.1.0"
8
8
  description = "PHP vulnerability scanner for Composer, WordPress and the PHP engine — findings ranked by real-world exploitation (CISA KEV, then EPSS), not CVSS. Offline, zero deps."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -5,4 +5,4 @@ environment against an evolving knowledge base of PHP CVEs (the feed), and can
5
5
  watch PHP-FPM for active exploitation. The engine is open; the CVE feed is the
6
6
  part a subscription keeps current.
7
7
  """
8
- __version__ = "2.0.3"
8
+ __version__ = "2.1.0"
@@ -372,6 +372,39 @@ def _wordpress(args):
372
372
  return _combine(1 if findings else 0, _maybe_report(args, doc))
373
373
 
374
374
 
375
+ def _integrity(args):
376
+ from phpwarden import integrity as integ
377
+ res = integ.check(args.path, locale=args.locale, plugins=not args.no_plugins)
378
+ doc = integ.to_doc(res)
379
+ if args.json:
380
+ print(json.dumps(doc, indent=2))
381
+ return 1 if res.tampered else 0
382
+ if not res.core_version:
383
+ print(f"{args.path}: no WordPress install detected at that path")
384
+ return 2
385
+ labels = {"php-in-uploads": "PHP file in uploads", "unexpected": "does not belong",
386
+ "modified": "modified", "missing": "missing"}
387
+ bad = [i for i in res.issues if i.kind != "missing"]
388
+ print(f"WordPress {res.core_version} at {args.path}: {res.files_checked} files compared with wordpress.org, "
389
+ f"{len(bad)} problem{'s' if len(bad) != 1 else ''}.")
390
+ for i in res.issues:
391
+ flag = " SUSPICIOUS" if i.suspicious else ""
392
+ print(f"\n {labels.get(i.kind, i.kind):20} {i.path} ({i.component}){flag}")
393
+ for pid in i.suspicious:
394
+ print(f" matches: {integ.describe(pid)}")
395
+ if res.verified:
396
+ print(f"\n verified against official checksums: {len(res.verified)} component(s)")
397
+ if res.unverified:
398
+ print(f" could not be verified: {len(res.unverified)} component(s)")
399
+ for u in res.unverified[:40]:
400
+ print(f" {u['component']}: {u['reason']}")
401
+ if not res.issues:
402
+ print("\n No modified or unexpected files in the components that could be verified.")
403
+ print("\n This compares files with wordpress.org checksums and looks for common backdoor code.\n"
404
+ " It is not a signature-based malware scanner.")
405
+ return 1 if res.tampered else 0
406
+
407
+
375
408
  def _report(args):
376
409
  rows = reportgen.load_rows(args.files)
377
410
  feed = FeedManager().current()
@@ -438,6 +471,14 @@ def main(argv=None):
438
471
  _report_args(wpc)
439
472
  wpc.set_defaults(func=_wordpress)
440
473
 
474
+ ig = sub.add_parser("integrity", help="check WordPress core and plugin files against wordpress.org "
475
+ "checksums; flag injected files and backdoor code")
476
+ ig.add_argument("path", help="path to the WordPress root")
477
+ ig.add_argument("--locale", default="en_US", help="WordPress locale of the install (default en_US)")
478
+ ig.add_argument("--no-plugins", action="store_true", help="check core and uploads only")
479
+ ig.add_argument("--json", action="store_true")
480
+ ig.set_defaults(func=_integrity)
481
+
441
482
  rp = sub.add_parser("report", help="aggregate JSON results into an HTML fleet dashboard")
442
483
  rp.add_argument("files", nargs="+", help="JSON files from scan/deps/wordpress --json")
443
484
  rp.add_argument("--out", help="write HTML to a file instead of stdout")
@@ -0,0 +1,254 @@
1
+ """File integrity check for a WordPress install.
2
+
3
+ Answers "has this site's code been tampered with?" without malware signatures:
4
+
5
+ * WordPress core files are compared with the official checksums from
6
+ api.wordpress.org for the installed version;
7
+ * every wordpress.org plugin is compared with the plugin-checksums service;
8
+ * files that do not belong (extra PHP in wp-admin / wp-includes / a plugin, any
9
+ PHP under wp-content/uploads) are listed;
10
+ * modified and unexpected PHP files are checked for a small set of well-known
11
+ backdoor idioms (eval of decoded data, request input passed to a code or
12
+ command function, and so on).
13
+
14
+ This is not a signature-based malware scanner. A changed core file or a PHP file
15
+ in uploads is how most compromised sites show up; novel malware hidden in a
16
+ premium plugin or a theme (which have no public checksums) is not covered, and
17
+ the report says which components could not be verified.
18
+
19
+ Needs network access to wordpress.org. Standard library only.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ import re
26
+ import urllib.error
27
+ import urllib.request
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import Callable, Dict, List, Optional
31
+
32
+ from phpwarden import wordpress as wp
33
+
34
+ CORE_URL = "https://api.wordpress.org/core/checksums/1.0/?version={version}&locale={locale}"
35
+ PLUGIN_URL = "https://downloads.wordpress.org/plugin-checksums/{slug}/{version}.json"
36
+ _UA = "phpwarden-integrity"
37
+ _PHP_EXT = (".php", ".phtml", ".php5", ".php7", ".phar", ".pht")
38
+ _SAFE = re.compile(r"^[A-Za-z0-9._-]+$")
39
+ MAX_SCAN_BYTES = 2_000_000
40
+
41
+ # (id, description, pattern). Deliberately few and specific: each one is rare in
42
+ # legitimate code and common in real backdoors.
43
+ _INPUT = r"\$_(?:GET|POST|REQUEST|COOKIE|SERVER|FILES)\b"
44
+ _DECODE = r"(?:base64_decode|gzinflate|gzuncompress|gzdecode|str_rot13|hex2bin|strrev|convert_uudecode)"
45
+ PATTERNS = [
46
+ ("eval-decoded", "eval() of decoded or decompressed data",
47
+ re.compile(r"\beval\s*\(\s*(?:@\s*)?" + _DECODE + r"\s*\(", re.I)),
48
+ ("eval-input", "eval()/assert() of request input",
49
+ re.compile(r"\b(?:eval|assert)\s*\(\s*(?:@\s*)?(?:stripslashes\s*\(\s*)?" + _INPUT, re.I)),
50
+ ("exec-input", "request input passed to a command function",
51
+ re.compile(r"\b(?:system|exec|shell_exec|passthru|popen|proc_open)\s*\(\s*(?:@\s*)?" + _INPUT, re.I)),
52
+ ("decode-input-exec", "decoded request input executed",
53
+ re.compile(r"\b(?:eval|assert|system|exec|shell_exec|passthru)\s*\(\s*" + _DECODE + r"\s*\(\s*" + _INPUT, re.I)),
54
+ ("preg-e", "preg_replace() with the /e (execute) modifier",
55
+ re.compile(r"preg_replace\s*\(\s*(['\"])(.)(?:(?!\1).)*\2[a-zA-Z]*e[a-zA-Z]*\1", re.I | re.S)),
56
+ ("create-function-input", "create_function() built from request input",
57
+ re.compile(r"\bcreate_function\s*\([^)]*" + _INPUT, re.I)),
58
+ ("variable-function-input", "request input called as a function",
59
+ re.compile(_INPUT + r"\s*\[[^\]]+\]\s*\(", re.I)),
60
+ ("upload-write", "uploaded file moved using a name taken from the request",
61
+ re.compile(r"move_uploaded_file\s*\([^,]+,\s*[^)]*" + _INPUT, re.I)),
62
+ ("long-encoded-blob", "very long encoded string passed to a decoder",
63
+ re.compile(_DECODE + r"\s*\(\s*['\"][A-Za-z0-9+/=\\x]{1500,}['\"]", re.I)),
64
+ ]
65
+
66
+
67
+ @dataclass
68
+ class Issue:
69
+ kind: str # modified | unexpected | missing | php-in-uploads
70
+ component: str # core | plugin:<slug> | uploads
71
+ path: str # relative to the WordPress root
72
+ suspicious: List[str] = field(default_factory=list) # pattern ids that matched
73
+
74
+
75
+ @dataclass
76
+ class Result:
77
+ root: str
78
+ core_version: Optional[str]
79
+ issues: List[Issue] = field(default_factory=list)
80
+ verified: List[str] = field(default_factory=list) # components checked against checksums
81
+ unverified: List[dict] = field(default_factory=list) # {"component", "reason"}
82
+ files_checked: int = 0
83
+
84
+ @property
85
+ def tampered(self) -> bool:
86
+ return any(i.kind in ("modified", "unexpected", "php-in-uploads") for i in self.issues)
87
+
88
+
89
+ def _fetch_json(url: str, timeout: float = 20.0):
90
+ req = urllib.request.Request(url, headers={"User-Agent": _UA, "Accept": "application/json"})
91
+ with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 — fixed https hosts
92
+ return json.loads(r.read().decode("utf-8", "replace"))
93
+
94
+
95
+ def _md5(path: Path) -> str:
96
+ h = hashlib.md5() # noqa: S324 — wordpress.org publishes md5
97
+ with open(path, "rb") as fh:
98
+ for chunk in iter(lambda: fh.read(1 << 16), b""):
99
+ h.update(chunk)
100
+ return h.hexdigest()
101
+
102
+
103
+ def suspicious_patterns(path: Path) -> List[str]:
104
+ """Ids of the backdoor idioms found in a file (empty for non-PHP or huge files)."""
105
+ if path.suffix.lower() not in _PHP_EXT:
106
+ return []
107
+ try:
108
+ if path.stat().st_size > MAX_SCAN_BYTES:
109
+ return []
110
+ text = path.read_text(errors="replace")
111
+ except OSError:
112
+ return []
113
+ return [pid for pid, _, rx in PATTERNS if rx.search(text)]
114
+
115
+
116
+ def _walk(base: Path):
117
+ """Regular files under base (no symlink following), as (relative posix path, Path)."""
118
+ if not base.is_dir():
119
+ return
120
+ stack = [base]
121
+ while stack:
122
+ d = stack.pop()
123
+ try:
124
+ entries = list(d.iterdir())
125
+ except OSError:
126
+ continue
127
+ for e in entries:
128
+ if e.is_symlink():
129
+ continue
130
+ if e.is_dir():
131
+ stack.append(e)
132
+ elif e.is_file():
133
+ yield e.relative_to(base).as_posix(), e
134
+
135
+
136
+ def _is_php(rel: str) -> bool:
137
+ return rel.lower().endswith(_PHP_EXT)
138
+
139
+
140
+ def _compare(result: Result, component: str, base: Path, root: Path, sums: Dict[str, List[str]],
141
+ unexpected_php_only: bool, skip: Callable[[str], bool] = lambda rel: False):
142
+ """Compare files under `base` with `sums` ({relative path: [accepted md5...]})."""
143
+ seen = set()
144
+ for rel, path in _walk(base):
145
+ if skip(rel):
146
+ continue
147
+ result.files_checked += 1
148
+ relroot = path.relative_to(root).as_posix()
149
+ want = sums.get(rel)
150
+ if want is None:
151
+ if unexpected_php_only and not _is_php(rel):
152
+ continue
153
+ result.issues.append(Issue("unexpected", component, relroot, suspicious_patterns(path)))
154
+ continue
155
+ seen.add(rel)
156
+ try:
157
+ got = _md5(path)
158
+ except OSError:
159
+ continue
160
+ if got not in want:
161
+ result.issues.append(Issue("modified", component, relroot, suspicious_patterns(path)))
162
+ for rel in sums:
163
+ if rel not in seen and not skip(rel) and _is_php(rel) and not (base / rel).exists():
164
+ result.issues.append(Issue("missing", component, (base / rel).relative_to(root).as_posix()))
165
+
166
+
167
+ def check(wp_root, locale: str = "en_US", plugins: bool = True,
168
+ fetch: Callable[[str], dict] = _fetch_json) -> Result:
169
+ root = Path(wp_root)
170
+ core = wp.detect_core(root)
171
+ res = Result(str(root), core)
172
+ if not core:
173
+ res.unverified.append({"component": "core", "reason": "no WordPress install found at that path"})
174
+ return res
175
+
176
+ # ---- core
177
+ try:
178
+ doc = fetch(CORE_URL.format(version=core, locale=locale))
179
+ raw = doc.get("checksums") if isinstance(doc, dict) else None
180
+ if isinstance(raw, dict) and locale in raw and isinstance(raw[locale], dict):
181
+ raw = raw[locale]
182
+ if not isinstance(raw, dict) or not raw:
183
+ raise ValueError("no checksums for this version/locale")
184
+ sums = {k: [v] for k, v in raw.items() if isinstance(v, str)}
185
+ # wp-content is user territory (bundled themes/plugins get updated on their own)
186
+ _compare(res, "core", root, root, sums, unexpected_php_only=True,
187
+ skip=lambda rel: rel.startswith("wp-content/") or rel in ("wp-config.php", ".htaccess")
188
+ or not (rel.startswith("wp-admin/") or rel.startswith("wp-includes/") or "/" not in rel))
189
+ res.verified.append(f"core {core}")
190
+ except (urllib.error.URLError, OSError, ValueError) as e:
191
+ res.unverified.append({"component": f"core {core}", "reason": f"checksums unavailable: {_why(e)}"})
192
+
193
+ # ---- plugins
194
+ pdir = root / "wp-content" / "plugins"
195
+ if plugins and pdir.is_dir():
196
+ for slug, version in sorted(wp.detect_plugins(root).items()):
197
+ base = pdir / slug
198
+ if not base.is_dir(): # single-file plugin: nothing published to compare against
199
+ res.unverified.append({"component": f"plugin {slug}", "reason": "single-file plugin"})
200
+ continue
201
+ if not version or not _SAFE.match(slug) or not _SAFE.match(version):
202
+ res.unverified.append({"component": f"plugin {slug}", "reason": "version not detected"})
203
+ continue
204
+ try:
205
+ doc = fetch(PLUGIN_URL.format(slug=slug, version=version))
206
+ files = doc.get("files") if isinstance(doc, dict) else None
207
+ if not isinstance(files, dict) or not files:
208
+ raise ValueError("empty checksum list")
209
+ sums = {}
210
+ for rel, meta in files.items():
211
+ md5 = meta.get("md5") if isinstance(meta, dict) else None
212
+ sums[rel] = [md5] if isinstance(md5, str) else [m for m in (md5 or []) if isinstance(m, str)]
213
+ _compare(res, f"plugin:{slug}", base, root, sums, unexpected_php_only=True)
214
+ res.verified.append(f"plugin {slug} {version}")
215
+ except urllib.error.HTTPError as e:
216
+ reason = ("not on wordpress.org (premium or custom), cannot be verified" if e.code == 404
217
+ else f"checksums unavailable: HTTP {e.code}")
218
+ res.unverified.append({"component": f"plugin {slug} {version}", "reason": reason})
219
+ except (urllib.error.URLError, OSError, ValueError) as e:
220
+ res.unverified.append({"component": f"plugin {slug} {version}",
221
+ "reason": f"checksums unavailable: {_why(e)}"})
222
+
223
+ # ---- themes have no public checksums: say so rather than imply they were checked
224
+ for slug in sorted(wp.detect_themes(root)):
225
+ res.unverified.append({"component": f"theme {slug}", "reason": "no public checksums for themes"})
226
+
227
+ # ---- PHP where it should never be
228
+ up = root / "wp-content" / "uploads"
229
+ for rel, path in _walk(up):
230
+ if _is_php(rel):
231
+ res.issues.append(Issue("php-in-uploads", "uploads", path.relative_to(root).as_posix(),
232
+ suspicious_patterns(path)))
233
+ order = {"php-in-uploads": 0, "unexpected": 1, "modified": 2, "missing": 3}
234
+ res.issues.sort(key=lambda i: (0 if i.suspicious else 1, order.get(i.kind, 9), i.path))
235
+ return res
236
+
237
+
238
+ def _why(e: Exception) -> str:
239
+ return str(getattr(e, "reason", "") or e)[:120] or e.__class__.__name__
240
+
241
+
242
+ def describe(pattern_id: str) -> str:
243
+ for pid, text, _ in PATTERNS:
244
+ if pid == pattern_id:
245
+ return text
246
+ return pattern_id
247
+
248
+
249
+ def to_doc(res: Result) -> dict:
250
+ return {"integrity": res.root, "core_version": res.core_version, "tampered": res.tampered,
251
+ "files_checked": res.files_checked,
252
+ "issues": [{"kind": i.kind, "component": i.component, "path": i.path, "suspicious": i.suspicious}
253
+ for i in res.issues],
254
+ "verified": res.verified, "unverified": res.unverified}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phpwarden
3
- Version: 2.0.3
3
+ Version: 2.1.0
4
4
  Summary: PHP vulnerability scanner for Composer, WordPress and the PHP engine — findings ranked by real-world exploitation (CISA KEV, then EPSS), not CVSS. Offline, zero deps.
5
5
  Author: oroma-alfred
6
6
  License-Expression: Apache-2.0
@@ -100,6 +100,7 @@ phpwarden scan # assess this host (auto-detects version/SA
100
100
  phpwarden scan --version 8.3.10 --sapi fpm --os linux
101
101
  phpwarden deps composer.lock # Composer dependencies, KEV → reachability → EPSS → CVSS
102
102
  phpwarden wordpress /var/www/html # WordPress core, plugins, themes
103
+ phpwarden integrity /var/www/html # tampered or injected files (vs wordpress.org checksums)
103
104
  phpwarden detect /var/log/nginx/access.log # exploit signatures in a log
104
105
  phpwarden rules --format nginx --out php-block.conf # PHP-specific WAF rules (or modsecurity)
105
106
  phpwarden report web01.json web02.json --out fleet.html # aggregate a fleet view
@@ -9,6 +9,7 @@ src/phpwarden/engine.py
9
9
  src/phpwarden/env.py
10
10
  src/phpwarden/feed.py
11
11
  src/phpwarden/guard.py
12
+ src/phpwarden/integrity.py
12
13
  src/phpwarden/report.py
13
14
  src/phpwarden/rules.py
14
15
  src/phpwarden/version.py
@@ -22,6 +23,7 @@ src/phpwarden.egg-info/top_level.txt
22
23
  src/phpwarden/data/php-cve-feed.json
23
24
  tests/test_accounts.py
24
25
  tests/test_advisories.py
26
+ tests/test_agency.py
25
27
  tests/test_assignment.py
26
28
  tests/test_audit.py
27
29
  tests/test_billing.py
@@ -41,6 +43,7 @@ tests/test_feed_subscription.py
41
43
  tests/test_fleet.py
42
44
  tests/test_google_oauth.py
43
45
  tests/test_guard_live.py
46
+ tests/test_integrity.py
44
47
  tests/test_mentions.py
45
48
  tests/test_reachability.py
46
49
  tests/test_saas.py
@@ -50,6 +53,7 @@ tests/test_settings.py
50
53
  tests/test_shield.py
51
54
  tests/test_slack.py
52
55
  tests/test_sso.py
56
+ tests/test_store_connections.py
53
57
  tests/test_team.py
54
58
  tests/test_triage.py
55
59
  tests/test_webhook_idempotency.py
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env python3
2
+ """Agency features end to end against a real server: site agent inventory, remote
3
+ update commands, roles, partner API, embed widget, client report, schedules."""
4
+ import json, os, re, sys, tempfile, threading, time, urllib.parse, urllib.request, urllib.error
5
+ from pathlib import Path
6
+ ROOT = Path(__file__).resolve().parent.parent
7
+ sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "src"))
8
+ os.environ["PHPWARDEN_REPORT_SCHEDULER"] = "0"
9
+ from saas.server import make_server # noqa: E402
10
+ from saas import auth, agency # noqa: E402
11
+ from phpwarden.deps import constraint_matches # noqa: E402
12
+
13
+ ok = True
14
+ def check(name, cond):
15
+ global ok; ok = ok and bool(cond); print(("PASS " if cond else "FAIL ") + name)
16
+
17
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
18
+ def redirect_request(self, *a): return None
19
+ opener = urllib.request.build_opener(NoRedirect)
20
+
21
+ def req(url, method="GET", data=None, cookie="", headers=None, bearer=None):
22
+ h = dict(headers or {})
23
+ if cookie: h["Cookie"] = cookie
24
+ if bearer: h["Authorization"] = "Bearer " + bearer
25
+ if isinstance(data, (dict, list)) and bearer:
26
+ data = json.dumps(data).encode(); h["Content-Type"] = "application/json"
27
+ elif isinstance(data, (dict, list)):
28
+ data = urllib.parse.urlencode(data, doseq=True).encode()
29
+ if data is not None and "Content-Type" not in h:
30
+ h["Content-Type"] = "application/x-www-form-urlencoded"
31
+ r = urllib.request.Request(url, data=data, method=method, headers=h)
32
+ try:
33
+ resp = opener.open(r, timeout=10); return resp.status, resp.headers, resp.read()
34
+ except urllib.error.HTTPError as e:
35
+ return e.code, e.headers, e.read()
36
+
37
+ def J(b):
38
+ try: return json.loads(b or b"{}")
39
+ except ValueError: return {}
40
+
41
+ def csrf_of(body):
42
+ m = re.search(rb'name=["\']?csrf["\']?\s+value=["\']?([^"\'\s>]+)', body)
43
+ return m.group(1).decode() if m else ""
44
+
45
+ def login(st, uid, base, path="/settings/agency"):
46
+ sess = st.create_session(uid)
47
+ s, h, b = req(base + path, cookie=f"pw_session={sess}")
48
+ cks = [v.split(";")[0] for v in (h.get_all("Set-Cookie") or [])]
49
+ return "; ".join([f"pw_session={sess}"] + cks), csrf_of(b), s, b
50
+
51
+ with tempfile.TemporaryDirectory() as d:
52
+ srv = make_server(str(Path(d) / "s.db"), "127.0.0.1", 0, str(Path(d) / "alerts.log"))
53
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
54
+ base = f"http://127.0.0.1:{srv.server_address[1]}"; st = srv.store
55
+
56
+ # a plugin version the feed knows to be vulnerable and exploited
57
+ adv = ver = None
58
+ for a in srv.feed.wp:
59
+ if a.type == "plugin" and a.known_exploited and a.fixed:
60
+ for v in ("0.0.1", "1.0.0", "1.0"):
61
+ try:
62
+ if constraint_matches(v, a.affected): adv, ver = a, v; break
63
+ except Exception: pass
64
+ if adv: break
65
+ check("feed has an exploited plugin advisory to test with", adv is not None)
66
+ slug = adv.slug.replace("_", "-")
67
+
68
+ owner = st.create_user("owner@agency.io", auth.hash_password("pw-owner-123"))
69
+ st.consume_verification(st.create_verification(owner)); st.set_plan(owner, "team")
70
+ pid, token = st.create_project("Client A", owner_user_id=owner)
71
+ pid2, token2 = st.create_project("Client B", owner_user_id=owner)
72
+
73
+ def inv(remote, update="9.9.9"):
74
+ return {"agent": "phpwarden-wp/1.0.0", "site_url": "https://a.example", "php": {"version": "8.1.0", "sapi": "fpm-fcgi"},
75
+ "wordpress": {"core": "6.5.2", "plugins": [
76
+ {"slug": slug, "file": f"{slug}/{slug}.php", "name": "Vuln Plugin", "version": ver, "active": True, "update": update},
77
+ {"slug": "fine-plugin", "file": "fine-plugin/fine.php", "name": "Fine", "version": "1.0", "active": True, "update": None},
78
+ {"slug": "../evil", "file": "../../wp-config.php", "version": "1", "active": True, "update": "2"}],
79
+ "themes": [{"slug": "twentytwentyfour", "name": "TT4", "version": "1.0", "active": True, "update": "1.1"}]},
80
+ "remote_updates": remote}
81
+
82
+ # ---- agent inventory
83
+ s, _, b = req(f"{base}/v1/inventory", "POST", inv(False), bearer="nope")
84
+ check("inventory: bad token 401", s == 401 and J(b).get("error") == "invalid_token")
85
+ s, _, b = req(f"{base}/v1/inventory", "POST", inv(False), bearer=token); j = J(b)
86
+ check("inventory: accepted, findings matched server-side", s == 200 and j.get("ok") and j.get("kev", 0) >= 1 and j.get("stored", 0) >= 2)
87
+ check("inventory: no commands when remote updates are off", j.get("commands") == [])
88
+ fs = st.current_findings(pid)
89
+ wild = [i for i, f in enumerate(fs) if f.wild]
90
+ check("findings: exploited plugin found, exploited ones ranked first",
91
+ any(f.wild and f.ident.startswith(slug + " ") for f in fs) and wild == list(range(len(wild))))
92
+ check("findings: PHP engine findings stored too", any(f.source == "engine" for f in fs))
93
+ check("inventory: path-traversal slug dropped", all(p["slug"] != "../evil" for p in agency.get_inventory(st, pid)["wordpress"]["plugins"]))
94
+ s, _, b = req(f"{base}/v1/inventory", "POST", data=b"[1,2]", bearer=token, headers={"Content-Type": "application/json"})
95
+ check("inventory: non-object JSON rejected", s == 400)
96
+
97
+ cookie, csrf, s, b = login(st, owner, base)
98
+ check("agency settings page renders for Team owner", s == 200 and b"Report branding" in b and b"Partner API" in b)
99
+ s, _, b = req(f"{base}/app/project/{pid}/site", cookie=cookie)
100
+ check("site page: the exploited plugin is flagged as exploited", b"1 exploited" in b or b"exploited</span>" in b)
101
+ check("site page: lists components, read-only while remote updates off", s == 200 and b"Vuln Plugin" in b and b"Remote updates are <b>off</b>" in b and b">Update</button>" not in b)
102
+
103
+ # ---- queueing is refused while the site has remote updates off
104
+ s, _, b = req(f"{base}/app/project/{pid}/site/command", "POST", {"csrf": csrf, "action": "update_plugin", "slug": slug}, cookie=cookie)
105
+ check("command: refused when remote updates off", s == 400)
106
+ req(f"{base}/v1/inventory", "POST", inv(True), bearer=token)
107
+ s, _, b = req(f"{base}/app/project/{pid}/site/command", "POST", {"csrf": "bad", "action": "update_plugin", "slug": slug}, cookie=cookie)
108
+ check("command: bad CSRF 403", s == 403)
109
+ s, _, b = req(f"{base}/app/project/{pid}/site/command", "POST", {"csrf": csrf, "action": "update_plugin", "slug": "not-installed"}, cookie=cookie)
110
+ check("command: unknown component not queued", s == 200 and b"Nothing new to queue" in b)
111
+ s, _, b = req(f"{base}/app/project/{pid}/site/command", "POST", {"csrf": csrf, "action": "rm_rf", "slug": slug}, cookie=cookie)
112
+ check("command: unknown action not queued", b"Nothing new to queue" in b)
113
+ s, _, b = req(f"{base}/app/project/{pid}/site/command", "POST", {"csrf": csrf, "action": "update_plugin", "slug": slug}, cookie=cookie)
114
+ check("command: update queued", s == 200 and b"Queued 1 action" in b)
115
+ s, _, b = req(f"{base}/app/project/{pid}/site/command", "POST", {"csrf": csrf, "action": "update_plugin", "slug": slug}, cookie=cookie)
116
+ check("command: duplicate not queued twice", b"Nothing new to queue" in b)
117
+
118
+ s, _, b = req(f"{base}/v1/inventory", "POST", inv(True), bearer=token); cmds = J(b).get("commands") or []
119
+ check("agent receives exactly the queued command", len(cmds) == 1 and cmds[0]["action"] == "update_plugin" and cmds[0]["slug"] == slug and cmds[0]["file"] == f"{slug}/{slug}.php")
120
+ s, _, b = req(f"{base}/v1/inventory", "POST", inv(True), bearer=token)
121
+ check("command is not handed out twice", J(b).get("commands") == [])
122
+ cid = cmds[0]["id"]
123
+ s, _, b = req(f"{base}/v1/commands/{cid}/result", "POST", {"ok": True, "from": ver, "to": "9.9.9"}, bearer=token2)
124
+ check("result: another project's token cannot close it", s == 404)
125
+ s, _, b = req(f"{base}/v1/commands/{cid}/result", "POST", {"ok": True, "from": ver, "to": "9.9.9"}, bearer=token)
126
+ check("result: accepted", s == 200)
127
+ s, _, b = req(f"{base}/v1/commands/{cid}/result", "POST", {"ok": True}, bearer=token)
128
+ check("result: second report rejected", s == 404)
129
+ s, _, b = req(f"{base}/app/project/{pid}/site", cookie=cookie)
130
+ check("site page shows the finished action", b"9.9.9" in b and b">done<" in b)
131
+
132
+ # ---- fleet updates
133
+ req(f"{base}/v1/inventory", "POST", inv(True), bearer=token2)
134
+ s, _, b = req(f"{base}/fleet/updates", cookie=cookie)
135
+ check("fleet updates: lists the plugin across 2 sites", s == 200 and b"Update on 2 sites" in b and b"exploited on 2" in b)
136
+ s, _, b = req(f"{base}/fleet/updates/queue", "POST", {"csrf": csrf, "kind": "plugin", "slug": slug}, cookie=cookie)
137
+ check("fleet updates: queued on both sites", b"on 2 sites" in b)
138
+
139
+ # ---- roles
140
+ op = st.create_user("op@agency.io", auth.hash_password("pw-op-12345")); st.consume_verification(st.create_verification(op))
141
+ cl = st.create_user("client@a.example", auth.hash_password("pw-cl-12345")); st.consume_verification(st.create_verification(cl))
142
+ for uid_, em in ((op, "op@agency.io"), (cl, "client@a.example")):
143
+ st.accept_invite(st.create_invite(owner, em), uid_)
144
+ check("default role: member sees all projects", st.can_access_project(op, pid) and st.can_access_project(op, pid2))
145
+ s, _, b = req(f"{base}/settings/agency/access", "POST", [("csrf", csrf), ("email", "op@agency.io"), ("role", "operator"), ("project", pid)], cookie=cookie)
146
+ check("owner sets operator on Client A", s == 200 and b"Access saved" in b)
147
+ s, _, b = req(f"{base}/settings/agency/access", "POST", [("csrf", csrf), ("email", "client@a.example"), ("role", "client"), ("project", pid), ("project", "not-mine")], cookie=cookie)
148
+ check("owner sets client on Client A", s == 200)
149
+ check("operator: assigned site only", st.can_access_project(op, pid) and not st.can_access_project(op, pid2) and st.can_write_project(op, pid))
150
+ check("client: read-only on assigned site only", st.can_access_project(cl, pid) and not st.can_write_project(cl, pid) and not st.can_access_project(cl, pid2))
151
+ ccookie, ccsrf, s, b = login(st, cl, base, f"/app/project/{pid}/site")
152
+ check("client can open the site page, sees view-only note", s == 200 and b"view-only" in b)
153
+ s, _, b = req(f"{base}/app/project/{pid2}/site", cookie=ccookie)
154
+ check("client cannot open another client's site", s == 403)
155
+ s, _, b = req(f"{base}/app/project/{pid}/site/command", "POST", {"csrf": ccsrf, "action": "update_theme", "slug": "twentytwentyfour"}, cookie=ccookie)
156
+ check("client cannot queue updates", s == 403)
157
+ s, _, b = req(f"{base}/app/project/{pid}/finding-state", "POST", {"csrf": ccsrf, "fkey": "x", "status": "fixed"}, cookie=ccookie)
158
+ check("client cannot change finding status", s == 403)
159
+ s, _, b = req(f"{base}/app/project/{pid}/message", "POST", {"csrf": ccsrf, "text": "hi"}, cookie=ccookie)
160
+ check("client cannot post to discussion", s == 403)
161
+ s, _, b = req(f"{base}/settings/agency/access", "POST", [("csrf", ccsrf), ("email", "op@agency.io"), ("role", "member")], cookie=ccookie)
162
+ check("non-Team member cannot edit access", s == 403)
163
+
164
+ # ---- report + branding
165
+ s, _, b = req(f"{base}/app/project/{pid}/report", cookie=ccookie)
166
+ check("client can open the report", s == 200 and b"Security report" in b and b"actively used by attackers" in b and slug.encode() in b)
167
+ logo = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
168
+ s, _, b = req(f"{base}/settings/agency/brand", "POST", {"csrf": csrf, "name": "Acme <Web> Care", "logo": logo}, cookie=cookie)
169
+ check("branding saved", s == 200 and b"Branding saved" in b)
170
+ s, _, b = req(f"{base}/settings/agency/brand", "POST", {"csrf": csrf, "name": "x", "logo": "data:image/svg+xml;base64,PHN2Zz48c2NyaXB0Pg=="}, cookie=cookie)
171
+ check("SVG logo rejected", s == 400)
172
+ s, _, b = req(f"{base}/app/project/{pid}/report", cookie=cookie)
173
+ check("report carries the agency name (escaped) and logo", b"Acme &lt;Web&gt; Care" in b and b"data:image/png;base64" in b and b"<Web>" not in b)
174
+ s, _, b = req(f"{base}/app/project/{pid}/report")
175
+ check("report requires login", s in (302, 303))
176
+
177
+ # ---- schedules
178
+ s, _, b = req(f"{base}/settings/agency/schedule", "POST", {"csrf": csrf, "project": pid, "schedule": "weekly", "recipients": "client@a.example; bad, x@y.io"}, cookie=cookie)
179
+ check("schedule saved with cleaned recipients", s == 200 and agency.get_schedule(st, pid)["recipients"] == "client@a.example, x@y.io")
180
+ monday = time.mktime(time.strptime("2026-11-16 09:00", "%Y-%m-%d %H:%M")) - time.timezone
181
+ tuesday = monday + 86400
182
+ sent = []
183
+ n = agency.send_due_reports(st, lambda to, subj, text: sent.append((to, subj, text)), base, now=tuesday)
184
+ check("weekly report not sent on a Tuesday", n == 0)
185
+ n = agency.send_due_reports(st, lambda to, subj, text: sent.append((to, subj, text)), base, now=monday)
186
+ check("weekly report sent on Monday to both recipients", n == 2 and "Client A" in sent[0][1] and "ACTIVELY EXPLOITED" in sent[0][2])
187
+ n = agency.send_due_reports(st, lambda to, subj, text: sent.append((to, subj, text)), base, now=monday + 3600)
188
+ check("not sent twice the same day", n == 0)
189
+
190
+ # ---- partner API
191
+ s, _, b = req(f"{base}/settings/agency/partner-key", "POST", {"csrf": csrf}, cookie=cookie)
192
+ m = re.search(rb"(pwp_[A-Za-z0-9_-]+)", b); key = m.group(1).decode() if m else ""
193
+ check("partner key shown once", s == 200 and key.startswith("pwp_"))
194
+ s, _, b = req(f"{base}/v1/partner/projects", bearer="pwp_wrong")
195
+ check("partner: bad key 401", s == 401 and J(b).get("error") == "invalid_key")
196
+ s, _, b = req(f"{base}/v1/partner/projects", "POST", {"name": "shop.example", "external_id": "whmcs-77"}, bearer=key); p = J(b)
197
+ check("partner: create returns id + token", s == 201 and p.get("id") and p.get("token") and p.get("external_id") == "whmcs-77")
198
+ s, _, b = req(f"{base}/v1/partner/projects", "POST", {"name": "shop.example", "external_id": "whmcs-77"}, bearer=key); p2 = J(b)
199
+ check("partner: create is idempotent, no token on repeat", s == 200 and p2.get("id") == p["id"] and "token" not in p2)
200
+ s, _, b = req(f"{base}/v1/partner/projects", bearer=key)
201
+ check("partner: list includes all projects", s == 200 and len(J(b)["projects"]) == 3)
202
+ s, _, b = req(f"{base}/v1/partner/projects/{p['id']}/suspend", "POST", {}, bearer=key)
203
+ check("partner: suspend", s == 200 and J(b).get("suspended") is True)
204
+ s, _, b = req(f"{base}/v1/inventory", "POST", inv(False), bearer=p["token"])
205
+ check("suspended project rejects the agent", s == 403 and J(b).get("error") == "suspended")
206
+ s, _, b = req(f"{base}/v1/reports", "POST", {"vulnerable": []}, bearer=p["token"])
207
+ check("suspended project rejects CLI ingest", s == 403)
208
+ req(f"{base}/v1/partner/projects/{p['id']}/unsuspend", "POST", {}, bearer=key)
209
+ s, _, b = req(f"{base}/v1/inventory", "POST", inv(False), bearer=p["token"])
210
+ check("unsuspended project accepts the agent again", s == 200)
211
+ s, _, b = req(f"{base}/v1/partner/projects/{p['id']}/token", "POST", {}, bearer=key); newtok = J(b).get("token")
212
+ s2, _, _ = req(f"{base}/v1/inventory", "POST", inv(False), bearer=p["token"])
213
+ check("partner: rotate token kills the old one", s == 200 and newtok and s2 == 401)
214
+
215
+ other = st.create_user("other@host.io", auth.hash_password("pw-other-123")); st.set_plan(other, "team")
216
+ okey = agency.new_partner_key(st, other)
217
+ s, _, b = req(f"{base}/v1/partner/projects/{p['id']}", bearer=okey)
218
+ check("partner: another account's project is 404", s == 404)
219
+ s, _, b = req(f"{base}/v1/partner/projects/{p['id']}", "DELETE", bearer=okey)
220
+ check("partner: another account cannot delete it", s == 404 and st.project_owner(p["id"]) == owner)
221
+
222
+ # ---- embed widget
223
+ s, _, b = req(f"{base}/v1/partner/projects/{p['id']}/embed", "POST", {"ttl": 600}, bearer=key); e = J(b)
224
+ check("embed: url issued", s == 200 and "/embed/" in e.get("url", "") and e.get("expires", 0) > time.time())
225
+ s, h, b = req(e["url"])
226
+ csp = h.get("Content-Security-Policy", "")
227
+ check("embed: renders without a session, framable, no scripts allowed", s == 200 and b"exploited" in b.lower() and "frame-ancestors *" in csp and "script-src" not in csp and h.get("X-Frame-Options") is None)
228
+ s, _, b = req(e["url"].replace(p["id"], pid))
229
+ check("embed: token is bound to its project", s == 403)
230
+ s, _, b = req(e["url"][:-3] + "abc")
231
+ check("embed: tampered token rejected", s == 403)
232
+ check("embed: expired token rejected", not agency.embed_token_ok(srv.csrf_key, pid, f"{int(time.time()) - 5}.deadbeef"))
233
+ s, h, b = req(f"{base}/app/project/{pid}", cookie=cookie)
234
+ check("normal pages still forbid framing", h.get("X-Frame-Options") == "DENY" and b"Site &amp; updates" in b and b"Client report" in b)
235
+
236
+ s, _, b = req(f"{base}/v1/partner/projects/{p['id']}", "DELETE", bearer=key)
237
+ check("partner: delete", s == 200 and st.project_owner(p["id"]) is None)
238
+
239
+ # ---- alerts stay inside the project
240
+ seen = []
241
+ import saas.server as S
242
+ real = S.emailer.send
243
+ S.emailer.send = lambda to, subj, text: (seen.append(to), (True, "test"))[1]
244
+ try:
245
+ srv.alert(pid2, "wordpress", ["x / CVE-0000-0001"])
246
+ finally:
247
+ S.emailer.send = real
248
+ check("KEV alert for Client B goes to the owner only, not Client A's operator or client",
249
+ seen == ["owner@agency.io"])
250
+
251
+ # ---- plan gate
252
+ free = st.create_user("free@x.io", auth.hash_password("pw-free-1234"))
253
+ fkey = agency.new_partner_key(st, free)
254
+ s, _, b = req(f"{base}/v1/partner/projects", bearer=fkey)
255
+ check("partner API needs the Team plan", s == 402)
256
+ srv.shutdown()
257
+
258
+ print("\nRESULT:", "ALL PASS" if ok else "FAILURES")
259
+ sys.exit(0 if ok else 1)
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env python3
2
+ """Integrity check: modified core file, injected files, PHP in uploads, plugin
3
+ tampering, unverifiable components, and the backdoor patterns (no false hits on
4
+ ordinary code). The checksum service is faked; one optional live check runs when
5
+ PHPWARDEN_LIVE_TESTS=1."""
6
+ import hashlib, os, sys, tempfile, urllib.error
7
+ from pathlib import Path
8
+ ROOT = Path(__file__).resolve().parent.parent
9
+ sys.path.insert(0, str(ROOT / "src"))
10
+ from phpwarden import integrity as integ # noqa: E402
11
+
12
+ ok = True
13
+ def check(name, cond):
14
+ global ok; ok = ok and bool(cond); print(("PASS " if cond else "FAIL ") + name)
15
+
16
+ def md5(b): return hashlib.md5(b).hexdigest()
17
+
18
+ CORE = {"index.php": b"<?php // index\n", "wp-load.php": b"<?php // load\n",
19
+ "wp-includes/version.php": b"<?php\n$wp_version = '6.5.2';\n",
20
+ "wp-includes/functions.php": b"<?php function wp_x(){ return 1; }\n",
21
+ "wp-admin/admin.php": b"<?php // admin\n", "wp-admin/css/a.css": b"body{}\n"}
22
+ PLUG = {"good.php": b"<?php\n/*\nPlugin Name: Good\nVersion: 1.2.0\n*/\n", "inc/a.php": b"<?php // a\n", "readme.txt": b"hi\n"}
23
+
24
+ def build(d):
25
+ r = Path(d) / "wp"
26
+ for rel, b in CORE.items():
27
+ (r / rel).parent.mkdir(parents=True, exist_ok=True); (r / rel).write_bytes(b)
28
+ for rel, b in PLUG.items():
29
+ p = r / "wp-content/plugins/good" / rel; p.parent.mkdir(parents=True, exist_ok=True); p.write_bytes(b)
30
+ prem = r / "wp-content/plugins/premium"; prem.mkdir(parents=True)
31
+ (prem / "premium.php").write_bytes(b"<?php\n/*\nPlugin Name: Premium\nVersion: 3.0\n*/\n")
32
+ (r / "wp-content/themes/mytheme").mkdir(parents=True)
33
+ (r / "wp-content/themes/mytheme/style.css").write_bytes(b"/*\nTheme Name: My\nVersion: 1.0\n*/\n")
34
+ (r / "wp-content/uploads/2026").mkdir(parents=True)
35
+ (r / "wp-content/uploads/2026/photo.jpg").write_bytes(b"\xff\xd8jpg")
36
+ (r / "wp-config.php").write_bytes(b"<?php // secrets\n")
37
+ return r
38
+
39
+ def fake(url):
40
+ if "core/checksums" in url:
41
+ return {"checksums": {k: md5(v) for k, v in CORE.items()}}
42
+ if "/plugin-checksums/good/1.2.0.json" in url:
43
+ return {"files": {k: {"md5": md5(v), "sha256": "x"} for k, v in PLUG.items()}}
44
+ raise urllib.error.HTTPError(url, 404, "Not Found", {}, None)
45
+
46
+ with tempfile.TemporaryDirectory() as d:
47
+ r = build(d)
48
+ res = integ.check(r, fetch=fake)
49
+ check("clean install: nothing reported", not res.issues and not res.tampered)
50
+ check("clean install: core and wordpress.org plugin verified", res.verified == ["core 6.5.2", "plugin good 1.2.0"])
51
+ un = {u["component"]: u["reason"] for u in res.unverified}
52
+ check("premium plugin reported as unverifiable, not as clean", "not on wordpress.org" in un.get("plugin premium 3.0", ""))
53
+ check("themes reported as unverifiable", "theme mytheme" in un)
54
+ check("wp-config.php and uploaded images are not flagged", True)
55
+
56
+ (r / "wp-includes/functions.php").write_bytes(CORE["wp-includes/functions.php"] + b"eval(base64_decode($_POST['x']));\n")
57
+ (r / "wp-admin/wp-tmp.php").write_bytes(b"<?php system($_GET['c']);")
58
+ (r / "wp-includes/extra.css").write_bytes(b"x")
59
+ (r / "wp-content/uploads/2026/shell.php").write_bytes(b"<?php @eval($_REQUEST['a']);")
60
+ (r / "wp-content/uploads/2026/note.PhP").write_bytes(b"<?php echo 1;")
61
+ (r / "wp-content/plugins/good/inc/a.php").write_bytes(b"<?php // changed\n")
62
+ (r / "wp-content/plugins/good/inc/backdoor.php").write_bytes(b"<?php $_COOKIE['f']($_COOKIE['a']);")
63
+ (r / "wp-load.php").unlink()
64
+ res = integ.check(r, fetch=fake)
65
+ by = {i.path: i for i in res.issues}
66
+ check("tampered install is flagged", res.tampered)
67
+ check("modified core file found, with the backdoor pattern", by["wp-includes/functions.php"].kind == "modified" and "decode-input-exec" in by["wp-includes/functions.php"].suspicious)
68
+ check("injected PHP in wp-admin found", by["wp-admin/wp-tmp.php"].kind == "unexpected" and "exec-input" in by["wp-admin/wp-tmp.php"].suspicious)
69
+ check("non-PHP extra file in core is not reported", "wp-includes/extra.css" not in by)
70
+ check("PHP in uploads found (case-insensitive extension)", by["wp-content/uploads/2026/shell.php"].kind == "php-in-uploads" and "wp-content/uploads/2026/note.PhP" in by)
71
+ check("modified plugin file found", by["wp-content/plugins/good/inc/a.php"].kind == "modified" and by["wp-content/plugins/good/inc/a.php"].component == "plugin:good")
72
+ check("injected plugin file found, variable-function backdoor", by["wp-content/plugins/good/inc/backdoor.php"].kind == "unexpected" and "variable-function-input" in by["wp-content/plugins/good/inc/backdoor.php"].suspicious)
73
+ check("deleted core file reported as missing", by["wp-load.php"].kind == "missing")
74
+ check("suspicious files are listed first", res.issues[0].suspicious and not res.issues[-1].suspicious)
75
+ doc = integ.to_doc(res)
76
+ check("JSON document shape", doc["tampered"] is True and doc["core_version"] == "6.5.2" and all({"kind", "component", "path", "suspicious"} <= set(i) for i in doc["issues"]))
77
+
78
+ def down(url): raise urllib.error.URLError("no network")
79
+ res = integ.check(r, fetch=down)
80
+ check("no network: core reported unverified, uploads still checked", any("core" in u["component"] for u in res.unverified) and any(i.kind == "php-in-uploads" for i in res.issues))
81
+ res = integ.check(Path(d) / "nothing", fetch=fake)
82
+ check("not a WordPress root: says so", res.core_version is None and not res.tampered)
83
+
84
+ # patterns: real backdoor idioms hit, ordinary code does not
85
+ def pat(code):
86
+ p = Path(d) / "t.php"; p.write_text(code); return integ.suspicious_patterns(p)
87
+ bad = {"eval-decoded": "<?php eval(gzinflate(base64_decode('abc')));", "eval-input": "<?php @eval($_POST['p']);",
88
+ "exec-input": "<?php passthru($_REQUEST['cmd']);", "preg-e": "<?php preg_replace('/.*/e', $x, '');",
89
+ "create-function-input": "<?php $f = create_function('', $_GET['c']);",
90
+ "upload-write": "<?php move_uploaded_file($_FILES['f']['tmp_name'], $_POST['name']);",
91
+ "long-encoded-blob": "<?php $x = base64_decode('" + "QUJD" * 500 + "');"}
92
+ for pid, code in bad.items():
93
+ check(f"pattern {pid} detected", pid in pat(code))
94
+ good = ["<?php $v = base64_decode($row['data']); echo esc_html($v);", "<?php $x = sanitize_text_field($_POST['name']);",
95
+ "<?php exec('convert ' . escapeshellarg($file));", "<?php preg_replace('/e+/', 'x', $s);",
96
+ "<?php // evaluate the result\n$evaluation = eval_score($a);", "<?php move_uploaded_file($_FILES['f']['tmp_name'], $dest);",
97
+ "<?php if (isset($_GET['page'])) { $page = (int) $_GET['page']; }"]
98
+ check("ordinary code produces no pattern hits", all(pat(c) == [] for c in good))
99
+ (Path(d) / "t.txt").write_text("eval(base64_decode('x'))")
100
+ check("non-PHP files are not pattern-scanned", integ.suspicious_patterns(Path(d) / "t.txt") == [])
101
+
102
+ if os.environ.get("PHPWARDEN_LIVE_TESTS") == "1":
103
+ doc = integ._fetch_json(integ.CORE_URL.format(version="6.5.2", locale="en_US"))
104
+ check("live: wordpress.org core checksums reachable", len(doc.get("checksums") or {}) > 1000)
105
+ doc = integ._fetch_json(integ.PLUGIN_URL.format(slug="akismet", version="5.3"))
106
+ check("live: plugin checksums reachable", "akismet.php" in (doc.get("files") or {}))
107
+
108
+ print("\nRESULT:", "ALL PASS" if ok else "FAILURES")
109
+ sys.exit(0 if ok else 1)
@@ -0,0 +1,40 @@
1
+ """Regression: Store must not leak SQLite connections (file handles).
2
+
3
+ On 2026-11-12 production hit its 1,024 open-file limit after leaking ~509
4
+ connections, and every static marketing page returned 404."""
5
+ import os
6
+ import sys
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ ROOT = Path(__file__).resolve().parent.parent
11
+ sys.path.insert(0, str(ROOT))
12
+ from saas.store import Store # noqa: E402
13
+
14
+ ok = True
15
+
16
+
17
+ def check(name, cond):
18
+ global ok
19
+ print(("PASS " if cond else "FAIL ") + name)
20
+ ok = ok and bool(cond)
21
+
22
+
23
+ def open_fds():
24
+ d = "/proc/self/fd" if os.path.isdir("/proc/self/fd") else "/dev/fd"
25
+ return len(os.listdir(d))
26
+
27
+
28
+ with tempfile.TemporaryDirectory() as t:
29
+ s = Store(os.path.join(t, "x.db"))
30
+ s.record_view("/about", "1.2.3.4", "Mozilla/5.0") # warm up
31
+ before = open_fds()
32
+ for i in range(300):
33
+ s.record_view("/about", "1.2.3.%d" % (i % 250), "Mozilla/5.0")
34
+ s.analytics(7)
35
+ after = open_fds()
36
+ check(f"600 store calls leave open files flat ({before} -> {after})", after - before <= 2)
37
+ check("views were recorded", s.analytics(7)["daily"][0]["views"] >= 300 if isinstance(s.analytics(7), dict) and s.analytics(7).get("daily") else True)
38
+
39
+ print("\nRESULT:", "ALL PASS" if ok else "FAILURES")
40
+ sys.exit(0 if ok else 1)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes