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.
- {phpwarden-2.0.3 → phpwarden-2.1.0}/PKG-INFO +2 -1
- {phpwarden-2.0.3 → phpwarden-2.1.0}/README.md +1 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/pyproject.toml +1 -1
- {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/__init__.py +1 -1
- {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/cli.py +41 -0
- phpwarden-2.1.0/src/phpwarden/integrity.py +254 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden.egg-info/PKG-INFO +2 -1
- {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden.egg-info/SOURCES.txt +4 -0
- phpwarden-2.1.0/tests/test_agency.py +259 -0
- phpwarden-2.1.0/tests/test_integrity.py +109 -0
- phpwarden-2.1.0/tests/test_store_connections.py +40 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/LICENSE +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/setup.cfg +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/data/php-cve-feed.json +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/deps.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/detect.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/engine.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/env.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/feed.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/guard.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/report.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/rules.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/version.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/vulndb.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden/wordpress.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden.egg-info/dependency_links.txt +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden.egg-info/entry_points.txt +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/src/phpwarden.egg-info/top_level.txt +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_accounts.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_advisories.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_assignment.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_audit.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_billing.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_cli_edges.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_connect_flow.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_dashboard_states.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_deps_and_rules.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_detection_validation.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_developer.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_discussion.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_e2e_readiness.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_email.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_engine.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_engine_feed.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_feed_license.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_feed_subscription.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_fleet.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_google_oauth.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_guard_live.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_mentions.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_reachability.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_saas.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_saas_billing.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_server.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_settings.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_shield.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_slack.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_sso.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_team.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_triage.py +0 -0
- {phpwarden-2.0.3 → phpwarden-2.1.0}/tests/test_webhook_idempotency.py +0 -0
- {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
|
+
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
|
|
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"
|
|
@@ -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
|
+
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 <Web> 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 & 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|