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 ADDED
@@ -0,0 +1,3 @@
1
+ """wpguard: scan, clean, harden, back up and monitor hacked WordPress sites with wp-cli."""
2
+
3
+ __version__ = "0.2.1"
wpguard/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """`python -m wpguard`."""
2
+
3
+ from wpguard.cli import main
4
+
5
+ raise SystemExit(main())
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)