Skip to content

cms: Replace Wordfence's scanner with Salt-managed checks and harden WordPress hosting - #660

Merged
jpmckinney merged 14 commits into
mainfrom
claude/wordfence-necessity-lospi2
Sep 14, 2026
Merged

jpmckinney merged 14 commits into
mainfrom
claude/wordfence-necessity-lospi2

Conversation

@jpmckinney

@jpmckinney jpmckinney commented Sep 12, 2026 •

Copy link
Copy Markdown
Member

Replaces what Wordfence's scanner and vulnerability feed did for the WordPress sites with Salt-managed checks, prepares the replacement for its two-factor authentication, and closes several hardening gaps found along the way. Wordfence itself is not removed here; that follows once 2FA is on the Two Factor plugin.

Why

An audit of Wordfence's own tables on coalition (dump of 29 July) and of iThemes Security's on corporate (backup of 11 September) found:

  • Every firewall block Wordfence recorded was for a PHP file that does not exist or for xmlrpc.php, both now denied by Apache. Its scanner never recorded a finding. 179 of 196 notifications were plugin-update nags.
  • The one real attack, 33 IPs each trying the coalition administrator's username once or twice in February 2026, was stopped by the password and TOTP, not by rate limiting. It reached PHP despite Basic auth, most likely over XML-RPC, which Wordfence's own setting allowed and Apache now denies.
  • iThemes recorded 20 vulnerabilities on corporate in two years, all version lag with a fix available, resolved by updating; zero integrity findings. Its file-change detection had not run since 2018.
  • The only thing Wordfence still provides that nothing else does is TOTP for administrators.

What this does

  • Checks (checks.enabled per site): a daily integrity check with wp core verify-checksums and wp plugin verify-checksums, mailing only on findings, and a daily updates check (check-updates.php, run with wp eval-file) that lists updates WP_Automatic_Updater::should_update() declines, such as major versions, and plugins whose update API gave no answer, which catches abandoned plugins and lapsed licenses. Plugins wordpress.org does not distribute are listed in checks.premium_plugins.
  • Apache: deny wp-config.php, paths inside dot-directories except .well-known/, PHP under wp-includes/ (except wp-tinymce.php) and wp-admin/includes/. Salt now sets wp-config.php to 600 and the WordPress directories to 755.
  • wp-config constants from pillar (wordpress:constants, PHP source, per-site overrides): DISABLE_WP_CRON, DISALLOW_FILE_EDIT, WP_AUTO_UPDATE_CORE.
  • Must-use plugins: require-two-factor (redirects administrators without a second factor to their profile until they enrol, and limits Two Factor to TOTP and backup codes; inert until the Two Factor plugin is active), disable-user-enumeration (hides the users REST endpoints and sitemap from anonymous visitors), a central wordpress:mu_plugins list, files moved to files/mu-plugins/, key renamed to mu_plugins.
  • cms state collapsed to one loop over wordpress.sites, with cron moved under it.

Production state

Applied on 12 September: DISALLOW_FILE_EDIT on both sites, the Apache denies, the file modes, coalition's cron jobs and inert require-two-factor. Pending the next state.apply cms: the renamed check script, the daily updates schedule, the well-known regex simplification, WP_AUTO_UPDATE_CORE on corporate, disable-user-enumeration on coalition, and the files/mu-plugins/ move (a no-op on disk). Corporate's opt-in to auto-updates, the checks and 2FA enforcement is kept out of this PR as a patch, to apply once its administrators expect it.

Follow-ups, not in this PR

  • Install Two Factor on coalition, enrol the administrators, then remove Wordfence; same on corporate after its rollout. Every administrator re-enrols once, since secrets do not port.
  • Restrict the origin to Cloudflare (Consider only accepting 80/443 connections from Cloudflare #645) and rate-limit wp-login.php at Cloudflare (Integrate Fail2Ban with Cloudflare #621); fail2ban's web jails likely never match behind the proxy.
  • Corporate: replace ALLOW_UNFILTERED_UPLOADS with safe-svg, remove the abandoned page-for-post-type.
  • Explored and not implemented: a cooldown before auto-installing plugin releases, keyed on when a version is first seen. It would have covered the June 2024 wordpress.org supply-chain attack and Gravity Forms' backdoored 2.9.12 in July 2025, at the cost of the same delay on every genuine security fix.

Verification

Salt states rendered against the real pillars with a Jinja harness and parsed; read-only dry runs against cms after each structural change; the unless guards for constants exercised against the production wp-config files; require-two-factor and disable-user-enumeration tested end to end on a local copy; the .well-known regex tested on a local Apache; phpcs and the Sphinx build clean.

Also explored

  • Why Two Factor. Wordfence Login Security, the standalone 2FA plugin, was closed on wordpress.org on 17 August 2026. Of the free alternatives, only WP 2FA has built-in role enforcement and a grace period, but it has had two 2FA-bypass advisories in 18 months; Two Factor has none, hence Two Factor plus a small enforcement plugin. Per-role enforcement is in review upstream (Add: Site-wide Two-Factor enforcement per user role WordPress/two-factor#845); once it ships, require-two-factor can shrink to the provider restriction.
  • Cloudflare Access in front of wp-login.php and /wp-admin/ would replace Basic auth with Google-backed 2FA, but needs Consider only accepting 80/443 connections from Cloudflare #645 first, a bypass for admin-ajax.php, and email PINs for the agency's editors. Deferred.
  • Faster auto-updates. WordPress checks twice daily. Running wp cron event run wp_version_check wp_update_plugins wp_update_themes hourly would install fixes within the hour. Not done.
  • Database credentials through the constants mechanism. Feasible without exposing the password in ps: wp-cli's --prompt reads the value from stdin, which cmd.run can supply with hide_output. It needs a second code path for secrets, so file.replace stays.
  • Change detection for premium plugins. Neither security plugin verified them either: every ACF Pro file was knownFile = 0 in Wordfence's table, and iThemes' file-change scan last ran in 2018. A nightly hash diff of those directories would be the equivalent; not done, since every legitimate update would report.
  • Virtual patching is the one capability lost. iThemes Pro's Patchstack integration auto-applied a firewall rule for W3 Total Cache ≤ 2.10.5 on 4 September, four days before the fix shipped. Nothing here does that; the mitigations are auto-updating everything and keeping the plugin count low. W3 Total Cache has since been removed from corporate.
  • The audit window is short. Wordfence prunes its logs to about 2,000 login rows, iThemes to 15 days, and the S3 backups keep 31 days, so the evidence covers weeks rather than years, except iThemes' vulnerability table.

https://claude.ai/code/session_01VuqqoFUnSXGQBC5aKGg5pQ

🤖 Generated with Claude Code

@jpmckinney
jpmckinney force-pushed the claude/wordfence-necessity-lospi2 branch 5 times, most recently from d16f4da to e931fd6 Compare September 13, 2026 02:56
@jpmckinney

Copy link
Copy Markdown
Member Author

Ideas from that are deliberately not done: https://developer.wordpress.org/advanced-administration/security/hardening/#disable-file-editing

  • DISALLOW_FILE_MODS: disables the auto-update must-use plugin, core updates and the agency's plugin updates.
  • Move wp-config.php above the web root: breaks the file.replace in salt/cms/init.sls, the documented wp config set steps, and probably the agency's Buddy.works deploys.
  • Make wp-content non-writable by the site user: PHP-FPM is the owner by design; uploads and updates would break.
  • Basic auth on all of /wp-admin/: breaks admin-ajax.php for Fluent Forms and Gravity Forms. The wp-login.php-only scope is correct.
  • Rename the table prefix on a live database.

@jpmckinney
jpmckinney force-pushed the claude/wordfence-necessity-lospi2 branch 3 times, most recently from eb40ce1 to 90a960d Compare September 13, 2026 04:48
James McKinney and others added 13 commits September 13, 2026 21:40
…Wordfence

A WordPress site opts in via its Pillar file:

    wordpress:
      sites:
        USERNAME:
          checks:
            enabled: True
            premium_plugins:
              - advanced-custom-fields-pro

Each check prints only problems, so that cron mails the site's contact only
if there is something to act on.

- integrity (daily): wp core verify-checksums and wp plugin verify-checksums
  --all, dropping the Success lines. --quiet would also drop the warnings
  that name the files. Plugins that wordpress.org doesn't distribute have no
  checksums; list them in `premium_plugins`. The Salt-managed must-use plugins are excluded
  automatically, as newer wp-cli versions verify them too.
- updates (daily): a wp eval-file script that lists the core, plugin and
  theme updates that WP_Automatic_Updater::should_update() declines, i.e.
  major versions and updates blocked by requirements.

Opt in the coalition site, whose ACF Pro is not distributed by wordpress.org.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013s8xuN8YKjFJgP75AJd8Zg
Such a plugin can never update: wordpress.org no longer distributes it, or
its license has expired. Nothing surfaces this, because no update is ever
offered. WordPress records every plugin it asked about in the update
transient's `checked` list and every answer in `response` or `no_update`,
so a plugin in neither was not answered for.

Verified against production: corporate has one (page-for-post-type, gone
from wordpress.org), coalition has none, and every licensed plugin
answered, so commercial plugins with valid licenses are not false
positives.

Fold it into the weekly updates check rather than add a cron job, since it
reads the same transient.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VuqqoFUnSXGQBC5aKGg5pQ
public_html/.env is a symlink to the theme's, which holds deployment
configuration. Apache's stock rules deny only ^\.ht, so the protection had
been a hand-written block in public_html/.htaccess, outside Salt.

Matches filenames, so .well-known/ challenges still resolve for mod_md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJdsVySwCahp5j8QJnVciP
…manage WordPress file modes

`<FilesMatch "^\.">` matches the requested file's basename only, so
`.git/HEAD` and `.git/config` under the coalition theme checkout were
served (HTTP 200). Add a `DirectoryMatch` for paths inside any
dot-directory, exempting `.well-known` for ACME.

Also deny `wp-config.php` (only PHP includes it), PHP files under
`wp-includes` except `wp-tinymce.php` (the one browsers request),
`wp-admin/includes`, and the installer, `wp-admin/install.php`. Neither the Salt include nor either site's `.htaccess`
blocked these.

Salt now sets wp-config.php to 600, .htaccess to 644, and wp-content and
its uploads, plugins and themes directories to 755. Coalition had 664 and
775. PHP-FPM runs as the owner, so nothing else needs access.

Document `DISALLOW_FILE_EDIT` as a setup step. It is read only in the
capability map, so plugins keep writing .htaccess; `DISALLOW_FILE_MODS`
would break auto-updates and is called out as such.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VuqqoFUnSXGQBC5aKGg5pQ
The Two Factor plugin has no role enforcement of its own
(WordPress/two-factor#846). This redirects users with `manage_options` and
no second factor to their profile page, where Two Factor's settings live,
and shows a notice there. The profile page and admin-ajax.php are exempt so
enrolment can complete.

It does nothing until the Two Factor plugin is active, so it can deploy
ahead of the plugin. It replaces Wordfence's "2FA required for
administrators", in place on coalition since 2023, which is the one
Wordfence feature still in use there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VuqqoFUnSXGQBC5aKGg5pQ
`wordpress:constants` in pillar/cms.sls holds the defaults for every
site, as PHP source: DISABLE_WP_CRON and DISALLOW_FILE_EDIT. A site's own
`constants` mapping overrides or adds to them; coalition adds
WP_AUTO_UPDATE_CORE=minor. Each constant is a `wp config set --raw`, guarded by an `unless`
that compares `wp config get`, which prints the evaluated value, with
`php -r 'echo …;'` of the same source, so the file is only rewritten when
a value differs.

A dry run against cms confirms the guard: every constant already matches,
so nothing runs on deploy.

Replaces three manual steps in the WordPress setup docs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VuqqoFUnSXGQBC5aKGg5pQ
Email codes are only as strong as the mailbox, and every Wordfence
enrolment on both sites was an authenticator app, so this keeps parity.
Backup codes cover a lost phone.

Tested on the local coalition copy: the profile page offers only the two
providers, enforcement redirects until a TOTP secret is enrolled, and
admin-ajax.php and editors are untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VuqqoFUnSXGQBC5aKGg5pQ
They are must-use plugins. A site's regular plugins are managed in
WordPress, not in pillar.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VuqqoFUnSXGQBC5aKGg5pQ
The phpfpm.sites loop created the site user, its directories, the cron
MAILTO and the WordPress cron job, keyed on each pool's context.user. Every
PHP-FPM site on this server is a WordPress site, and the wordpress.sites
key is that same user, so one loop over wordpress.sites does it all.

Move `cron` (contact, ignore) from phpfpm.sites to wordpress.sites: it
configures the WordPress cron job's mail, not PHP-FPM. Document the site
entry, since `contact` is required.

A dry run against cms shows the same pending changes as before, and no
change to the user, directory, MAILTO or cron states.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VuqqoFUnSXGQBC5aKGg5pQ
check-updates.php stays in files/: it is run by wp eval-file, not loaded
as a plugin.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VuqqoFUnSXGQBC5aKGg5pQ
Hides the REST API's users endpoints and the users sitemap from visitors
who are not logged in. Author archives stay, because the sites link to
them; their URLs still carry each author's nicename.

The coalition theme already removes the users sitemap, and Yoast replaces
core sitemaps on corporate, so the sitemap filter is a default for whatever
theme comes next rather than a change today.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VuqqoFUnSXGQBC5aKGg5pQ
….sls

`wordpress:mu_plugins` holds the must-use plugins for every site, as
`wordpress:constants` holds the constants. A site's own `mu_plugins` add
to them. The three plugins both sites had move to the central list, and so does
require-two-factor, which is inert on a site without the Two Factor plugin.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VuqqoFUnSXGQBC5aKGg5pQ
…ll with your own user

Two Factor is installed, constants and must-use plugins are added, and the
checks are scheduled before a single deploy, rather than deploying between
steps. Interpreting the checks' emails becomes an admonition.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VuqqoFUnSXGQBC5aKGg5pQ
@jpmckinney
jpmckinney force-pushed the claude/wordfence-necessity-lospi2 branch from 90a960d to bf1f763 Compare September 14, 2026 01:40
…r tables

Neither plugin is installed, and neither site has the tables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VuqqoFUnSXGQBC5aKGg5pQ
@jpmckinney
jpmckinney merged commit fab64c3 into main Sep 14, 2026
14 checks passed
@jpmckinney
jpmckinney deleted the claude/wordfence-necessity-lospi2 branch September 14, 2026 01:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant