wpguard 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wpguard/__init__.py +3 -0
- wpguard/__main__.py +5 -0
- wpguard/audit.py +100 -0
- wpguard/backup.py +193 -0
- wpguard/behavior.py +70 -0
- wpguard/cli.py +263 -0
- wpguard/config.py +180 -0
- wpguard/dbscan.py +94 -0
- wpguard/destinations.py +169 -0
- wpguard/diffs.py +108 -0
- wpguard/discover.py +150 -0
- wpguard/feeds.py +135 -0
- wpguard/fix.py +224 -0
- wpguard/lockfile.py +100 -0
- wpguard/monitor.py +144 -0
- wpguard/recovery.py +1105 -0
- wpguard/remote.py +48 -0
- wpguard/report.py +66 -0
- wpguard/scan.py +29 -0
- wpguard/scanner.py +130 -0
- wpguard/schedule.py +136 -0
- wpguard/signatures.py +138 -0
- wpguard/updates.py +249 -0
- wpguard/util.py +56 -0
- wpguard/vulns.py +32 -0
- wpguard/wpcli.py +68 -0
- wpguard/wpscan.py +49 -0
- wpguard/yarascan.py +32 -0
- wpguard-0.2.1.dist-info/METADATA +212 -0
- wpguard-0.2.1.dist-info/RECORD +33 -0
- wpguard-0.2.1.dist-info/WHEEL +4 -0
- wpguard-0.2.1.dist-info/entry_points.txt +3 -0
- wpguard-0.2.1.dist-info/licenses/LICENSE +21 -0
wpguard/__init__.py
ADDED
wpguard/__main__.py
ADDED
wpguard/audit.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""The `audit` command: full inventory + findings, exported as Markdown or JSON (secrets redacted)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from wpguard import __version__, wpcli
|
|
11
|
+
from wpguard.report import Rep
|
|
12
|
+
from wpguard.scan import scan
|
|
13
|
+
from wpguard.signatures import PHP_EXT
|
|
14
|
+
|
|
15
|
+
SETTINGS = (
|
|
16
|
+
"siteurl", "home", "blogname", "admin_email", "default_role", "users_can_register",
|
|
17
|
+
"permalink_structure", "WPLANG", "template", "stylesheet",
|
|
18
|
+
) # fmt: skip
|
|
19
|
+
DROPINS = ("advanced-cache.php", "object-cache.php", "db.php", "db-error.php", "maintenance.php", "sunrise.php")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def audit(site: Path, a) -> Rep:
|
|
23
|
+
"""Inventory + scan findings as a dict in rep.data (printed by the CLI, not logged)."""
|
|
24
|
+
rep = Rep(site, quiet=True)
|
|
25
|
+
sc = scan(site, a, Rep(site, quiet=True))
|
|
26
|
+
cfg = {}
|
|
27
|
+
if (cp := site / "wp-config.php").exists():
|
|
28
|
+
for k, v in re.findall(r"define\(\s*['\"](\w+)['\"]\s*,\s*([^;]*?)\)\s*;", cp.read_text(errors="replace")):
|
|
29
|
+
cfg[k] = "[redacted]" if re.search(r"PASSWORD|KEY|SALT|DB_USER", k) else v.strip()
|
|
30
|
+
nphp = sum(f.lower().endswith(tuple(PHP_EXT)) for _, _, fs in os.walk(site) for f in fs)
|
|
31
|
+
users = wpcli.jget(site, "user", "list", "--fields=ID,user_login,user_email,roles,user_registered", "--format=json")
|
|
32
|
+
wpc = site / "wp-content"
|
|
33
|
+
try:
|
|
34
|
+
php = subprocess.run(["php", "-r", "echo PHP_VERSION;"], capture_output=True, text=True, check=False).stdout
|
|
35
|
+
except OSError:
|
|
36
|
+
php = "unknown (php not found)"
|
|
37
|
+
rep.data = {
|
|
38
|
+
"site": str(site),
|
|
39
|
+
"generated": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
40
|
+
"tool": f"wpguard {__version__}",
|
|
41
|
+
"wordpress": wpcli.wp(site, "core", "version").stdout.strip(),
|
|
42
|
+
"php": php,
|
|
43
|
+
"settings": {k: wpcli.wp(site, "option", "get", k).stdout.strip() for k in SETTINGS},
|
|
44
|
+
"plugins": wpcli.jget(site, "plugin", "list", "--format=json", "--fields=name,version,status,update"),
|
|
45
|
+
"themes": wpcli.jget(site, "theme", "list", "--format=json", "--fields=name,version,status,update"),
|
|
46
|
+
"users": {"count": len(users), "by_role": dict(Counter(u["roles"] for u in users)), "list": users[:500]},
|
|
47
|
+
"cron_hooks": sorted(
|
|
48
|
+
{e["hook"] for e in wpcli.jget(site, "cron", "event", "list", "--fields=hook", "--format=json")}
|
|
49
|
+
),
|
|
50
|
+
"mu_plugins": sorted(f.name for f in (wpc / "mu-plugins").glob("*.php")),
|
|
51
|
+
"dropins": [n for n in DROPINS if (wpc / n).exists()],
|
|
52
|
+
"wp_config": cfg,
|
|
53
|
+
"php_files": nphp,
|
|
54
|
+
"findings": sc.hits,
|
|
55
|
+
}
|
|
56
|
+
return rep
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def md_table(rows: list[dict], cols: list[str]) -> str:
|
|
60
|
+
if not rows:
|
|
61
|
+
return "_none_\n"
|
|
62
|
+
|
|
63
|
+
def cell(v: object) -> str:
|
|
64
|
+
return str(v).replace("|", "\\|").replace("\n", " ")
|
|
65
|
+
|
|
66
|
+
lines = ["| " + " | ".join(cols) + " |", "|" + "---|" * len(cols)]
|
|
67
|
+
lines += ["| " + " | ".join(cell(r.get(c, "")) for c in cols) + " |" for r in rows]
|
|
68
|
+
return "\n".join(lines) + "\n"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def audit_md(d: dict) -> str:
|
|
72
|
+
def kv(m: dict) -> str:
|
|
73
|
+
return md_table([{"key": k, "value": v} for k, v in m.items()], ["key", "value"])
|
|
74
|
+
|
|
75
|
+
u = d["users"]
|
|
76
|
+
return "\n".join(
|
|
77
|
+
[
|
|
78
|
+
f"# WordPress audit: {d['site']}",
|
|
79
|
+
f"_generated {d['generated']} by {d['tool']}_\n",
|
|
80
|
+
(
|
|
81
|
+
f"## Overview\n\n- WordPress: {d['wordpress']}\n- PHP: {d['php']}\n- PHP files: {d['php_files']}\n"
|
|
82
|
+
f"- Findings: {len(d['findings'])}\n"
|
|
83
|
+
),
|
|
84
|
+
"## Settings\n",
|
|
85
|
+
kv(d["settings"]),
|
|
86
|
+
f"## Findings ({len(d['findings'])})\n",
|
|
87
|
+
md_table(d["findings"], ["kind", "what", "why"]),
|
|
88
|
+
"## Plugins\n",
|
|
89
|
+
md_table(d["plugins"], ["name", "version", "status", "update"]),
|
|
90
|
+
"## Themes\n",
|
|
91
|
+
md_table(d["themes"], ["name", "version", "status", "update"]),
|
|
92
|
+
f"## Users ({u['count']}) by role: {u['by_role']}\n",
|
|
93
|
+
md_table(u["list"], ["ID", "user_login", "user_email", "roles", "user_registered"]),
|
|
94
|
+
f"## Must-use plugins\n\n{', '.join(d['mu_plugins']) or '_none_'}\n",
|
|
95
|
+
f"## Drop-ins\n\n{', '.join(d['dropins']) or '_none_'}\n",
|
|
96
|
+
f"## Cron hooks ({len(d['cron_hooks'])})\n\n{', '.join(d['cron_hooks']) or '_none_'}\n",
|
|
97
|
+
"## wp-config.php constants (secrets redacted)\n",
|
|
98
|
+
kv(d["wp_config"]),
|
|
99
|
+
]
|
|
100
|
+
)
|
wpguard/backup.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""backup / verify / restore: DB dump + site files in a zstd tar, optional age encryption, multi-destination upload."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tarfile
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from wpguard import wpcli
|
|
12
|
+
from wpguard.destinations import DestError, Local, parse, upload_all
|
|
13
|
+
from wpguard.report import Rep
|
|
14
|
+
from wpguard.util import stamp
|
|
15
|
+
|
|
16
|
+
SKIP_UPLOADS = "site/wp-content/uploads"
|
|
17
|
+
CHUNK = 1024 * 1024
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sha256(p: Path) -> str:
|
|
21
|
+
with p.open("rb") as fh:
|
|
22
|
+
return hashlib.file_digest(fh, "sha256").hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def age_encrypt(src: Path, recipient: str) -> Path:
|
|
26
|
+
if not shutil.which("age"):
|
|
27
|
+
msg = "--encrypt-to needs the `age` binary (https://age-encryption.org)"
|
|
28
|
+
raise DestError(msg)
|
|
29
|
+
out = src.with_name(src.name + ".age")
|
|
30
|
+
r = subprocess.run(["age", "-r", recipient, "-o", str(out), str(src)], capture_output=True, text=True, check=False)
|
|
31
|
+
if r.returncode:
|
|
32
|
+
msg = f"age failed: {r.stderr.strip()}"
|
|
33
|
+
raise DestError(msg)
|
|
34
|
+
src.unlink()
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def age_decrypt(src: Path, identity: str, dst: Path) -> None:
|
|
39
|
+
r = subprocess.run(
|
|
40
|
+
["age", "-d", "-i", identity, "-o", str(dst), str(src)], capture_output=True, text=True, check=False
|
|
41
|
+
)
|
|
42
|
+
if r.returncode:
|
|
43
|
+
msg = f"age decrypt failed: {r.stderr.strip()}"
|
|
44
|
+
raise DestError(msg)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def write_archive(site: Path, out: Path, *, uploads: bool) -> None:
|
|
48
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
49
|
+
sql = Path(tmp) / "db.sql"
|
|
50
|
+
r = wpcli.wp(site, "db", "export", str(sql))
|
|
51
|
+
if r.returncode:
|
|
52
|
+
sys.exit(f"db export failed, aborting: {r.stderr.strip()}")
|
|
53
|
+
with tarfile.open(out, "x:zst") as t: # "x": never overwrite an existing backup
|
|
54
|
+
t.add(sql, arcname="db.sql")
|
|
55
|
+
t.add(site, arcname="site", filter=lambda i: None if not uploads and i.name.startswith(SKIP_UPLOADS) else i)
|
|
56
|
+
out.chmod(0o600) # holds wp-config.php and the DB
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def verify_archive(arc: Path, identity: str | None = None) -> list[str]:
|
|
60
|
+
"""Problems found in a backup (empty list = good): checksum sidecar, readable tar, plausible db.sql, WP files."""
|
|
61
|
+
problems: list[str] = []
|
|
62
|
+
if (side := arc.with_name(arc.name + ".sha256")).exists():
|
|
63
|
+
if sha256(arc) != side.read_text().split()[0]:
|
|
64
|
+
return [f"checksum mismatch for {arc.name}"]
|
|
65
|
+
else:
|
|
66
|
+
problems.append("no .sha256 sidecar (cannot detect corruption of the file itself)")
|
|
67
|
+
if arc.suffix == ".age":
|
|
68
|
+
if not identity:
|
|
69
|
+
return [*problems, "encrypted: only the checksum was verified (pass --age-identity to verify contents)"]
|
|
70
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
71
|
+
plain = Path(tmp) / arc.name.removesuffix(".age")
|
|
72
|
+
age_decrypt(arc, identity, plain)
|
|
73
|
+
return [p for p in verify_archive(plain) if "sidecar" not in p]
|
|
74
|
+
seen = set()
|
|
75
|
+
try:
|
|
76
|
+
with tarfile.open(arc, "r:zst") as t:
|
|
77
|
+
for m in t:
|
|
78
|
+
seen.add(m.name)
|
|
79
|
+
if m.isfile():
|
|
80
|
+
f = t.extractfile(m)
|
|
81
|
+
head = f.read(CHUNK) if f else b""
|
|
82
|
+
while f and f.read(CHUNK):
|
|
83
|
+
pass
|
|
84
|
+
if m.name == "db.sql" and not (m.size and (b"CREATE TABLE" in head or b"INSERT INTO" in head)):
|
|
85
|
+
problems.append("db.sql is empty or does not look like a MySQL dump")
|
|
86
|
+
except (tarfile.TarError, OSError, EOFError, ValueError) as e:
|
|
87
|
+
return [*problems, f"archive unreadable: {e}"]
|
|
88
|
+
problems += [f"missing {need} in archive" for need in ("db.sql", "site/wp-load.php") if need not in seen]
|
|
89
|
+
return problems
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def make_backup(site: Path, a, rep: Rep, *, uploads: bool = True, local_only: bool = False) -> Path:
|
|
93
|
+
"""Create the archive, optionally encrypt/verify, upload to every destination, apply retention."""
|
|
94
|
+
specs = [] if local_only else list(a.to or [])
|
|
95
|
+
dests = [parse(s) for s in specs]
|
|
96
|
+
out_dir = Path(a.out) if a.out else None
|
|
97
|
+
local = [d for d in dests if isinstance(d, Local)]
|
|
98
|
+
if not dests or (out_dir and not local):
|
|
99
|
+
dests.insert(0, Local(out_dir or site.parent))
|
|
100
|
+
staging_is_temp = False
|
|
101
|
+
if local_only or any(isinstance(d, Local) for d in dests):
|
|
102
|
+
staging = next(d.dir for d in dests if isinstance(d, Local)).resolve()
|
|
103
|
+
else:
|
|
104
|
+
staging, staging_is_temp = Path(tempfile.mkdtemp(prefix="wpguard-")), True
|
|
105
|
+
if staging == site or site in staging.parents:
|
|
106
|
+
sys.exit("backup destination must be outside the site directory")
|
|
107
|
+
staging.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
arc = staging / f"wpguard-backup-{site.name}-{stamp()}.tar.zst"
|
|
109
|
+
write_archive(site, arc, uploads=uploads)
|
|
110
|
+
if a.encrypt_to:
|
|
111
|
+
arc = age_encrypt(arc, a.encrypt_to)
|
|
112
|
+
arc.chmod(0o600)
|
|
113
|
+
digest = sha256(arc)
|
|
114
|
+
side = arc.with_name(arc.name + ".sha256")
|
|
115
|
+
side.write_text(f"{digest} {arc.name}\n")
|
|
116
|
+
rep.log(f" backup -> {arc} ({arc.stat().st_size // 1024} KiB, sha256 {digest[:12]}...)")
|
|
117
|
+
if a.verify:
|
|
118
|
+
problems = [p for p in verify_archive(arc, a.age_identity) if "sidecar" not in p]
|
|
119
|
+
for p in problems:
|
|
120
|
+
rep.log(f" VERIFY FAILED: {p}")
|
|
121
|
+
if problems:
|
|
122
|
+
rep.exit = 1
|
|
123
|
+
return arc
|
|
124
|
+
rep.log(" verified: archive readable, db.sql plausible, WordPress files present")
|
|
125
|
+
for label, result in upload_all(dests, [arc, side], site.name, a.keep or 0).items():
|
|
126
|
+
rep.log(f" {label}: {result}")
|
|
127
|
+
if result.startswith("FAILED"):
|
|
128
|
+
rep.exit = 1
|
|
129
|
+
if staging_is_temp and not a.keep_local and not rep.exit:
|
|
130
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
131
|
+
elif staging_is_temp:
|
|
132
|
+
rep.log(f" local copy kept in {staging}")
|
|
133
|
+
return arc
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def backup(site: Path, a) -> Rep:
|
|
137
|
+
rep = Rep(site)
|
|
138
|
+
make_backup(site, a, rep, uploads=not a.no_uploads)
|
|
139
|
+
return rep
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def latest(site: Path, a) -> Path | None:
|
|
143
|
+
if a.src:
|
|
144
|
+
return Path(a.src)
|
|
145
|
+
base = Path(a.out) if a.out else site.parent
|
|
146
|
+
found = sorted(p for p in base.glob(f"wpguard-backup-{site.name}-*.tar.zst*") if not p.name.endswith(".sha256"))
|
|
147
|
+
return found[-1] if found else None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def verify(site: Path, a) -> Rep:
|
|
151
|
+
rep = Rep(site)
|
|
152
|
+
if not (arc := latest(site, a)) or not arc.is_file():
|
|
153
|
+
rep.log("no backup found (use --from FILE or --out DIR)")
|
|
154
|
+
rep.exit = 1
|
|
155
|
+
return rep
|
|
156
|
+
problems = verify_archive(arc, a.age_identity)
|
|
157
|
+
for p in problems:
|
|
158
|
+
rep.hit("verify", arc.name, p)
|
|
159
|
+
if not problems:
|
|
160
|
+
rep.log(f"OK {arc}")
|
|
161
|
+
return rep
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def restore(site: Path, a) -> Rep:
|
|
165
|
+
rep = Rep(site)
|
|
166
|
+
arc = latest(site, a)
|
|
167
|
+
if not arc or not arc.is_file():
|
|
168
|
+
rep.log("no backup found (use --from FILE.tar.zst)")
|
|
169
|
+
rep.exit = 1
|
|
170
|
+
return rep
|
|
171
|
+
if (side := arc.with_name(arc.name + ".sha256")).exists() and sha256(arc) != side.read_text().split()[0]:
|
|
172
|
+
rep.log(f"CHECKSUM MISMATCH for {arc}, aborting")
|
|
173
|
+
rep.exit = 1
|
|
174
|
+
return rep
|
|
175
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
176
|
+
src = arc
|
|
177
|
+
if arc.suffix == ".age":
|
|
178
|
+
if not a.age_identity:
|
|
179
|
+
rep.log("encrypted backup: pass --age-identity KEYFILE")
|
|
180
|
+
rep.exit = 1
|
|
181
|
+
return rep
|
|
182
|
+
src = Path(tmp) / arc.name.removesuffix(".age")
|
|
183
|
+
age_decrypt(arc, a.age_identity, src)
|
|
184
|
+
with tarfile.open(src, "r:zst") as t:
|
|
185
|
+
for m in t:
|
|
186
|
+
if m.name == "db.sql":
|
|
187
|
+
t.extract(m, tmp, filter="data")
|
|
188
|
+
elif m.name.startswith("site/"):
|
|
189
|
+
t.extract(m.replace(name=m.name.removeprefix("site/")), site, filter="data")
|
|
190
|
+
r = wpcli.wp(site, "db", "import", f"{tmp}/db.sql")
|
|
191
|
+
rep.log("db import:", (r.stdout + r.stderr).strip() or "ok")
|
|
192
|
+
rep.log(f"files restored from {arc}. Quarantined files stay in {site.parent}/wpguard-quarantine (use `undo`).")
|
|
193
|
+
return rep
|
wpguard/behavior.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Heuristic 'behavior' score for a PHP file: obfuscation, dangerous-call density, odd names/places, timestamps.
|
|
2
|
+
|
|
3
|
+
Signatures catch known shells; this catches the unknown ones. A score >= threshold is reported for review and is
|
|
4
|
+
never auto-quarantined unless `--aggressive` is given (heuristics have false positives).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
from collections import Counter
|
|
10
|
+
|
|
11
|
+
CALLS = [
|
|
12
|
+
(re.compile(rb"\beval\s*\(", re.IGNORECASE), 3, "eval()"),
|
|
13
|
+
(re.compile(rb"\bassert\s*\(\s*\$", re.IGNORECASE), 3, "assert($var)"),
|
|
14
|
+
(re.compile(rb"\b(?:shell_exec|passthru|proc_open|popen|pcntl_exec)\s*\(", re.IGNORECASE), 3, "shell execution"),
|
|
15
|
+
(re.compile(rb"\b(?:system|exec)\s*\(\s*\$", re.IGNORECASE), 3, "system/exec($var)"),
|
|
16
|
+
(re.compile(rb"\b(?:gzinflate|gzuncompress|gzdecode|str_rot13)\s*\(", re.IGNORECASE), 2, "decompress/rot13"),
|
|
17
|
+
(re.compile(rb"\bbase64_decode\s*\(", re.IGNORECASE), 1, "base64_decode"),
|
|
18
|
+
(re.compile(rb"\bcreate_function\s*\(", re.IGNORECASE), 2, "create_function"),
|
|
19
|
+
(re.compile(rb"\bpreg_replace\s*\(\s*['\"][^'\"]*/[a-z]*e[a-z]*['\"]", re.IGNORECASE), 3, "preg_replace /e"),
|
|
20
|
+
(re.compile(rb"\$\$\w+|\$\{\s*['\"]"), 2, "variable variables"),
|
|
21
|
+
(re.compile(rb"\$\w+\s*\(\s*\$_(?:GET|POST|REQUEST|COOKIE)"), 3, "dynamic call on request input"),
|
|
22
|
+
(re.compile(rb"(?:\\x[0-9a-fA-F]{2}){8,}"), 3, "hex-escaped strings"),
|
|
23
|
+
(re.compile(rb"(?:chr\s*\(\s*\d+\s*\)\s*\.?\s*){6,}", re.IGNORECASE), 3, "chr() chains"),
|
|
24
|
+
]
|
|
25
|
+
DOUBLE_EXT = re.compile(r"\.php\d?\.(?:jpe?g|png|gif|ico|txt|css|js)$|\.(?:jpe?g|png|gif|ico)\.php\d?$", re.IGNORECASE)
|
|
26
|
+
KNOWN_BAD_NAMES = {"wp-vcd.php", "wp-tmp.php", "wp-feed.php", "xmrlpc.php", "class.plugin-modules.php", "wp-cache.php"}
|
|
27
|
+
ASSET_DIRS = ("wp-includes/js/", "wp-includes/css/", "wp-includes/images/", "wp-includes/fonts/", "wp-admin/images/",
|
|
28
|
+
"wp-admin/css/", "wp-admin/js/", "wp-content/languages/") # fmt: skip
|
|
29
|
+
WEEK = 7 * 86400
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def entropy(data: bytes) -> float:
|
|
33
|
+
if not data:
|
|
34
|
+
return 0.0
|
|
35
|
+
n = len(data)
|
|
36
|
+
return -sum(c / n * math.log2(c / n) for c in Counter(data).values())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def score(
|
|
40
|
+
data: bytes, rel: str, mtime: float | None = None, sibling_median: float | None = None, now: float | None = None
|
|
41
|
+
) -> tuple[int, list[str]]:
|
|
42
|
+
"""(score, reasons) for a PHP file whose site-relative posix path is `rel`."""
|
|
43
|
+
total, why = 0, []
|
|
44
|
+
|
|
45
|
+
def add(points: int, reason: str) -> None:
|
|
46
|
+
nonlocal total
|
|
47
|
+
total += points
|
|
48
|
+
why.append(f"{reason} (+{points})")
|
|
49
|
+
|
|
50
|
+
for rx, pts, label in CALLS:
|
|
51
|
+
if rx.search(data):
|
|
52
|
+
add(pts, label)
|
|
53
|
+
sample = data[:200_000]
|
|
54
|
+
if len(sample) >= 1024 and entropy(sample) > 5.8:
|
|
55
|
+
add(3, "high entropy (packed/obfuscated)")
|
|
56
|
+
if len(sample) > 5000 and max(map(len, sample.split(b"\n"))) > 5000 and sample.count(b"\n") < len(sample) / 2000:
|
|
57
|
+
add(2, "very long lines")
|
|
58
|
+
name = rel.rsplit("/", 1)[-1]
|
|
59
|
+
if name.lower() in KNOWN_BAD_NAMES:
|
|
60
|
+
add(5, "filename used by known malware")
|
|
61
|
+
if DOUBLE_EXT.search(name):
|
|
62
|
+
add(4, "double extension")
|
|
63
|
+
if rel.startswith(ASSET_DIRS):
|
|
64
|
+
add(3, "php in an asset directory")
|
|
65
|
+
if mtime is not None:
|
|
66
|
+
if now is not None and mtime > now + 3600:
|
|
67
|
+
add(2, "modified in the future")
|
|
68
|
+
if sibling_median is not None and mtime - sibling_median > 30 * 86400:
|
|
69
|
+
add(2, "much newer than its siblings")
|
|
70
|
+
return total, why
|
wpguard/cli.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Command line: argument parsing, config/profile merging, local vs ssh dispatch, reports and exit codes."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import copy
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from wpguard import __version__, config, discover, feeds, remote, wpcli, wpscan
|
|
11
|
+
from wpguard.audit import audit, audit_md
|
|
12
|
+
from wpguard.backup import backup, restore, verify
|
|
13
|
+
from wpguard.diffs import diff
|
|
14
|
+
from wpguard.fix import fix, harden, undo
|
|
15
|
+
from wpguard.lockfile import lock_cmd
|
|
16
|
+
from wpguard.monitor import baseline, logs, watch
|
|
17
|
+
from wpguard.recovery import recover_main
|
|
18
|
+
from wpguard.report import Rep, notify, write_report
|
|
19
|
+
from wpguard.scan import scan
|
|
20
|
+
from wpguard.schedule import schedule
|
|
21
|
+
from wpguard.updates import updates
|
|
22
|
+
|
|
23
|
+
DOC = """wpguard: scan, clean, harden, back up and monitor hacked WordPress sites with wp-cli.
|
|
24
|
+
|
|
25
|
+
site commands (SITE = path, profile name, hostname like blog.example.com, host:/path or host:domain over ssh):
|
|
26
|
+
scan fix harden undo baseline watch backup verify restore audit diff lock updates
|
|
27
|
+
other commands:
|
|
28
|
+
setup download wp-cli (sha512 verified) and create ~/.config/wpguard/wpguard.toml
|
|
29
|
+
init [--config FILE] create a starter ./wpguard.toml (--force to overwrite)
|
|
30
|
+
discover [HOST...] [--save] find WordPress sites by hostname on this machine / ssh hosts (nginx, apache vhosts)
|
|
31
|
+
logs [SITE...] find the entry point in web server access logs
|
|
32
|
+
sigs list|update [NAME..] signature feeds (maldet hashes, YARA rules)
|
|
33
|
+
schedule add|remove|show JOB SITE cron / systemd timers (JOB: watch backup scan sigs updates)
|
|
34
|
+
wp [--unsafe] SITE ARGS... run ANY wp-cli command (plugins/themes not loaded unless --unsafe)
|
|
35
|
+
wpscan [URL] ARGS... run the real WPScan (local or docker); WPSCAN_TOKEN is used if set
|
|
36
|
+
recover ARGS... rebuild a wiped site from a DB / dump (see `recover --help`)
|
|
37
|
+
Docs: https://github.com/arian24b/wpguard
|
|
38
|
+
"""
|
|
39
|
+
SITE_CMDS: dict[str, Callable] = {
|
|
40
|
+
"scan": scan, "fix": fix, "harden": harden, "undo": undo, "baseline": baseline, "watch": watch,
|
|
41
|
+
"backup": backup, "verify": verify, "restore": restore, "audit": audit, "diff": diff, "lock": lock_cmd,
|
|
42
|
+
"updates": updates,
|
|
43
|
+
} # fmt: skip
|
|
44
|
+
FAIL_ON_HITS = {"scan", "watch", "lock", "verify", "diff", "updates"}
|
|
45
|
+
NOTIFY_ON = {*FAIL_ON_HITS, "fix"}
|
|
46
|
+
LOCAL_WP_CMDS = {"scan", "fix", "harden", "restore", "audit", "backup", "diff", "lock", "updates", "undo"}
|
|
47
|
+
LOCAL_ONLY = {"config", "report", "sites_file", "jobs", "pull", "all", "help", "version", "force", "save"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
51
|
+
ap = argparse.ArgumentParser(prog="wpguard", description=DOC, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
52
|
+
ap.add_argument("cmd", choices=[*SITE_CMDS, "logs", "setup", "init", "discover", "sigs", "schedule"])
|
|
53
|
+
ap.add_argument("sites", nargs="*", help="paths, profile names, hostnames, host:/path or host:domain")
|
|
54
|
+
ap.add_argument("--version", action="version", version=f"wpguard {__version__}")
|
|
55
|
+
g = ap.add_argument_group("general")
|
|
56
|
+
g.add_argument("--config", help="wpguard.toml (default: ./wpguard.toml, ~/.config/wpguard/wpguard.toml)")
|
|
57
|
+
g.add_argument("--all", action="store_true", help="every site profile in the config")
|
|
58
|
+
g.add_argument("--force", action="store_true", help="(init, setup) overwrite / re-download")
|
|
59
|
+
g.add_argument("--save", action="store_true", help="(discover) add the found sites to the config")
|
|
60
|
+
g.add_argument("--sites-file")
|
|
61
|
+
g.add_argument("-j", "--jobs", type=int, default=1)
|
|
62
|
+
g.add_argument("--report", help="write report: out.json | out.html | out.txt")
|
|
63
|
+
g.add_argument("--notify", action="store_true", help="alert (telegram/email) when there are findings")
|
|
64
|
+
g.add_argument("--url", help="real site URL: pins WP_HOME/WP_SITEURL (harden/fix)")
|
|
65
|
+
g.add_argument("--ignore", action="append", help="glob of site-relative paths to ignore (repeatable)")
|
|
66
|
+
g.add_argument("--lock-file", help="path of wpguard.lock (default: next to the config, else ./)")
|
|
67
|
+
d = ap.add_argument_group("detection")
|
|
68
|
+
d.add_argument("--sigs", action="append", help="file of extra regexes, one per line")
|
|
69
|
+
d.add_argument("--hashdb", action="append", help="known-bad hash database (csv/txt/json/sqlite/.hdb)")
|
|
70
|
+
d.add_argument("--hashdb-good", action="append", help="known-clean hash database (suppresses findings)")
|
|
71
|
+
d.add_argument("--yara", action="append", help="YARA rule file (needs the yara or yr binary)")
|
|
72
|
+
d.add_argument("--no-feeds", action="store_true", help="ignore downloaded signature feeds")
|
|
73
|
+
d.add_argument("--no-behavior", action="store_true", help="disable heuristic behavior scoring")
|
|
74
|
+
d.add_argument("--behavior-threshold", type=int, default=5)
|
|
75
|
+
d.add_argument("--since", type=int, help="flag PHP modified / admins created in the last N days")
|
|
76
|
+
d.add_argument("--no-net", action="store_true", help="skip wordpress.org / WPScan lookups")
|
|
77
|
+
f = ap.add_argument_group("fix / harden")
|
|
78
|
+
f.add_argument("--dry-run", action="store_true", help="show the plan, change nothing")
|
|
79
|
+
f.add_argument("--yes", action="store_true", help="do not ask for confirmation")
|
|
80
|
+
f.add_argument("--aggressive", action="store_true", help="also quarantine behavior/yara findings")
|
|
81
|
+
f.add_argument("--clean-db", action="store_true")
|
|
82
|
+
f.add_argument("--prune", action="store_true")
|
|
83
|
+
f.add_argument("--delete-user", action="append")
|
|
84
|
+
f.add_argument("--lockdown", action="store_true", help="also DISALLOW_FILE_MODS + no auto-update (blocks updates)")
|
|
85
|
+
f.add_argument("--only", action="append", help="(undo) glob of files to restore")
|
|
86
|
+
b = ap.add_argument_group("backup")
|
|
87
|
+
b.add_argument("--out", help="backup: local directory | audit: output file")
|
|
88
|
+
b.add_argument(
|
|
89
|
+
"--to", action="append", help="destination: DIR | s3://bucket/prefix | rsync:HOST:/path (repeatable)"
|
|
90
|
+
)
|
|
91
|
+
b.add_argument("--keep", type=int, help="retention: keep the newest N backups per destination")
|
|
92
|
+
b.add_argument("--no-uploads", action="store_true")
|
|
93
|
+
b.add_argument("--encrypt-to", help="age recipient (age1...) to encrypt the archive")
|
|
94
|
+
b.add_argument("--age-identity", help="age identity file (verify/restore of encrypted backups)")
|
|
95
|
+
b.add_argument("--verify", action="store_true", help="backup: verify the archive before uploading")
|
|
96
|
+
b.add_argument(
|
|
97
|
+
"--keep-local", action="store_true", help="keep the local copy when only remote destinations are set"
|
|
98
|
+
)
|
|
99
|
+
b.add_argument("--from", dest="src", help="restore/verify/undo: backup file or quarantine dir")
|
|
100
|
+
b.add_argument("--pull", help="remote backup: rsync the archive back into this directory")
|
|
101
|
+
x = ap.add_argument_group("diff / lock / updates")
|
|
102
|
+
x.add_argument("--core", action="store_true", help="(diff) core files only")
|
|
103
|
+
x.add_argument("--plugin", action="append", help="(diff) only this plugin slug")
|
|
104
|
+
x.add_argument("--max-lines", type=int, default=60)
|
|
105
|
+
x.add_argument("--check", action="store_true", help="(lock) report drift instead of writing")
|
|
106
|
+
x.add_argument("--apply", action="store_true", help="(updates) apply the safe updates on the real site")
|
|
107
|
+
x.add_argument("--stage-db", help="(updates) existing empty database to use for staging")
|
|
108
|
+
x.add_argument("--check-url", action="append", help="(updates) extra URL path to health-check")
|
|
109
|
+
x.add_argument("--keep-stage", action="store_true")
|
|
110
|
+
s = ap.add_argument_group("schedule")
|
|
111
|
+
s.add_argument("--every", help="15m | 6h")
|
|
112
|
+
s.add_argument("--daily", help="HH:MM")
|
|
113
|
+
s.add_argument("--cron", help="raw cron expression")
|
|
114
|
+
s.add_argument("--systemd", action="store_true")
|
|
115
|
+
s.add_argument("--install", action="store_true")
|
|
116
|
+
o = ap.add_argument_group("output")
|
|
117
|
+
o.add_argument("--format", choices=["md", "json"], help="(audit)")
|
|
118
|
+
o.add_argument("--log", action="append", default=[], help="(logs) access log file")
|
|
119
|
+
o.add_argument("--top", type=int, default=10)
|
|
120
|
+
return ap
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def apply_opts(ap: argparse.ArgumentParser, ns: argparse.Namespace, opts: dict, origin: str) -> None:
|
|
124
|
+
"""Fill options the user did not give on the command line from config values (CLI always wins)."""
|
|
125
|
+
dests = {act.dest for act in ap._actions} # noqa: SLF001
|
|
126
|
+
for key, val in opts.items():
|
|
127
|
+
if key not in dests:
|
|
128
|
+
msg = f"{origin}: unknown option {key!r}"
|
|
129
|
+
raise config.ConfigError(msg)
|
|
130
|
+
if getattr(ns, key) == ap.get_default(key):
|
|
131
|
+
setattr(ns, key, list(val) if isinstance(val, list) else val)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def serialize(ap: argparse.ArgumentParser, ns: argparse.Namespace) -> list[str]:
|
|
135
|
+
"""Namespace -> CLI flags that differ from defaults (what a remote wpguard must be told)."""
|
|
136
|
+
out: list[str] = []
|
|
137
|
+
for act in ap._actions: # noqa: SLF001
|
|
138
|
+
if not act.option_strings or act.dest in LOCAL_ONLY:
|
|
139
|
+
continue
|
|
140
|
+
val = getattr(ns, act.dest, None)
|
|
141
|
+
if val in (None, False, [], act.default):
|
|
142
|
+
continue
|
|
143
|
+
flag = max(act.option_strings, key=len)
|
|
144
|
+
if isinstance(act, argparse._StoreTrueAction): # noqa: SLF001
|
|
145
|
+
out.append(flag)
|
|
146
|
+
elif isinstance(act, argparse._AppendAction): # noqa: SLF001
|
|
147
|
+
for v in val:
|
|
148
|
+
out += [flag, str(v)]
|
|
149
|
+
else:
|
|
150
|
+
out += [flag, str(val)]
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def run_target(ap: argparse.ArgumentParser, ns: argparse.Namespace, cfg: config.Config, t: config.Target) -> Rep:
|
|
155
|
+
ta = copy.copy(ns)
|
|
156
|
+
ta._det = None # noqa: SLF001 detectors are built per target (options may differ)
|
|
157
|
+
ta.site_key, ta.config_path = t.key, cfg.path
|
|
158
|
+
apply_opts(ap, ta, t.opts, f"[sites.{t.name}]") # first writer wins: CLI > profile > defaults
|
|
159
|
+
apply_opts(ap, ta, cfg.defaults, "[defaults]")
|
|
160
|
+
if t.ssh:
|
|
161
|
+
return remote.run(t.ssh, t.remote_cmd, [ns.cmd, t.path, *serialize(ap, ta)], live=Rep.live, pull=ns.pull)
|
|
162
|
+
return SITE_CMDS[ns.cmd](Path(t.path), ta)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def main(argv: list[str] | None = None) -> int:
|
|
166
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
167
|
+
if argv[:1] == ["wp"]:
|
|
168
|
+
wpcli.wp_passthrough(argv[1:])
|
|
169
|
+
if argv[:1] == ["wpscan"]:
|
|
170
|
+
wpscan.wpscan_passthrough(argv[1:])
|
|
171
|
+
if argv[:1] == ["recover"]:
|
|
172
|
+
recover_main(argv[1:])
|
|
173
|
+
return 0
|
|
174
|
+
if not argv:
|
|
175
|
+
print(DOC)
|
|
176
|
+
return 2
|
|
177
|
+
ap = build_parser()
|
|
178
|
+
ns = ap.parse_args(argv)
|
|
179
|
+
if ns.cmd == "init":
|
|
180
|
+
path = Path(ns.config) if ns.config else Path("wpguard.toml")
|
|
181
|
+
if config.write_template(path, force=ns.force):
|
|
182
|
+
print(f"created {path.resolve()} (edit it, then try: wpguard discover --save)")
|
|
183
|
+
return 0
|
|
184
|
+
print(f"{path} already exists (use --force to overwrite)")
|
|
185
|
+
return 1
|
|
186
|
+
if ns.cmd == "setup":
|
|
187
|
+
wpcli.setup()
|
|
188
|
+
if created := config.ensure_default():
|
|
189
|
+
print(f"created {created} (edit it, or run: wpguard discover --save)")
|
|
190
|
+
return 0
|
|
191
|
+
try:
|
|
192
|
+
cfg = config.load(ns.config)
|
|
193
|
+
except config.ConfigError as e:
|
|
194
|
+
sys.exit(f"error: {e}")
|
|
195
|
+
if ns.cmd == "sigs":
|
|
196
|
+
return feeds.sigs_cmd(ns.sites, cfg.feeds)
|
|
197
|
+
ns.config_path = cfg.path
|
|
198
|
+
if ns.cmd == "discover":
|
|
199
|
+
try:
|
|
200
|
+
return discover.discover(ns.sites, cfg, cfg.path, save=ns.save)
|
|
201
|
+
except config.ConfigError as e:
|
|
202
|
+
sys.exit(f"error: {e}")
|
|
203
|
+
if ns.cmd == "schedule":
|
|
204
|
+
ns.site_key = ""
|
|
205
|
+
rep = schedule(ns.sites, ns)
|
|
206
|
+
return rep.exit
|
|
207
|
+
if ns.sites_file:
|
|
208
|
+
ns.sites += Path(ns.sites_file).read_text().split()
|
|
209
|
+
Rep.live = ns.jobs == 1
|
|
210
|
+
if ns.cmd == "logs":
|
|
211
|
+
reps = [logs([str(Path(s).resolve()) for s in ns.sites], ns)]
|
|
212
|
+
else:
|
|
213
|
+
if not ns.sites and not ns.all:
|
|
214
|
+
sys.exit("give at least one SITE (path, profile name, host:/path) or --all")
|
|
215
|
+
if ns.cmd == "fix" and ns.jobs > 1 and not (ns.yes or ns.dry_run):
|
|
216
|
+
sys.exit("fix with -j > 1 cannot ask for confirmation: add --yes (or use --dry-run)")
|
|
217
|
+
try:
|
|
218
|
+
targets = config.targets(cfg, ns.sites, every=ns.all)
|
|
219
|
+
for t in targets:
|
|
220
|
+
if not t.path:
|
|
221
|
+
discover.fill(t) # hostname-only target: find its path from the vhosts
|
|
222
|
+
if ns.cmd in LOCAL_WP_CMDS and any(not t.ssh for t in targets):
|
|
223
|
+
wpcli.need_wp()
|
|
224
|
+
bad = [t.path for t in targets if not t.ssh and not (Path(t.path) / "wp-load.php").exists()]
|
|
225
|
+
if bad:
|
|
226
|
+
sys.exit(f"not WordPress roots: {bad}")
|
|
227
|
+
with ThreadPoolExecutor(ns.jobs) as ex:
|
|
228
|
+
reps = list(ex.map(lambda t: run_target(ap, ns, cfg, t), targets))
|
|
229
|
+
except config.ConfigError as e:
|
|
230
|
+
sys.exit(f"error: {e}")
|
|
231
|
+
if not Rep.live:
|
|
232
|
+
for r in reps:
|
|
233
|
+
print("\n".join(r.lines))
|
|
234
|
+
if ns.cmd == "audit":
|
|
235
|
+
_export_audit(ns, reps)
|
|
236
|
+
if ns.report:
|
|
237
|
+
write_report(ns.report, reps)
|
|
238
|
+
found = [r for r in reps if r.hits]
|
|
239
|
+
if found and ns.notify and ns.cmd in NOTIFY_ON:
|
|
240
|
+
notify(
|
|
241
|
+
"\n".join(
|
|
242
|
+
f"{r.site}: {len(r.hits)} findings\n"
|
|
243
|
+
+ "\n".join(f"- [{h['kind']}] {h['what']}: {h['why']}" for h in r.hits[:15])
|
|
244
|
+
for r in found
|
|
245
|
+
),
|
|
246
|
+
cfg.notify,
|
|
247
|
+
)
|
|
248
|
+
return 1 if any(r.exit for r in reps) or (found and ns.cmd in FAIL_ON_HITS) else 0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _export_audit(ns: argparse.Namespace, reps: list[Rep]) -> None:
|
|
252
|
+
import json
|
|
253
|
+
|
|
254
|
+
fmt = ns.format or ("json" if (ns.out or "").endswith(".json") else "md")
|
|
255
|
+
docs = [r.data for r in reps]
|
|
256
|
+
txt = (
|
|
257
|
+
json.dumps(docs[0] if len(docs) == 1 else docs, indent=1) if fmt == "json" else "\n\n".join(map(audit_md, docs))
|
|
258
|
+
)
|
|
259
|
+
if ns.out:
|
|
260
|
+
Path(ns.out).write_text(txt)
|
|
261
|
+
print("audit ->", ns.out, file=sys.stderr)
|
|
262
|
+
else:
|
|
263
|
+
print(txt)
|