Skip to content

WordPress ​

The wordpress plugin watches a WordPress install for the changes an attacker makes after getting in. It lists administrators, trashed pages, must-use plugins and the web user's crontab, and the server raises an alert for each suspicious item. Turn it on for any WordPress site you host and can run the agent next to.

The plugin only reports what it sees. The server decides severity, and it computes the new-admin and removed-admin diffs against the snapshot it stored last time, so restarting the agent can't reset the baseline.

Enable it ​

Add wordpress to the plugins array. The agent runs on the WordPress host.

json
{
  "plugins": ["wordpress"],
  "wordpress": {
    "refreshInterval": 300000,
    "sites": [
      {
        "name": "example-site",
        "path": "/var/www/example-site",
        "url": "https://www.example.com",
        "runAs": "www-data",
        "cronUser": "www-data",
        "criticalPages": ["login", "home"],
        "muPluginsAllowlist": ["object-cache-helper.php"]
      },
      {
        "name": "container-site",
        "path": "/var/www/html",
        "url": "https://blog.example.com",
        "lxc": "wp-container",
        "lxcBin": "/snap/bin/lxc"
      }
    ]
  }
}

For one site, the site keys can sit directly in the wordpress section instead of sites.

OptionDefaultWhat it does
refreshInterval300000Milliseconds between polls. Section level only.
sitesnoneArray of sites. Sites without path are skipped.
namebasename of pathDisplay name, used in alerts. The unique id is wp-<name>, falling back to url, then path, when name is unset.
pathrequiredWordPress root, where wp-config.php lives. Inside the container's filesystem when lxc is set.
urlnonePublic URL. Passed to WP-CLI as --url.
wpCliwpWP-CLI binary.
runAsnoneOS user to run WP-CLI as, through sudo -n -u. WP-CLI refuses to run as root.
lxcnoneLXC container the site runs in. Commands then run through sudo -n lxc exec <lxc> -- with --allow-root.
lxcBinlxcPath to the lxc binary.
cronUserwww-dataUser whose crontab should be empty.
muPluginsAllowlistnoneKnown-good mu-plugin filenames. Without it, unknown mu-plugins are never flagged.
criticalPageslogin, home, index, front-pageSlugs or titles whose trashing raises an alert.
servicePatternswpsvc, wpengine, bot, svc, researchlabs, wordpress-svcSubstrings that mark an admin login or email as a service account.

What it reports ​

One payload per site:

  • admins, each with id, login, email, registered. adminsError when the list could not be read.
  • suspiciousAdmins, admins whose login or email matches a service pattern.
  • wpVersion, or wpVersionError.
  • trashedPages, every trashed page and post, and trashedCriticalPages, the ones matching criticalPages. trashedError on failure.
  • muPlugins, the .php files in wp-content/mu-plugins, and unknownMuPlugins when an allowlist is set.
  • webUserCrontab, hasWebUserCrontab, cronUser, or cronError.
  • error, when the whole collection failed.

Each collector fails on its own. One failure leaves its field empty and sets its *Error field. The rest of the poll still reports.

When it alerts ​

WordPress uses the findings model. Every problem is its own alert, so a site with a rogue admin and a crontab raises two. A finding alerts on the first report that shows it and resolves when a report no longer contains it. It can alert again only after it has resolved, and a repeat within 30 minutes of the resolve is recorded without paging. All findings are severity error.

FindingTrigger
New administratorA login appears in admins that was not in the previous snapshot. The alert lists each new admin's user id and registration date, plus admin counts before and after. Keyed by the set of new logins, so another new admin alerts again.
Administrator removed or demotedA login in the previous snapshot is missing from this one. The alert lists who is gone, who is still an admin, and the counts.
Service-account adminAn entry in suspiciousAdmins. One alert per login. This fires on the first poll, before any baseline exists.
Web-user crontabhasWebUserCrontab is true, meaning cronUser has at least one non-comment crontab line.
Critical page trashedAn entry in trashedCriticalPages. One alert per page.
Unknown mu-pluginAn entry in unknownMuPlugins. One alert per file.
Check errorThe payload has error, the whole site failed to collect.

The admin diffs only run against a good baseline. If the previous snapshot errored or had no admins, there is no baseline and no new-admin alert. If this poll errored or found no admins, the removed-admin check is skipped, because every real site has at least one admin and an empty list means collection failed.

With no findings but adminsError or cronError set, the site goes to warning with "posture collection incomplete". That warning goes through the normal path. It notifies after a second consecutive bad report and resolves when the site reports clean.

On the dashboard ​

The card is labelled "WordPress" plus the version.

  • WP shows wpVersion. Never colored.
  • ADMINS shows the admin count. Red when any admin is a service account.
  • MU-PLUGINS shows the count. Red when any is unknown.
  • CRON shows "clean", or "CRONTAB!" in red.
  • TRASHED PAGES appears, in red, when a critical page is in the trash.
  • The ADMINISTRATORS list has one row per admin. Service-account admins show SERVICE-ACCT in red.

The card's overall color comes from the status the server stored. A WordPress service with no report for 900 seconds shows OFFLINE.

Requirements ​

  • The agent on the WordPress host, with WP-CLI installed.
  • With runAs or lxc, the agent user needs passwordless sudo for those commands. The plugin always passes sudo -n.
  • Reading another user's crontab needs privilege. The plugin tries crontab -l -u <cronUser> first and retries with sudo -n on a permission error.
  • Read access to wp-content/mu-plugins on the host, for sites not in a container.

Gotchas ​

  • New-admin and removed-admin findings come from a diff, and the next poll's snapshot already includes the change. So the finding resolves on the following poll, about five minutes later. The alert still goes out once, and the event stays in the system's history.
  • Service patterns are case-insensitive substrings of the login and the email. The default bot and svc patterns can match ordinary names. Set servicePatterns per site if they misfire.
  • Any non-comment crontab line counts, including a bare MAILTO= line.
  • Inside a container, a user denied crontab entirely (by cron.deny) counts as clean.
  • WP-CLI runs with --skip-plugins --skip-themes, so a broken plugin can't corrupt the JSON output.
  • Only top-level .php files in mu-plugins are listed.