The README says how to install the plugin, which switches to turn on and what needs APCu. This document is the long version of each feature: exactly what is read, what is sent, what never leaves the site, and what codesafe does with it. Read it when a switch's one-line description is not enough.
- Traffic rollups
- Software inventory
- Auto-update probed vulnerable plugins
- The Shield
- Integrity scan
- Security events
- What never leaves the site
- Automatic, signed updates
Error reports alone have no denominator: "37 requests for pages this site
does not serve" reads very differently on 50 000 requests a minute than on
200. With rollups enabled, the plugin counts every request WordPress handles
into per-minute counters — request total, split by response status, HTTP
method, the page type WordPress resolved (front page, singular/{post_type},
archive/{taxonomy}, search, login, admin, REST, …) and logged-in state —
and ships each completed minute as one small POST to codesafe. A
request answered 404 counts only as "matched nothing", which is exactly the
scanner-probe signal codesafe's attack detection reads as a rate.
Since 0.5.1 the same fragment also carries request-duration histograms: every request's wall time (PHP start to shutdown) counted into 12 fixed buckets, per site and per page type. codesafe reads them back as ≈p50/≈p95 trends with release markers and a slowest-pages table on its PERFORMANCE panel — bucket counts only, so a raw timing never leaves the site and the payload grows by a few hundred bytes a minute.
Since 0.7.0 it also carries the Shield's hit counters — how many requests each rule observed and how many it refused, per minute — which is what the SHIELD band's hour, day and lifetime columns are made of, and what the alarm judges a blocking rule by. Counts per rule id, nothing else.
What deliberately never travels: URLs, query strings, IP addresses, user agents, cookies, or anything else request-derived — the counter names come from a closed vocabulary WordPress itself defines, so the payload is structurally incapable of carrying visitor data.
Enabling it takes two switches (either one off keeps the feature inert):
-
In WordPress: Settings → ovos codesafe → Traffic rollups, or lock it in
wp-config.php:define('OVOS_CONSOLE_ROLLUPS', true);
-
In codesafe: tick Traffic rollups (
rollups_enabled) on the project — a sender posting to a project without it is refused and stays inert, so enabling the two sides in either order is safe.
Requirements and caveats:
- APCu is required (the
apcuPHP extension, enabled for the web SAPI). Counters accumulate in APCu shared memory and one request per minute ships them; without APCu the feature is a silent no-op — no counting, no sends, no errors — because a WordPress host without shared memory could only produce undercounted numbers, and a wrong denominator is worse than none. The settings page says above the switch whether APCu is there. - Requests served entirely by a page-cache plugin (or a CDN) before WordPress boots are not counted — cached traffic never reaches PHP. Probe traffic is never a cache hit, so the attack signal is unaffected.
- Overhead is one APCu increment set per request (sub-microsecond, no I/O) plus a single sub-second POST per minute of traffic, sent after the response went out.
Off by default, because an installed-software list is a disclosure: it
names exactly which plugins (and versions) a site runs, which is precisely
what an attacker probes for. It therefore ships only when BOTH ends opt in —
this setting (or OVOS_CONSOLE_INVENTORY) and the project's CVE switch in
codesafe; codesafe answers 403 and stores nothing until its side is on too.
What one report contains, exactly: platform (wordpress), the core and
PHP versions, and up to 300 items of {type: plugin|theme|mu-plugin, slug, version, name, active}. No file paths, no option values, no user data —
the report says what is installed, never where or how it is configured.
When it ships: once a day (the heartbeat codesafe reads as "the sensor is alive"), and after anything that changes the list — installs, updates, (de)activations, deletions, theme switches, core updates. The change hooks only mark a flag; the actual gather-and-send runs at shutdown, fire-and- forget with the same millisecond bounds as every other call this plugin makes, so it can never slow a visitor down. An unchanged report is acknowledged by codesafe as a duplicate and costs one timestamp update.
codesafe's nightly security cve-sync matches stored inventories against
the vulnerability feeds and keeps per-project findings: installed version,
the version that fixes it, CVSS, the CVE and its weakness class — and a
PROBED count when codesafe has already seen requests naming that plugin's
path, which is the "you run X and someone is looking for it" signal worth
acting on first. A finding is a case in the INBOX from the moment it is
probed; it resolves by itself when the inventory says the update landed.
The answer to an inventory report may name the installed plugins that are
vulnerable and already being probed on this site. With Auto-update
probed vulnerable plugins on (or OVOS_CONSOLE_AUTO_UPDATE_VULNERABLE), the
plugin adds exactly those to WordPress' own auto_update_plugins list — the
same toggle you flip on the Plugins screen, the one virtual patch WordPress
supports natively — and reports each switch-on as a privileged_action
security event, so codesafe's audit trail carries it.
Nothing is downgraded, deactivated or deleted, and nothing is installed by this plugin: WordPress updates from wordpress.org on its own schedule, the way it would for a plugin you ticked yourself. Opt-in at both ends — codesafe names plugins only while the project's own Auto-update switch is on — and a plugin that later drops out of the vulnerable set keeps its auto-update; that is a WordPress setting you own, and the plugin never takes one away.
A CVE in a plugin you run has a window: from the day it is public until the day you update. Scanners read the same advisories you do, and the exploit usually arrives before the patch is out or before you get to it. The Shield closes that window with a request-side match, run by this plugin before WordPress does anything: codesafe drafts the exploit's request shape from the CVE's fix commit or advisory, a person reviews it, and every site with an open finding of that CVE pulls the rule and matches its requests against it.
Two switches, four gates. Exploit detection (OVOS_CONSOLE_SHIELD_DETECT)
pulls the rules and observes: a match is reported to codesafe as a
shield_observe security event — the rule, the CVE, the matched fragment
capped at 200 bytes, the address — and the request is served. Nothing is
blocked. Block detected exploits (OVOS_CONSOLE_SHIELD_ENFORCE) answers
403 to a request that matches a rule marked PROVEN — a rule a person
promoted in codesafe after reading what it matched on live traffic — and
reports it as shield_block, with an X-Shield-Rule header naming the rule.
Before any request is refused, four things have to be true: codesafe's own
per-project blocking switch, the rule's proven mode, this site's detection
box, and this site's blocking box. An observe rule never blocks, whatever the
boxes say. Both boxes are read on every request and never cached with the
rules, so unticking acts on the very next request. OVOS_CONSOLE_SHIELD_KILL
(a constant only) switches the whole Shield off with no network at all — for
a host that must never call home.
What is read per request: the path and query string, the user agent, the
address — and the request body only while a body rule is live for this
site (form fields as name=value lines, other bodies capped at 64 KB).
Nothing of it is stored or sent except the matched fragment.
Where the rules live: the kernel pulls GET /api/v1/shield from codesafe
every five minutes as a conditional request (a 304 costs nothing), and caches
the ruleset in APCu and in wp-content/ovos-console/shield.json behind a deny
.htaccess — the file alone on a host without APCu. A codesafe that goes
silent for a day lifts the rules: a stale shield is not a shield. The rules a
site holds are never more than 25, and each is one of uri, ua, ip or
body with equals, prefix, contains, cidr or a bounded regex —
the same grammar the JS side and codesafe's own screen run, conformance-tested
in all three, with nothing that can be made to run slow.
It fails open, always. A rule the engine cannot run, a codesafe that does not answer, a store that cannot be written, an error of any kind — the request is served as if the Shield were not there. The Shield may never be why a page does not load.
Rules are temporary by construction. A finding rule exists only while a live CVE finding on this site justifies it: update the plugin, and the rule leaves the next pull by itself. A rule a person writes by hand in codesafe carries an expiry or a name and a note — every rule either expires or is accountable.
The alarm. A blocking rule can be wrong — a pattern wider than the fix that refuses an editor's save, a user-agent fragment shared with a partner's webhook. codesafe watches every PROVEN rule's refusals: signed-in requests refused, a spread of addresses that is a real share of the site's traffic, addresses with no attack history. Any of those raises an Urgent case in the INBOX with one action — DEMOTE TO OBSERVE — and pages the project's mail and chat. Nothing is demoted automatically; a person decides, and the sites stop refusing within five minutes of the click.
The Shield status line under the two switches says what this site holds: how many rules are live and how many of them may block, when the ruleset was last pulled, and whether blocking is on — or why the Shield is off.
The Shield needs the software inventory (the rules follow the findings) and the project's CVE switch in codesafe. Hits are counted into the traffic rollups where APCu is available.
Every other sensor in this plugin needs the foreign file to do something after
the plugin is installed: throw an error (so source can say uploads or
unknown), be saved through the editor, be activated. A webshell dropped before
the plugin arrived, used once and left behind, does none of that. The integrity
scan asks the tree directly.
What it looks for, in a read-only walk (root → uploads → wp-content → plugins → core → themes, so an interrupted pass has already covered the urgent part):
| finding | tier | what it catches |
|---|---|---|
executable-shaped file under uploads (x.php, shell.php.jpg, .phtml, .phar) |
urgent | the classic drop — the index.php listing stubs plugins write there are recognised and skipped |
media file whose first kilobyte carries <?php / <?= (.ico .jpg .png .gif .webp .bmp .pdf .svg; .txt .log .csv under uploads) |
urgent | the favicon_a1b2.ico family, the image behind an AddHandler |
| PHP in the document root that is not one of core's own root files | urgent | about.php, wp-conflg.php; Wordfence's wordfence-waf.php is recognised when Wordfence is installed |
.htaccess / .user.ini / php.ini directives: auto_prepend_file / auto_append_file to a file no installed plugin owns, AddHandler/AddType mapping PHP to another extension, SetHandler to PHP, engine on or CGI under uploads, RewriteRule/Redirect/ErrorDocument to another host |
urgent / high | persistence, the polyglot's trigger, the redirect hack — reported as directive kind, file and line, never the line's text |
the live auto_prepend_file / auto_append_file of the PHP configuration |
urgent | the .user.ini trick from the running interpreter's point of view |
PHP in a hidden path (.x.php, .well-known/…) |
high | the hidden drop — dotfile tool configs in vendored packages are recognised |
PHP in wp-content outside any plugin, theme, mu-plugin or upload path |
high | wp-content/strange/s.php; WordPress' own .l10n.php translations are recognised |
a drop-in (object-cache.php, advanced-cache.php, db.php, wp-cache-config.php) with none of its known plugins installed and no vendor header |
high | the shell disguised as a cache config — a drop-in with a vendor header or an installed owner is listed as info |
a plugin data directory (wflogs, w3tc-config, updraft, …) whose plugin is not installed |
high | listed and never descended: the directory is the finding |
must-use plugins, drop-ins with an owner, install.php |
info / high | listed for the operator; install.php on a live site is high |
checksums — a core file that differs from wordpress.org's list for this version, or a PHP file under wp-admin/ / wp-includes/ the list never had |
urgent | the backdoored wp-load.php, the class-wp-helper.php nobody shipped — core_modified / core_foreign |
| checksums — the same two verdicts inside a wp.org plugin's directory, against the plugin's own list for its installed version | high | plugin_modified / plugin_foreign; a premium or custom plugin has no list and is not judged, themes are never judged this way |
| database — administrators (registration date, sessions, last login), application passwords on admins, active plugins whose file is gone, scheduled hooks nobody listens to, uninstall callables of gone plugins, options carrying code markers, foreign scripts / iframes / obfuscation in published content and widgets (posts and pages, plus the rows a block theme keeps its markup in: Site Editor templates and template parts, global styles, the Customizer's Additional CSS — none of them files), a site URL disagreeing with its constant, registration into a role above subscriber | info – high | the attacker's other filesystem; ids, option names and hook names only — never a login, an e-mail, a value or a post body |
| a directory under uploads or in the root that changed after its newest file | info | something was removed or renamed here recently — dated, so codesafe can hold it against the waves |
| a file owned by another uid than its siblings; a symlink leaving the site | high | the web server writing among the deploy user's files; the symlink attack (silent on Windows and single-uid hosting) |
Plus which protection is present — the active login-rate-limit, two-factor and
upload-scanner plugins, or "none detected" (commodity features this plugin
deliberately does not re-implement) — and a debug.log written under the
document root as a high finding.
Plus the posture: DISALLOW_FILE_EDIT, DISALLOW_FILE_MODS, debug
display, whether uploads denies PHP by .htaccess (Apache/LiteSpeed only),
world-writable uploads, world-readable wp-config.php, XML-RPC, open
registration and its default role, version control in the document root,
readme.html, and the ini fingerprint (auto_prepend_file, open_basedir,
disable_functions, user_ini.filename, OPcache).
The checksum lists come from wordpress.org — core by version and locale
(api.wordpress.org/core/checksums), every wp.org plugin by slug and installed
version (downloads.wordpress.org/plugin-checksums) — fetched once per version,
kept in a transient for a month, a 404 remembered for a week. A background
chunk fetches at most one list and yields the rest to the next request; a
Scan now round fetches three. Listed files missing from disk are counted per
area, never listed: hosts strip readmes, and a missing file is not an
intrusion. Every finding a list produced carries md5 differs … or not in the wordpress.org list … as its detail, so the authority behind it is visible.
The database checks run once per pass, bounded (LIMITs, twenty findings per
detector), read-only, and report what can be acted on without disclosing
anything private: users/<id> with the registration date as the finding's
date (codesafe holds it against the attack waves of the same hours —
admin #57 was registered forty seconds after 27.0.113.86 stopped probing),
options/<name> with the length and the marker, options/cron/<hook>,
posts/<id> with the foreign host.
What it never does: write, delete, rename, quarantine or touch .htaccess;
follow a symlink; descend into .git, node_modules, wp-content/cache or
wp-content/upgrade; read more than the first kilobyte of a media file or
64 KB of a directive file; send a file's content. Findings are paths, sizes
and dates. A finding is a place to look, not a verdict.
Scan now — the button under Settings → ovos codesafe → Integrity scan. Each
round spends up to fifteen seconds (always five short of max_execution_time)
and the page re-submits itself until the pass is complete; the results table
stays on that page, codesafe or not. Background — the Integrity scan
switch (or OVOS_CONSOLE_SCAN): half a second per request after the response
went out, one pass per interval (OVOS_CONSOLE_SCAN_INTERVAL, 1 or 7
days), the position kept in one option between requests, no WP-Cron, no APCu.
A completed pass is posted to codesafe's /api/v1/ingest/files (the
SECURITY view's FILES band); an instance without that endpoint answers 404 and
the settings page still shows the result.
The tier on a finding is the plugin's proposal. codesafe decides — against the project's repository where one is mapped, the attack waves of the same hours, and what an operator has already acknowledged — and a new urgent or high finding, or a path that comes BACK after a scan said it was gone, pages the project's channels at once.
Errors say what broke; security events say what was refused — and refusals are where an attack is visible before anything breaks. With the switch on, the plugin reports:
- Failed logins (
auth_failure) — every door funnels through the same hook: the wp-login form, XML-RPC, REST basic auth, and rejected application passwords. The username is masked to every fourth character, the rest starred (marcin->m***i*), so the line keeps the length — and past 24 characters it states the real length instead (x***x***...[4000]), which is what a credential-stuffing probe looks like; the reason travels as WordPress' error codes (invalid_username,incorrect_password), never as core's HTML error messages. - The login that succeeded after failures (
auth_success) — the credential-stuffing success, with the failure counts and the account it landed on. Clean logins are never reported. - Rejected nonce checks (
csrf_reject) — a failedcheck_admin_referer/check_ajax_refereris the CSRF signal (or an expired-session replay); the nonce action name says which form was targeted. - Forbidden REST calls (
permission_denied) — REST requests answered 401/403, the shape of user enumeration and capability probing; reported with the error code and route. - Sensitive admin changes (
privileged_action) — the moves an attacker makes after getting in, routine for an admin but an audit trail during an incident: user creation and role grants (an administrator created is its own kind), plugin and theme installs and activations, changes to theusers_can_register,default_role,admin_email,siteurlandhomeoptions (the option name only — values are deliberately not reported), saves from the theme/plugin file editor, application passwords minted for administrators, and every plugin the auto-update switched on.
They arrive in codesafe as security events, grouped apart from errors and
accepted independently of the project's severity threshold. The refusals
(failed logins, rejected nonces, REST 401/403s) are informational: no issues,
no alerts, they feed codesafe's attack detection and the address reputation.
The two kinds that happened do become issues — a login that succeeded after
failures, and a sensitive admin change — and codesafe's default rules escalate
the ones that matter: a success after five or more failures or from a known
attacker's address, a theme install through the admin (the forced install that
opens the Click2Shell chain on WordPress before 7.1.1), a file-editor save and
a newly minted application password — those reach whoever the project's alert
policy names. Reports are capped at 60 per minute — a credential-stuffing run
cannot turn the reporter into the flood it surfaces.
Enable it under Settings → ovos codesafe → Security events, or lock it in
wp-config.php:
define('OVOS_CONSOLE_SECURITY_EVENTS', true);The codesafe side is on by default for every project (the per-project
Security events switch under the project's Data tab is the off switch).
The Shield's own two kinds (shield_observe, shield_block) do not depend on
this switch — detection is its own consent.
Every report is reduced before it is sent, in the plugin — codesafe redacts again on its side as a backstop, but the first cut happens here:
- Credentials are dropped, by field name, wherever they stand: request
variables, headers, the request body and the URL's query string. The list
covers passwords in every spelling (
pass,pwd,user_pass,pass1), tokens, secrets, keys, authorization headers, cookies — and, since 0.6.6, a CSRF nonce (_wpnonce,_ajax_nonce, every field withnonceat a word start). A nonce is short-lived, but it is a per-user, per-action token, and it had no business travelling. Names that merely contain the letters (announce) are untouched. - The request body is a document, not text (0.6.4):
structure, the default, parses it — form, JSON, XML-RPC — walks the keys the way the variables are walked (soopts[api_key]is caught, and a percent-encoded credential is decoded first), masks what is left inside the values, and re-encodes at 8 KB. A body that will not parse is dropped rather than handed to a regex.offsends none;fullis the raw body, 16 KB. - Usernames are masked to every fourth character (
marcin->m***i*); e-mail addresses in any value are masked with the domain kept. - Traffic rollups carry counts under names WordPress itself defines — never a URL, an address, a user agent or a cookie.
- The inventory says what is installed, never where or how it is configured; the scan sends paths, sizes and dates, never content; the Shield sends the matched fragment, capped at 200 bytes, and reads the body only while a body rule is live.
- Browser reports strip input values and scripts from a DOM snapshot in the browser, before upload.
From 0.4.5 on the plugin keeps itself current, with no updater plugin, license
key or update server involved. Its header declares the GitHub repository as its
Update URI, so WordPress core's own update flow (5.8+) asks the plugin for
its latest GitHub release: new versions appear under Dashboard → Updates
and install like any directory plugin, straight from the release zip.
- One-click, from Dashboard → Updates or the Plugins screen, or
wp plugin update ovos-console. - Unattended — flip Enable auto-updates in the Plugins list (
wp plugin auto-updates enable ovos-console) and core's twice-daily cron installs new versions on its own. - The check is fire-and-forget: an offline host, a rate-limited GitHub API or a missing asset just means "no update visible right now", never an error on your dashboard.
- A successful answer is cached for twelve hours. Check again on the updates screen bypasses core's own cache; to also drop the plugin's,
wp transient delete ovos_console_latest_release.
Every release is signed, and the plugin verifies the signature before WordPress installs a byte (since 0.6.5). The release workflow signs the zip — Ed25519, over the whole file — with a key that lives in a GitHub environment admitting release tags only, and publishes the signature beside the zip (ovos-console.zip.sig). On update the plugin downloads both, verifies with sodium_crypto_sign_verify_detached() (WordPress bundles it for its own update signing, so it exists on every install) and hands WordPress the verified file. A package that does not verify is refused with a message on the update screen and nothing is installed; a release without a signature is not offered at all. There is no degraded mode. The public key is in the source (Updater::PUBLIC_KEY, 0d91f295…919c04) and in RELEASE.md.
Sites still on 0.4.4 or older need one last manual install of a newer version; everything after that arrives through the updater. Sites on 0.6.4 or older install their next release unverified — they predate the check — and verify every one after it.