cms: Replace Wordfence's scanner with Salt-managed checks and harden WordPress hosting - #660
Merged
Merged
Conversation
jpmckinney
force-pushed
the
claude/wordfence-necessity-lospi2
branch
5 times, most recently
from
September 13, 2026 02:56
d16f4da to
e931fd6
Compare
Member
Author
|
Ideas from that are deliberately not done: https://developer.wordpress.org/advanced-administration/security/hardening/#disable-file-editing
|
jpmckinney
force-pushed
the
claude/wordfence-necessity-lospi2
branch
3 times, most recently
from
September 13, 2026 04:48
eb40ce1 to
90a960d
Compare
…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
force-pushed
the
claude/wordfence-necessity-lospi2
branch
from
September 14, 2026 01:40
90a960d to
bf1f763
Compare
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
xmlrpc.php, both now denied by Apache. Its scanner never recorded a finding. 179 of 196 notifications were plugin-update nags.What this does
checks.enabledper site): a daily integrity check withwp core verify-checksumsandwp plugin verify-checksums, mailing only on findings, and a daily updates check (check-updates.php, run withwp eval-file) that lists updatesWP_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 inchecks.premium_plugins.wp-config.php, paths inside dot-directories except.well-known/, PHP underwp-includes/(exceptwp-tinymce.php) andwp-admin/includes/. Salt now setswp-config.phpto 600 and the WordPress directories to 755.wordpress:constants, PHP source, per-site overrides):DISABLE_WP_CRON,DISALLOW_FILE_EDIT,WP_AUTO_UPDATE_CORE.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 centralwordpress:mu_pluginslist, files moved tofiles/mu-plugins/, key renamed tomu_plugins.wordpress.sites, withcronmoved under it.Production state
Applied on 12 September:
DISALLOW_FILE_EDITon both sites, the Apache denies, the file modes, coalition's cron jobs and inertrequire-two-factor. Pending the nextstate.apply cms: the renamed check script, the daily updates schedule, thewell-knownregex simplification,WP_AUTO_UPDATE_COREon corporate,disable-user-enumerationon coalition, and thefiles/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
wp-login.phpat Cloudflare (Integrate Fail2Ban with Cloudflare #621); fail2ban's web jails likely never match behind the proxy.ALLOW_UNFILTERED_UPLOADSwithsafe-svg, remove the abandonedpage-for-post-type.Verification
Salt states rendered against the real pillars with a Jinja harness and parsed; read-only dry runs against
cmsafter each structural change; theunlessguards for constants exercised against the production wp-config files;require-two-factoranddisable-user-enumerationtested end to end on a local copy; the.well-knownregex tested on a local Apache; phpcs and the Sphinx build clean.Also explored
require-two-factorcan shrink to the provider restriction.wp-login.phpand/wp-admin/would replace Basic auth with Google-backed 2FA, but needs Consider only accepting 80/443 connections from Cloudflare #645 first, a bypass foradmin-ajax.php, and email PINs for the agency's editors. Deferred.wp cron event run wp_version_check wp_update_plugins wp_update_themeshourly would install fixes within the hour. Not done.ps: wp-cli's--promptreads the value from stdin, whichcmd.runcan supply withhide_output. It needs a second code path for secrets, sofile.replacestays.knownFile = 0in 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.https://claude.ai/code/session_01VuqqoFUnSXGQBC5aKGg5pQ
🤖 Generated with Claude Code