Add critical security-update task with UI lockdown and admin alerts - #769
Add critical security-update task with UI lockdown and admin alerts#769tacoverdo wants to merge 8 commits into
Conversation
When a WordPress core security release is available (any patch release on the installed branch, e.g. 6.8.1 -> 6.8.2), the plugin now: - Publishes a top-priority, non-dismissable, non-snoozable "security-update" task that becomes the ONLY recommendation shown to users who can install it, until the update is installed. Editors, user to-dos and pending celebrations are unaffected. - Emails all administrators (update_core users) directly via wp_mail, once per offered version. - Pings the new progress-planner-saas/v1/security-update-alert endpoint (license key + remote nonce, same pattern as change-site-url) so the SaaS can email the registered subscriber, and exposes a security_updates block in the get-stats payload. Detection runs on the set_site_transient_update_core hook so it works from front-end cron without an admin visit. Task IDs are versioned per release; superseded or withdrawn offers are cleaned up silently, and installing the update (manually or via auto-update) completes the task. Also hardens rest_prepare_recommendation() against an undefined prpl_url meta index when meta is not registered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Test on Playground |
✅ Code Coverage Report
🎉 Great job maintaining/improving code coverage! 📊 File-level Coverage Changes (14 files)🆕 New Files
📈 Coverage Improved
ℹ️ About this report
|
🔍 WordPress Plugin Check Report
📊 Report
❌ Errors (1)📁 readme.txt (1 error)
|
| 📍 Line | 🔖 Check | 💬 Message |
|---|---|---|
0 |
update_modification_detected | Plugin Updater detected. Detected code which may be altering WordPress update routines. Detected: _site_transient_update_core |
📁 classes/suggested-tasks/providers/class-content-review.php (4 warnings)
| 📍 Line | 🔖 Check | 💬 Message |
|---|---|---|
232 |
WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in | Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information. |
377 |
WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in | Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information. |
381 |
WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in | Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information. |
388 |
WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in | Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information. |
📁 classes/suggested-tasks/data-collector/class-unpublished-content.php (1 warning)
| 📍 Line | 🔖 Check | 💬 Message |
|---|---|---|
103 |
WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in | Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information. |
📁 classes/suggested-tasks/data-collector/class-yoast-orphaned-content.php (1 warning)
| 📍 Line | 🔖 Check | 💬 Message |
|---|---|---|
111 |
PluginCheck.Security.DirectDB.UnescapedDBParameter | Unescaped parameter $query used in $wpdb->get_row()\n$query assigned unsafely at line 98. |
📁 classes/suggested-tasks/data-collector/class-terms-without-description.php (1 warning)
| 📍 Line | 🔖 Check | 💬 Message |
|---|---|---|
108 |
PluginCheck.Security.DirectDB.UnescapedDBParameter | Unescaped parameter $query used in $wpdb->get_results()\n$query assigned unsafely at line 106. |
📁 classes/suggested-tasks/data-collector/class-terms-without-posts.php (1 warning)
| 📍 Line | 🔖 Check | 💬 Message |
|---|---|---|
120 |
PluginCheck.Security.DirectDB.UnescapedDBParameter | Unescaped parameter $query used in $wpdb->get_results()\n$query assigned unsafely at line 118. |
📁 classes/activities/class-query.php (2 warnings)
| 📍 Line | 🔖 Check | 💬 Message |
|---|---|---|
71 |
PluginCheck.Security.DirectDB.UnescapedDBParameter | Unescaped parameter $table_name used in $wpdb->query()\n$table_name assigned unsafely at line 58. |
163 |
PluginCheck.Security.DirectDB.UnescapedDBParameter | Unescaped parameter $where_args used in $wpdb->get_results()\n$where_args assigned unsafely at line 153. |
🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check
progressplanner.com will watch the wordpress.org releases feed and read each site's installed version from the existing get-stats payload (security_updates.installed_version), instead of sites pushing to a new endpoint. This removes the push endpoint's abuse surface entirely. A regression test now asserts the alert path makes no outbound HTTP requests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On multisite only super admins can update core, so the expected recipient list must be built from get_super_admins() there — matching what Security_Update_Monitor::get_recipients() correctly does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The version-check API only labels the newest release "upgrade"; a branch-behind site (e.g. 6.9.1 when 7.0.x is current) receives its same-branch security patch (6.9.7) with response "autoupdate". The response filter only accepted "upgrade", so exactly the most at-risk sites — those on older branches — never got the task or the alert. Accept both "upgrade" and "autoupdate" offers; cross-branch entries are still rejected by the same-major.minor rule, dev builds by the stability check. Verified against the live API shape for 6.9.1 (upgrade 7.0.4 / autoupdate 7.0.4 / autoupdate 6.9.7 -> detects 6.9.7). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The wp-admin Updates page only lists the latest release — get_core_updates() skips "autoupdate" offers — so a branch-behind site (6.9.1 with 7.0.x current) is only ever offered the next major there. The task actions now include a form that POSTs the exact branch version and locale to core's own update-core.php?action=do-core-upgrade handler: find_core_update() matches any offer in the transient (including autoupdate ones), so the user gets core's native upgrade flow pinned to e.g. 6.9.7, with the nonce, capability check, maintenance mode and filesystem-credentials handling all handled by core. The "Go to the Updates page" link remains as fallback. Adds Security_Update_Monitor::get_pending_security_update_offer() to expose the matched offer object (version + locale). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Light-touch advice phrased not to license delay: "make a fresh backup first — but do not postpone the update if you have none." The one-click button routes around wp-admin's Updates page, which is where WordPress normally shows its backup notice, so the task and email carry it instead. The email links to the wordpress.org backups documentation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dashboard carries the one-click branch-pinned update button; the wp-admin Updates page only offers the latest major, so it remains the target only for sites that have not onboarded yet (their dashboard would show the welcome screen instead of the task). Also restructures the email body into separate translatable paragraphs and rewords the auto-update note so it fits both link targets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Solid, well-researched PR. The core insight — that I verified the detection claim against the live version-check API. A 6.9.1 site, even with So accepting both Two things I'd consider blockers. 🔴 1.
|
…elity Blockers: - on_automatic_updates_complete() used DB::get(), which drops provider_id (only get_tasks_by() builds the tax query), so the security-update entry matched the first published task of any provider, trashed it and credited points. Switched to get_tasks_by() and gated the security task on $update_results['core'] — the hook also fires after plugin/theme-only runs. Tests now seed a second provider's task and a plugin-only run. - DB::get() results were only invalidated in delete_recommendation(); trashing via update_recommendation() left a cached "active" lockdown (and the asymmetric `if ( $results )` never caches the inactive state). add() and update_recommendation() now flush the group, and the wp_cache_flush() workarounds in tests are gone. Test fidelity: - Replaced the dead version_checked branch (wp_version_check() keeps version_checked equal to the running version) with a single get_installed_version() behind a progress_planner_installed_wp_version filter, so completion/celebration tests exercise the production comparison with only the version source swapped. Added an explicit completion test. Minor: - get_recipients() fetches 'fields' => 'user_email' only. - Alert lock uses network options like the throttle; a stale lock is cleared and the request bails instead of two requests both sending. - The widget view gates on the provider's capability_required() instead of a literal capability. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Addressed in 76a6528 — responding to #769 (comment) item by item. 🔴 1. 🔴 2. Stuck lockdown / cache — Confirmed, including the 🟡 🟡 Minor — Priority — keeping Full suite green in single-site and multisite modes (445 tests), PHPStan level 10, WPCS. |
|
Verified all of it against 🔴 1 — 🔴 2 — Fixing invalidation in 🟡 🟡 Minor — all fine. Clearing and bailing on a stale lock is better than taking it over. Gating the widget view on Priority LGTM. One note for the future, not a merge concern:
Review assisted by Claude Code. |
What
When a WordPress core security release is available, put site owners on highest alert:
security-updatetask (priority 0, 2 points, not dismissable, not snoozable) becomes the only recommendation shown to users who can install it — including under "Show all recommendations" — until the update is installed.update-core.php?action=do-core-upgradehandler. This matters because the wp-admin Updates page never shows same-branch patches (get_core_updates()skipsautoupdateoffers) and would push a branch-behind user toward the next major instead.find_core_update()accepts any offer in the transient, so core's native upgrade flow (nonce, capability, maintenance mode, FS credentials) runs pinned to the branch patch. A "Go to the Updates page" link remains as fallback.update_coreusers) receive a directwp_mailalert, once per offered version. No email addresses leave the site, and the alert path makes zero outbound HTTP requests (covered by a regression test).get-statspayload gains asecurity_updatesblock (pending,installed_version,offered_version,last_alerted_version) so progressplanner.com can email the registered subscriber via the feed-driven approach (see below).The alert email
Sent as plain text, one individually addressed
wp_mailper recipient, translatable via theprogress-plannertextdomain. For an onboarded site (example: 6.9.1 offered 6.9.7):Sites that have not onboarded get the same email without the dashboard paragraph, with the link pointing to
wp-admin/update-core.phpinstead (their PP dashboard would show the welcome screen, not the task).Detection
Every patch release on the installed branch (e.g. 6.8.1 → 6.8.2) is treated as a security release. Cross-branch jumps (6.7.2 → 6.8.0) and alpha/beta/RC builds never trigger. Branch-behind sites are covered because WordPress serves a same-branch point-release offer whenever security fixes ship — note that once a newer major exists, that offer arrives with response
autoupdate(only the newest release is labeledupgrade), so detection accepts both response types. Verified against the live version-check API for a 6.9.1 site (upgrade 7.0.4/autoupdate 7.0.4/autoupdate 6.9.7→ detects 6.9.7).Detection hooks
set_site_transient_update_core, so it fires from front-end cron — no admin visit needed — with anadmin_initfallback.Lifecycle
security-update-6-8-2): each release is a fresh task with fresh points, even after an older one was completed.automatic_updates_complete.Deliberately not locked down
update_core(they can't act on it) keep their normal task list.userprovider)./progress-planner/v1/tasksendpoint.Also in this PR
rest_prepare_recommendation()hardened against an undefinedprpl_urlmeta index (exposed by the new tests when meta isn't registered).on_automatic_updates_complete()generalized to handle bothupdate-coreandsecurity-updatetasks.SaaS-side work (separate, on progressplanner.com): feed-driven subscriber email
No new endpoint. Instead, progressplanner.com watches the wordpress.org releases feed; when a patch release ships for branch X.Y, it polls connected sites' existing
get-statsendpoint and emails the subscriber of any site whosesecurity_updates.installed_versionis on branch X.Y below the new patch. Notes:installed_version+ the feed, not the site'spendingflag — a freshly polled site's own update check may not have run yet.installed_versionreads the running version and is always accurate.An earlier revision of this PR pushed a
security-update-alertping to the SaaS; it was removed (see commit history) because a push-triggers-email endpoint is an abuse surface the feed-driven design avoids entirely.Testing
pre_site_transient_update_coreand assert the single-card lockdown) and manual verification on a live site.🤖 Generated with Claude Code