Skip to main content

wpguard

CI

English | فارسی

Command line tool to scan, clean, harden, back up and monitor hacked WordPress sites, built on wp-cli. Python 3.14, no runtime dependencies (boto3 for S3 and pymysql for recover are optional extras).

Install

uvx wpguard --help                 # run without installing (`uvx --from wpguard wpg` for the short name) (uv downloads Python 3.14 if needed)
uv tool install wpguard            # or: pipx install wpguard
pip install 'wpguard[s3]'          # S3 backups (boto3)   |   'wpguard[mysql]' for `recover` against a live MySQL
wpg --help                         # `wpg` is a short alias of `wpguard` (installed together)
wpguard setup                      # downloads wp-cli (sha512 verified) and creates ~/.config/wpguard/wpguard.toml
wpguard init                       # (optional) starter ./wpguard.toml for this project

Requirements: Linux, php and a MySQL/MariaDB client for the site commands (not for baseline, watch, logs, verify). Run as the site's file owner (sudo -u www-data wpguard ...) so new files keep the right ownership. Optional tools it uses when present: yara (or YARA-X yr), clamscan, age, rsync, ssh, wpscan/docker.

Quick start

wpguard scan /var/www/site                       # 1. look first, changes nothing
wpguard fix  /var/www/site --dry-run             # 2. see the full plan
wpguard fix  /var/www/site --url https://your.site     # 3. does it (asks to confirm; --yes to skip)
wpguard undo /var/www/site                       #    false positive? put quarantined files back
wpguard harden /var/www/site --lockdown --url https://your.site   # after everything is updated
wpguard baseline /var/www/site                   # snapshot the clean state
wpguard schedule add watch /var/www/site --every 15m --install    # alert on any change

SITE can be a path, a profile name from wpguard.toml, a hostname (blog.example.com), or host:/path / host:domain to run it over ssh (see below).

Commands

Command What it does
scan read-only: signatures, hash DBs, YARA, behavior score, wp-config.php/.htaccess/.user.ini tampering, hidden files, mu-plugins, ClamAV, core/plugin checksum extras, DB injections, new admins, odd cron, outdated/abandoned/vulnerable plugins
fix scan → plan → confirm → backup → quarantine → reinstall core/plugins/themes (pinned versions if locked) → DB clean → harden → new salts → rescan → baseline. --dry-run stops after the plan
undo move quarantined files back (latest quarantine, --from DIR, --only GLOB)
harden block PHP in uploads, apply wp-config constants, perms 755/644; --prune removes inactive plugins/themes
diff unified diff of every modified core/plugin file against the official wordpress.org file
backup DB dump + all files → wpguard-backup-<site>-<time>.tar.zst, optional encryption, concurrent upload to several destinations, retention, verification
verify check a backup (checksum, readable archive, plausible db.sql, WordPress files present)
restore re-import the DB and files from a backup (checksum verified first)
baseline / watch SHA-256 snapshot / diff against it; exit 1 and alert on new, changed or removed files
lock write wpguard.lock (pinned core/plugin/theme versions and feed digests); lock --check reports drift
updates try every pending update on a staging copy, report which ones break the site; --apply applies the safe ones
logs find the entry point in access logs: brute force, xmlrpc, exploit POSTs, requests to shells you quarantined
audit full inventory + findings as Markdown or JSON (secrets redacted)
sigs list / sigs update signature feeds (maldet hashes, YARA rule sets)
schedule add|remove|show JOB SITE cron lines or systemd user timers for watch backup scan sigs updates
wp [--unsafe] SITE ARGS... run any wp-cli command (plugins/themes not loaded unless --unsafe)
wpscan [URL] ARGS... run the real WPScan (local binary or docker); WPSCAN_TOKEN becomes --api-token
recover ARGS... rebuild a wiped site from the DB or a dump (recover --help)
setup download wp-cli and create ~/.config/wpguard/wpguard.toml if no config exists
init create a commented starter ./wpguard.toml (or --config FILE; --force overwrites)
discover [HOST...] find WordPress sites by hostname on this machine or ssh hosts; --save adds them to the config

Config file and profiles

wpguard setup (global, ~/.config/wpguard/wpguard.toml) or wpguard init (this directory) writes a commented starter file; wpguard discover --save fills in sites for you. It is searched in ./ then ~/.config/wpguard/, or use --config FILE:

[defaults]
since = 14
keep = 7

[sites.blog]
path = "/var/www/blog"
url = "https://blog.example.com"
to = ["/srv/backups", "s3://my-bucket/blog", "rsync:backup:/srv/backups/blog"]

[sites.mina]
ssh = "mina"                      # a host from ~/.ssh/config
path = "/var/www/mina"            # path on the remote host

Now wpguard scan blog, wpguard backup --all, wpguard fix mina --dry-run work. Any CLI option can be a config key (long name, dashes → underscores). Precedence: command line > [sites.NAME] > [defaults]. Unknown keys are an error. [notify] holds telegram_token, telegram_chat, email (env vars WPGUARD_TELEGRAM_TOKEN, WPGUARD_TELEGRAM_CHAT, WPGUARD_EMAIL win; keep the file private).

wpguard.lock

wpguard lock blog records core, plugin and theme versions (and the digests of downloaded signature feeds) in wpguard.lock next to the config; commit it. Then:

  • fix reinstalls the pinned versions instead of "latest" (no surprise major jumps while cleaning),
  • lock --check exits 1 when something drifted (new plugin, version changed, feed changed): a good cron job,
  • updates --apply refreshes the pins for the updates it applied.

Sites by hostname

Any site command accepts the site's hostname instead of its path:

wpguard discover                         # list WordPress sites on this machine (nginx/apache vhosts + wp-config.php)
wpguard discover mina --save             # same on the ssh host `mina`, and add them to wpguard.toml
wpguard scan blog.example.com            # by hostname (profile `domain`/`url`, or found from this machine's vhosts)
wpguard backup mina:blog.example.com     # hostname on an ssh host: path is looked up over ssh, then the command runs there

Resolution order for SITE: profile name → profile domain/url host → host:/path → host:domain → an existing local path → a bare domain looked up in this machine's web-server configs. A profile may omit path when it has a domain (and optionally ssh): the path is then found from the vhosts. Discovery reads /etc/nginx, /etc/apache2, /etc/httpd (server_name/ServerName/ServerAlias + root/DocumentRoot) and wp-config.php under /var/www /srv /home /opt; unusual layouts can still be registered by hand. www. variants match.

Remote sites over SSH

wpguard scan mina:/var/www/site          # ad-hoc: host from ~/.ssh/config, path on the remote
wpguard scan mina                        # profile with ssh = "mina"
wpguard backup mina --to s3://bkt/mina --pull ./pulled     # remote backup, archive rsync'ed back

wpguard runs the same command on the remote machine through ssh <host> <remote_cmd> ..., so your ~/.ssh/config (keys, ProxyJump, ports) is used as-is. The remote needs uv (the default remote_cmd is uvx wpguard; set remote_cmd = "~/.local/bin/wpguard" per profile if it is installed). Your CLI options and config values are forwarded; --config, --report, -j, --pull stay local. Exit codes propagate (1 = findings). Paths in options such as --hashdb are read on the remote host.

Detection

  • Signatures (built in) and --sigs FILE (your own regexes; blank/invalid lines are ignored).
  • Hash databases: --hashdb known-bad, --hashdb-good known-clean (suppresses findings). Any file with hex md5/sha1/sha256: csv, txt, json, .gz, ClamAV .hdb, SQLite.
  • Behavior score (--behavior-threshold, default 5; --no-behavior): dangerous-call density, hex/chr obfuscation, high entropy, very long lines, double extensions, PHP in asset dirs, names used by known malware, mtime far newer than siblings or in the future. Review only: fix does not quarantine behavior or YARA findings unless you pass --aggressive.
  • YARA: --yara RULES.yar (repeatable) through the yara or yr binary.
  • Feeds: wpguard sigs update downloads maldet md5 hashes (rfxn), php-malware-finder (LGPL-3.0) and Neo23x0/signature-base webshell rules (Detection Rule License 1.1: attribution). They are cached under ~/.local/share/wpguard/feeds, used automatically by scan/fix (--no-feeds disables) and their digests are pinned by lock. Add your own with [feeds.NAME] in the config.
  • Core/plugin diff: wpguard diff SITE [--core] [--plugin SLUG] shows what an attacker changed in an official file.
  • --ignore GLOB (repeatable, or ignore = [...] in config) silences known-good paths.

Safer fix

fix always prints the plan (what will be quarantined, what is only flagged for review, which users will be deleted, which versions will be installed) and then asks Proceed? [y/N]. Non-interactive runs refuse unless --yes; -j > 1 requires --yes or --dry-run. Quarantined files are moved (never deleted) into <site parent>/wpguard-quarantine/<site>-<time>/files/ with a manifest.json; wpguard undo puts them back. wp-config.php and .htaccess outside uploads are only ever reported. A backup (without uploads) is taken before anything changes.

Backups

wpguard backup blog                                   # to the destinations in the profile (or next to the site)
wpguard backup /var/www/site --out /srv/backups --verify --keep 7
wpguard backup blog --to s3://bkt/blog --to rsync:backup:/srv/b --encrypt-to age1... --keep 14
wpguard verify blog --out /srv/backups                # latest local backup
wpguard restore /var/www/site --from /srv/backups/wpguard-backup-site-20261009-113206.tar.zst

Archive layout: db.sql + site/... in a zstd tar, mode 600, with a .sha256 sidecar; an existing backup is never overwritten. Destinations (--to, repeatable): a local directory, s3://bucket/prefix (boto3, standard AWS_* env, AWS_ENDPOINT_URL for MinIO/R2), rsync:HOST:/path (HOST may be an ssh alias). All destinations upload concurrently; each is size-checked after upload and one failing destination does not stop the others (exit 1). Retention (--keep N) keeps the newest N per destination and site, and only runs after a verified upload. --verify checks the archive before uploading. --encrypt-to needs age; restore/verify of such files needs --age-identity KEY. --no-uploads leaves out wp-content/uploads. The archive contains wp-config.php (DB password): keep it private.

Update advisor

wpguard updates SITE lists pending core/plugin/theme updates and tests each one on a throwaway copy: files (without uploads) plus a cloned database, served by PHP's built-in server on 127.0.0.1. After every update it requests /, /wp-login.php, /wp-admin/ (add more with --check-url) and reads the server log; 5xx pages and PHP fatals are reported as BREAKS, then that plugin is rolled back and the next one is tested alone. Inside the copy outgoing mail and HTTP (except wordpress.org) are blocked and WP-Cron is off, so loading the site's plugins cannot e-mail customers. Needs php and a MySQL user that may CREATE DATABASE (or --stage-db EXISTING_EMPTY_DB). --apply backs up and then updates only the items that passed. --keep-stage leaves the copy for inspection.

Scheduler

wpguard schedule show backup blog --daily 03:30          # prints the cron line
wpguard schedule add watch blog --every 15m --install    # writes it to your crontab (idempotent, marker comments)
wpguard schedule add backup blog --daily 03:30 --systemd --install    # systemd user timer
wpguard schedule remove watch blog

Jobs: watch backup scan sigs updates; watch, scan and updates run with --notify. Put alert settings in [notify] (cron has no environment). For systemd user timers to run while logged out: loginctl enable-linger $USER.

Cleaning a hacked site: isolate the site → scan --since 14 → logs (find the hole: POSTs to plugin PHP, requests to files you later quarantine) → fix --dry-run, then fix → reinstall premium plugins marked SKIP from the vendor → change DB/FTP/SSH/WordPress passwords and remove unknown admins → harden --lockdown after updating → baseline + schedule add watch.

Wiped site: wpguard recover --dump site.sql --db NAME --user U --out site --list (inventory), then without --list to download core/plugins/themes at the versions the DB records; restore uploads and premium code from a backup and continue as above.

Audit: wpguard audit blog --out blog.md (or .json): versions, settings, plugins, themes, users by role, cron hooks, mu-plugins, drop-ins, wp-config.php constants with DB user/password/salts redacted, plus all findings.

Development

uv sync
uv run ruff check . && uv run ruff format --check .      # ruff, select = ["ALL"] (ignores are listed with reasons in pyproject.toml)
uv run pytest
uv build

GitHub Actions (.github/workflows/ci.yml) runs ruff, pytest and a wheel smoke test on every push and pull request. When CI succeeds on main, release.yml runs python-semantic-release: it reads Conventional Commits (feat: → minor, fix: → patch, feat!: / BREAKING CHANGE: → major), bumps __version__ and uv.lock, updates CHANGELOG.md, tags vX.Y.Z, creates the GitHub release and publishes the built package to PyPI (trusted publishing: add a publisher for this repo, workflow release.yml, environment pypi on pypi.org once). Commits without a releasable type (docs:, chore:, ci:, test:) do not release.

What it does NOT do

  • Detection is heuristic. Obfuscated commercial plugins can be flagged and new shells can be missed; it does not replace a WAF or server-level scanning.
  • DB cleaning is regex-based: read the dry run. fix installs pinned versions if locked, otherwise the latest (a major jump can break compatibility; updates exists to test that).
  • The update advisor needs a real PHP + MySQL environment; DB migrations done by a plugin update can leave the staging DB changed between candidates.
  • Remote mode requires uv (or an installed wpguard) on the remote host.
  • Quarantine and backups sit next to the site (outside the web root); delete them when no longer needed, they hold old malicious files and database dumps.

Safety and license

Use only on sites you own or are authorised to administer. MIT license (LICENSE). Third-party signature feeds keep their own licenses (see above).

Metadata

Release files for wpguard 0.2.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for wpguard 0.2.1
File Size Uploaded
wpguard-0.2.1.tar.gz 87.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for wpguard 0.2.1
File Interpreter ABI Platform
wpguard-0.2.1-py3-none-any.whl Python 3 none any Details

Total release size: 161.1 kB

Release files / wpguard-0.2.1.tar.gz

Download URL wpguard-0.2.1.tar.gz
Size 87.5 kB
Tags Source
SHA-256 checksum
How to use checksums
3f9ef83469a7ee5853946c2cc85971f5ad6a060a0ea62fd2bdffba77a3127112
BLAKE2b-256 checksum
How to use checksums
f3d6253afb82a423dd165229c1117fb4d809e91fb0d14b4a47512bef26082e74
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.

Transparency log

Release files / wpguard-0.2.1-py3-none-any.whl

Download URL wpguard-0.2.1-py3-none-any.whl
Size 73.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d7a3882ad16ed3a63893e05b91fabb479dcb1d35dc9fafb6b61861eae36b22ab
BLAKE2b-256 checksum
How to use checksums
3d5a39ebab360fd956b655bc9db21d6119fd56af4e1f5ca4e3effe3f85371304
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.

Transparency log

Release history Release notifications | RSS feed

0.3.0

2 release files

This release

0.2.1 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page