Install
This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/omens-agency/omens-wp-plugin-audit/archive/refs/heads/main.zipOmens Site Audit
A stateless audit sensor for WordPress sites Omens maintains. It runs a fixed set of read-only checks (security and currency, SEO and indexability, server-side accessibility, legal and consent drift), keeps only the latest run in one option, and exports it as JSON for the central maintenance audit, which files findings in BugHerd and keeps every baseline off the site. Repo: github.com/omens-agency/omens-wp-plugin-audit (public, so sites install and update from it without a token). Plugin folder and slug: omens-wp-plugin-audit. WP-CLI command: wp omens-audit.
It runs on every kind of site we host: starter-theme builds with a repo, legacy Omens themes, and purchased themes with a child theme and nothing else. What differs is the route on each finding: on a build with a repo a template defect is code (a PR), on everything else it becomes human (someone decides).
What it is not
- Not a vulnerability scanner. Known CVEs come from the host's alerts, confirmed on the filesystem, in the central run. The plugin reports currency (updates pending), which is a different measurement.
- Not a history. One run, replaced each time. BugHerd owns the lifecycle of a finding; the central run keeps baselines (administrators, mu-plugins) where an attacker with admin access cannot edit them.
- Not a fixer. Every check is a pure read. Nothing is deleted, changed or sent anywhere.
- No outbound HTTP except loopback requests to the site's own
home_url(), sequentially, 10 seconds each, with a cache-bust query string. No secrets. No REST routes.
Commands
| Command | Does |
|---|---|
wp omens-audit run [--family=<f>] [--check=<id>[,<id>]] [--dry-run] [--format=table\|json] |
Runs the checks and stores the run. Prints one line per check as it goes. --check re-runs exactly one check to verify a fix; --dry-run prints without storing. |
wp omens-audit export [--part=run\|findings\|sample\|inventory\|meta] [--pretty] |
Prints the latest run as JSON. Exits 1 if the stored run belongs to another host or environment (a database that was copied), with the reason. |
wp omens-audit checks [--format=table\|json\|csv] |
Lists the registered checks, their family, default severity, version and whether each applies here. |
Tools > Site Audit in wp-admin shows the same run and has a nonce-checked Run now. The screen is visible to Omens accounts by default (see omens_audit/show_admin).
The run
{
"schema": 1, "plugin_version": "0.1.0", "run_at": "2026-11-06T22:10:00+00:00",
"host": "example.com", "environment": "production",
"build_type": "custom", "build_evidence": "OMENS_STARTER_VERSION 1.12.0 defined by theme example",
"theme": { "stylesheet": "example", "name": "Example", "version": "1.4.0", "author": "Omens", "parent": "", "starter": "1.12.0" },
"wp_version": "7.1.4", "php_version": "8.4.27", "locale": "en_US",
"sample": [ "https://example.com/", "..." ], "fetched": [ "..." ],
"checks": { "security.file-edit-enabled": { "version": "1.0.0", "status": "ran", "ms": 1 } },
"findings": [ { "...": "see below" } ],
"counts": { "total": 1, "severity": { "critical": 0, "serious": 1, "moderate": 0, "minor": 0, "info": 0 }, "family": { "security": 1 } },
"inventory": { "admins": [ { "id": 1, "login": "…", "email_domain": "example.com", "registered": "…", "roles": ["administrator"], "last_login": "" } ], "mu_plugins": [ { "file": "…", "name": "…", "version": "…", "sha1": "…", "size": 0 } ] },
"fetch_seconds": 0, "duration_ms": 73
}
sample is the exact list of URLs the rendered-HTML checks were given and fetched the ones that answered; the central run uses them to decide whether a finding was re-checked before it moves anything to review.
A finding
{
"fingerprint": "sha1(host|check|subject)",
"check": "security.file-edit-enabled", "subject": "DISALLOW_FILE_EDIT",
"family": "security", "source": "plugin", "severity": "serious",
"title": "Theme and plugin file editors are reachable in wp-admin",
"summary": "one sentence", "detail": "what was found, trimmed to 300 characters",
"url": "", "selector": "", "criterion": "none",
"fix": { "route": "config", "where": "wp-config.php", "steps": [ "ordered, concrete" ], "confidence": "high" },
"evidence": [ "what was measured" ],
"check_version": "1.0.0"
}
The fingerprint never includes the run date, so the same problem fingerprints the same way every month. Severities: critical, serious, moderate, minor, info (info is never filed). Routes: code, content, config, vendor, human.
The checks (version 1)
| Family | Check | Default severity | Route |
|---|---|---|---|
| security | security.php-in-uploads PHP files under uploads, minus an allowlist (omens_audit/uploads_allow) |
critical | human |
| security | security.admins-without-2fa administrators with no second factor (WP 2FA, Two Factor, Wordfence) |
serious | human |
| security | security.file-edit-enabled DISALLOW_FILE_EDIT not true |
serious | config |
| security | security.debug-display errors would print to visitors on production |
serious | config |
| currency | currency.plugins updates pending, plus a list of plugins with no update channel |
moderate | config |
| currency | currency.house-plugins the Omens plugins expected for the build type are installed, active and current |
moderate | config |
| seo | seo.indexing-blocked production noindexed, or staging indexable (the host name is cross-checked against WP_ENVIRONMENT_TYPE) |
critical | config |
| seo | seo.sitemap sitemap unreachable, empty, or listing a noindexed sampled page |
serious | config |
| seo | seo.description-missing indexable entries with no stored description, per post type |
moderate | content |
| a11y | a11y.lang-missing <html lang> missing or not the site locale |
serious | code |
| a11y | a11y.img-alt-missing no alt (code) or empty alt on a non-decorative image (routed) |
serious | routed |
| a11y | a11y.link-empty links and buttons with no accessible name (routed by href) |
serious | routed |
| a11y | a11y.img-alt-filename alt text that is a filename or meaningless |
moderate | routed |
| legal | legal.placeholder-live a template placeholder on a published legal page |
critical | content |
| legal | legal.page-missing a legal page missing, unpublished or not linked from the footer |
serious (moderate on purchased) | content |
| legal | legal.consent-missing the consent platform's script absent from the home page |
serious | config |
| legal | legal.tracker-drift a tracker in a disclosure category the privacy policy does not name |
serious or moderate by category | content |
| legal | legal.review-overdue privacy policy or accessibility statement untouched for 12 months |
moderate | human |
The two remaining version 1 checks, baseline.admin-new and baseline.mu-plugins-changed, run in the central audit from the inventories this plugin exports, not on the site.
Routed means the content-versus-code rule in includes/routing.php: a missing attribute is template code; an empty or bad value is searched for in the entry's post_content, its ACF fields, ACF options pages and (for images) the Media Library, and in the theme's own source. Found in editor content only: content, naming the field. Found in the theme only: code, naming the file. Found in both, or no usable key: confidence low, which the fix workflow treats as "propose, do not change". On purchased sites code becomes human.
Build type
custom means a theme scaffolded from omens-wp-starter-theme, detected by the OMENS_STARTER_VERSION constant its functions.php defines. Everything else is purchased, including legacy Omens themes that predate the starter (build_evidence says which). On purchased sites every code route becomes human with "theme or child theme; no repo" prepended to where. A legacy theme that does have a repo can say so:
add_filter( 'omens_audit/build_type', fn( $t ) => array( 'type' => 'custom', 'origin' => 'omens-legacy', 'evidence' => 'repo omens-agency/example-wp-theme' ) );
Configuration
Everything is a filter, in the theme or a small site plugin. Never edit the plugin's own files; every update replaces them.
| Filter | Default | Purpose |
|---|---|---|
omens_audit/checks |
all registered checks | Add, replace or remove checks (keyed by ID). |
omens_audit/severity |
the check's default | Change a finding's severity on this site: ( $severity, $check_id, $finding ). |
omens_audit/sample_urls |
home, legal pages, one entry per public post type, five most recently modified pages | Add or replace sample URLs (own host only). |
omens_audit/sample_cap |
25 | Cap on the sample. |
omens_audit/fetch_args |
10 s timeout, 3 redirects | wp_remote_get() arguments for loopback. |
omens_audit/legal_slugs, omens_audit/legal_pages |
web-legal-pages slugs; core's privacy page | Where the terms and accessibility statement live. |
omens_audit/build_type |
detected | Override the build type (shape above). |
omens_audit/schedule |
true | The monthly WP-Cron fallback. The central run is the real scheduler. |
omens_audit/capability |
manage_options |
Capability for the screen and Run now. |
omens_audit/admin_domains |
omens.agency, threeomens.com |
Email domains that see Tools > Site Audit. |
omens_audit/show_admin |
Omens account, or the site is omens.agency | Final say on who sees the screen: ( $show, $user ). |
omens_audit/last_login_keys |
common plugin meta keys | Where to read a last-login timestamp for the admin inventory. |
omens_audit/auto_update |
true | Opt out of unattended updates (they still show in Plugins > Updates). |
Writing a check
One class per check in checks/<family>/, extending Omens_Audit_Check_Base: id(), family(), title(), severity(), optionally version() and applies( $ctx ), and run( $ctx ) returning findings from $this->finding( array( 'subject' => …, 'title' => …, … ) ). $ctx->sample() is the URL list and $ctx->fetch( $url ) the loopback fetcher (cached per run; a failure is recorded once and surfaces as run.fetch-failed). A check that cannot run returns array( $this->cannot_run( 'why' ) ). A check that throws is caught and reported as run.check-failed; the rest of the run continues. Every check ships with two fixtures, one that fires and one that does not, under tests/.
Installing and updating
wp plugin install https://github.com/omens-agency/omens-wp-plugin-audit/releases/latest/download/omens-wp-plugin-audit.zip --activate
The plugin updates itself from GitHub releases through Plugin Update Checker (vendored). Only the release asset named omens-wp-plugin-audit.zip is accepted, never GitHub's source tarball, and full releases apply themselves on every site within about a day. tools/release.sh <version> publishes a pre-release; after a staging install and the smoke test, VERIFIED=1 tools/promote.sh <version> makes it the full release.
Developing
tools/fixtures.sh [filter]runs every check's fixtures undertests/checks/, one PHP process per case against the WordPress stubs intests/bootstrap.php, and fails if any registered check lacks afiresand acleancase. The routing rule has one fixture per branch (in thea11y.img-alt-missinganda11y.link-emptyfiles).tools/smoke.sh ~/Studio/<site>symlinks the checkout into a local site and runs the M1 assertions (activation, the three commands, schema, dry run, fingerprint stability, foreign-run refusal, cron, uninstall). Local sites only.php -lon every file andphpcs(WordPress-Extra,phpcs.xml.dist) before a commit.- State: one option,
omens_audit_last_run, not autoloaded, keyed by host and environment. If a change needs more state than that, stop and reread the plan.
Record
Build decisions are in docs/decisions.md. The plan, check catalog and spike measurements live in the Omens Library beside this checkout and are not in the repo.