# 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`.

| Option | Default | What it does |
| --- | --- | --- |
| `refreshInterval` | `300000` | Milliseconds between polls. Section level only. |
| `sites` | none | Array of sites. Sites without `path` are skipped. |
| `name` | basename of `path` | Display name, used in alerts. The unique id is `wp-<name>`, falling back to `url`, then `path`, when `name` is unset. |
| `path` | required | WordPress root, where `wp-config.php` lives. Inside the container's filesystem when `lxc` is set. |
| `url` | none | Public URL. Passed to WP-CLI as `--url`. |
| `wpCli` | `wp` | WP-CLI binary. |
| `runAs` | none | OS user to run WP-CLI as, through `sudo -n -u`. WP-CLI refuses to run as root. |
| `lxc` | none | LXC container the site runs in. Commands then run through `sudo -n lxc exec <lxc> --` with `--allow-root`. |
| `lxcBin` | `lxc` | Path to the `lxc` binary. |
| `cronUser` | `www-data` | User whose crontab should be empty. |
| `muPluginsAllowlist` | none | Known-good mu-plugin filenames. Without it, unknown mu-plugins are never flagged. |
| `criticalPages` | `login`, `home`, `index`, `front-page` | Slugs or titles whose trashing raises an alert. |
| `servicePatterns` | `wpsvc`, `wpengine`, `bot`, `svc`, `researchlabs`, `wordpress-svc` | Substrings 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.

| Finding | Trigger |
| --- | --- |
| New administrator | A 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 demoted | A 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 admin | An entry in `suspiciousAdmins`. One alert per login. This fires on the first poll, before any baseline exists. |
| Web-user crontab | `hasWebUserCrontab` is true, meaning `cronUser` has at least one non-comment crontab line. |
| Critical page trashed | An entry in `trashedCriticalPages`. One alert per page. |
| Unknown mu-plugin | An entry in `unknownMuPlugins`. One alert per file. |
| Check error | The 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.
