# CheckRoost for WordPress

A read-only health document, so CheckRoost can report WordPress, PHP, plugin and
theme update status **without holding a credential that can change anything**.

## Why not an Application Password

Core's `/wp/v2/plugins` route needs the `activate_plugins` capability, which is
effectively administrator. A monitoring service holding that for twenty-nine
client sites could install plugins, create users and edit content on all of
them — an enormous blast radius to buy a count of pending updates.

This inverts it, the same way the CheckRoost server agent does for a Linux box:
**the site publishes a document about itself behind its own token, and the
monitor reads it.** There is no write route, no capability granted, and nothing
the token can do but return the JSON below. Losing it discloses version numbers.
It does not lose the site. (From 1.3.3 it can also name a file the site is
exposing — which is the point of finding it, and a reason to delete it fast.)

## What it returns

```json
{
  "checkroost": { "version": "1.0.0" },
  "wp":      { "version": "6.7.1", "updateAvailable": false,
               "multisite": false, "debug": false, "homeUrl": "https://example.com" },
  "php":     { "version": "8.3.2" },
  "plugins": { "total": 24, "active": 22, "inactive": 2,
               "updates": 3, "checkedAt": "2026-10-02T04:11:00+00:00" },
  "themes":  { "total": 3, "active": "Twenty Twenty-Four",
               "updates": 1, "checkedAt": "2026-10-02T04:11:00+00:00" },
  "cron":    { "overdue": 0, "disabled": false },
  "generatedAt": "2026-10-02T06:40:12+00:00"
}
```

**`checkedAt` is reported beside every update count on purpose.** WordPress
refreshes these twice a day through wp-cron; if cron is broken the counts go
stale, and a monitor that cannot tell "no updates" from "nobody has looked since
March" is exactly the confident wrong answer this product exists to avoid. The
plugin deliberately does *not* force a refresh — that would make every poll an
outbound request to wordpress.org from the customer's server.

No content, no user data, no site options beyond the ones above.

### Exposed files (1.3.3)

`exposure` and the flat `exposedFiles` / `exposedFileTimes` / `exposedFileRisk`
/ `exposedFileHttp` maps (keyed by path) report files that should never be in
the site's folders: database dumps, archives and backups, copies of
`wp-config.php`, `.env` files, logs, a `.git` repository, phpinfo and Adminer.
Once a day, with the other slow readings, the plugin walks the web root three
levels deep (skipping core, plugins, themes, `vendor`, `node_modules` and WP
Engine's `_wpeprivate`), bounded to 5,000 entries, three seconds and 25 results.
It records name, size and date and **never opens a file**.

For each file found (up to ten) it sends one HEAD request to the site itself.
A 200 means anybody can download it: critical. A 401/403/404 means the server
refuses it: a warning. With no answer, the place decides — anything at the top
of the site, a credentials file, a `.git` repository or a dump over 1 MB is
critical. From 1.3.6 it also reports PHP files in `wp-content/uploads` (six
folders deep) and at the top of `wp-content/cache` — where a webshell is left.
From 1.3.9 uploads is walked in full a few seconds a day: folders a plugin left
come before year folders, image names are not stopped at, and a walk that runs
out of time goes on from where it stopped the next day. `exposure.uploadsCovered`,
`uploadsChecked` and `uploadsCompletedAt` say how far it has got.
It never requests one, since that would run it: it asks for a made-up
`checkroost-<random>.php` in the same folder, and a refusal (401/403) makes the
file a warning, any other answer critical. From 1.3.7 every listed file is
looked for again (`file_exists`, never a read) each time the document is built,
so a deleted file drops off at the next check rather than after the next walk;
`exposure.goneSinceScan` says how many. `exposure.status` is `pending` until the first scan, so CheckRoost's
alert (`exposure.status ne critical`) never fires on a site nobody has scanned.

## Install

1. Upload `checkroost-<version>.zip` through **Plugins → Add New → Upload**
   and activate it, or unzip it and copy the `checkroost` folder into
   `wp-content/plugins/`. Keep the folder: a bare `checkroost.php` in
   `wp-content/plugins/` works, but cannot update itself.
2. **Settings → CheckRoost** shows the endpoint and the generated token, and a
   **Connect to CheckRoost** button. The first activation opens it for you
   (from 1.3.5, and not for a bulk, network, WP-CLI or ManageWP activation);
   after that it is the **Settings** link on the plugin's row. Click it, choose the monitor in CheckRoost,
   and the check is set up. The token travels after the `#` in the link, which a
   browser never sends to a server, and is saved only when you confirm.
3. Or by hand (the only way when the token is set in `wp-config.php`): in
   CheckRoost, add an **Application health** check to the monitor:
   - URL: `https://example.com/wp-json/checkroost/v1/health`
   - Header: `X-CheckRoost-Token: <the token>`
   - Assertions, for example: `plugins.updates` `lt` `5`, `wp.updateAvailable`
     `eq` `false`, `cron.overdue` `eq` `0`

Nothing else in CheckRoost needs changing — the application-health check already
fetches a JSON document, sends a header, and asserts on dot-paths.

## Updates

From 1.3.0 the plugin updates itself through WordPress's own update system. The
`Update URI` header tells WordPress not to ask WordPress.org about it; instead
it reads `https://app.checkroost.com/wordpress/info.json` (cached for six hours)
and lists a newer version where every other update appears: the Plugins screen,
**Dashboard → Updates** and ManageWP. It is installed the same way, by a person
or by a tool they chose. Nothing installs itself.

Before WordPress installs a release, the plugin downloads it and its `.sig`
and checks an Ed25519 signature over the zip's SHA-384 against the public key
built into the plugin (`CHECKROOST_PUBLIC_KEYS` — a primary, and a backup
kept offline so that losing the primary is recoverable). A zip that does not match is
deleted, not installed. The private key that signs releases is on one machine,
not on the server, so somebody who took over app.checkroost.com could stop
updates arriving but could not use them to put code on a site.

The cost is that whoever holds that key can ship code to every site running the
plugin. That is true of any plugin's author, and it is the reason the key is
not on a server or in CI.

### Keeping the token out of the database

Put this in `wp-config.php` and the plugin will prefer it, storing nothing:

```php
define( 'CHECKROOST_TOKEN', 'a-long-random-string' );
```

That is the better place for it: not in a database dump, and not readable from
wp-admin by a compromised administrator session.

## Verifying it

```sh
php -l checkroost.php     # syntax
php test.php              # behaviour, against a stubbed WordPress
```

`test.php` stubs the WordPress surface this plugin touches and runs the plugin
against it: route registration, the token comparison, the document's shape,
what happens when the update transients are missing or malformed, and the
updater, including a real signed release and a tampered copy of it. It also asserts that the file **calls nothing that could change the
site**: no `update_option`, no `activate_plugin`, no `wp_update_plugins`, no
POST/PUT/PATCH/DELETE route. That last group is the security argument of this
plugin, expressed as a test so it cannot quietly stop being true.

The list of stubs at the top of `test.php` is itself the useful artefact: it is
the complete set of WordPress functions this plugin depends on, and the number
to keep small.

**What it cannot prove.** It asserts the plugin does the right thing with the
shapes it *expects*. It cannot prove real WordPress returns those shapes — only
real WordPress can. So the remaining risk is concentrated in the update
transients and `_get_cron_array()`, and it shows up as a 500 on the endpoint,
not as a broken site. See below.

## How much risk installing this actually carries

Less than it first appears, and the reason is worth knowing.

The only code that runs on **every page load** is the plugin header, its
constants, and the `add_action` and `add_filter` calls. The updater runs only
in wp-admin, when WordPress checks for or installs updates. The only code that runs on **activation**
is `random_bytes()`, `add_option()`, scheduling the daily refresh, and on the
first activation a 30-second transient that sends whoever pressed Activate to
the settings page. All of it is trivial, and all of it has
been executed by the test above.

Everything that touches a WordPress API you might doubt — the update transients,
`get_plugins()`, `_get_cron_array()` — runs **only inside the REST callback,
only when the endpoint is requested with a valid token**. If one of those is
wrong, you get a 500 on one URL that nobody but CheckRoost calls. The site keeps
serving.

That is why this is safe to put on a real site without a staging rehearsal,
provided you can get at the files. If activation ever did go wrong, deleting or
renaming the plugin directory over SFTP deactivates it immediately, and
WordPress 5.2+ refuses to enable a plugin that fatals rather than white-screening
the site.

Start with a site of your own rather than a client's, request the endpoint once,
and read the JSON before going further.

## Security notes

- The token is compared with `hash_equals`, because a plain `===` short-circuits
  on the first differing byte and leaks the token to anyone willing to measure.
- It is accepted from a **header only**, never a query parameter — query strings
  land in access logs, referrer headers and browser history.
- A wrong token and a missing token return the same uninformative 403, so a
  caller cannot use the endpoint to discover whether the plugin is installed.
- The response is `Cache-Control: no-store, private`, so a page cache or CDN in
  front of the site cannot serve a stale document — or one site's document to
  another caller.
- The endpoint is authenticated even though it exposes "only" version numbers.
  A list of installed plugins and their versions is a shopping list for anyone
  looking for a known vulnerability, and publishing it unauthenticated would
  make the site less safe than before the plugin was installed.

## What it will never do

No write routes, ever. The moment this can change something, the argument at the
top of this file stops being true and it becomes the credential it was built to
avoid. Updating plugins is a job for the host, for WP-CLI, or for a person —
not for the thing whose only job is to report honestly on what it finds.
