Skip to content

Add critical security-update task with UI lockdown and admin alerts - #769

Open
tacoverdo wants to merge 7 commits into
developfrom
taco/security-update-task
Open

Add critical security-update task with UI lockdown and admin alerts#769
tacoverdo wants to merge 7 commits into
developfrom
taco/security-update-task

Conversation

@tacoverdo

@tacoverdo tacoverdo commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

What

When a WordPress core security release is available, put site owners on highest alert:

  • A new security-update task (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.
  • The task carries a one-click, branch-pinned update button ("Update to WordPress 6.9.7 now"): a form POSTing the exact offered version + locale to core's own update-core.php?action=do-core-upgrade handler. This matters because the wp-admin Updates page never shows same-branch patches (get_core_updates() skips autoupdate offers) 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.
  • All administrators (update_core users) receive a direct wp_mail alert, once per offered version. No email addresses leave the site, and the alert path makes zero outbound HTTP requests (covered by a regression test).
  • The get-stats payload gains a security_updates block (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_mail per recipient, translatable via the progress-planner textdomain. For an onboarded site (example: 6.9.1 offered 6.9.7):

Subject: [Example Site] Critical: WordPress 6.9.7 security update available

A WordPress security release is available: version 6.9.7 (your site runs 6.9.1).

Security issues in WordPress are typically exploited within hours of a release, so please update as soon as possible:

https://example.org/wp-admin/admin.php?page=progress-planner

Your Progress Planner dashboard has a one-click button to install exactly this update; the task will be marked complete once your site is updated.

If you have a backup solution, make a fresh backup before updating — but do not postpone the update if you have none. Learn more about backups: https://wordpress.org/documentation/article/wordpress-backups/

If your site has automatic updates enabled, it may install this update by itself — in that case, please verify that the update has been applied.

This alert was sent by the Progress Planner plugin.

Sites that have not onboarded get the same email without the dashboard paragraph, with the link pointing to wp-admin/update-core.php instead (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 labeled upgrade), 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 an admin_init fallback.

Lifecycle

  • Task IDs are versioned (security-update-6-8-2): each release is a fresh task with fresh points, even after an older one was completed.
  • Superseded or withdrawn offers are cleaned up silently (no bogus celebration).
  • Installing the update manually completes the task with the normal celebration; auto-updates complete it silently via automatic_updates_complete.

Deliberately not locked down

  • Users without update_core (they can't act on it) keep their normal task list.
  • The user's own to-do list (user provider).
  • Pending-celebration and other non-publish statuses, so celebrations still fire during lockdown.
  • The SaaS-facing /progress-planner/v1/tasks endpoint.

Also in this PR

  • rest_prepare_recommendation() hardened against an undefined prpl_url meta index (exposed by the new tests when meta isn't registered).
  • on_automatic_updates_complete() generalized to handle both update-core and security-update tasks.

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-stats endpoint and emails the subscriber of any site whose security_updates.installed_version is on branch X.Y below the new patch. Notes:

  • Decide from installed_version + the feed, not the site's pending flag — a freshly polled site's own update check may not have run yet. installed_version reads the running version and is always accurate.
  • Dedupe per (site, offered version) server-side.
  • Queue/spread the release-day polling burst.

An earlier revision of this PR pushed a security-update-alert ping 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

  • 33 new PHPUnit tests (detection matrix, alert throttling/recipients/no-outbound-HTTP, provider lifecycle incl. superseded/withdrawn offers, lockdown on both REST chokepoints, System_Status). Full suite: 432 tests / 1269 assertions green.
  • PHPStan level 10, WPCS, and PHP lint all clean.
  • Not yet covered: a Playwright spec (would fake the offer via pre_site_transient_update_core and assert the single-card lockdown) and manual verification on a live site.

🤖 Generated with Claude Code

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>
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Test on Playground
Test this pull request on the Playground
or download the zip

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

✅ Code Coverage Report

Metric Value
Total Coverage 35.45% 📉
Base Coverage 31.62%
Difference 📈 3.83%

⚠️ Coverage below recommended 40% threshold

🎉 Great job maintaining/improving code coverage!

📊 File-level Coverage Changes (13 files)

🆕 New Files

Class Coverage Lines
🟢 Progress_Planner\Suggested_Tasks\Providers\Security_Update 91.36% 74/81
🟢 Progress_Planner\Utils\Security_Update_Monitor 89.43% 110/123

📈 Coverage Improved

Class Before After Change
Progress_Planner\Activities\Suggested_Task 50.00% 88.89% +38.89%
Progress_Planner\Suggested_Tasks\Task 20.00% 56.67% +36.67%
Progress_Planner\Suggested_Tasks 9.60% 43.62% +34.02%
Progress_Planner\Suggested_Tasks\Providers\Tasks 36.59% 67.07% +30.48%
Progress_Planner\Badges 67.21% 95.08% +27.87%
Progress_Planner\Suggested_Tasks\Providers\Blog_Description 33.33% 54.17% +20.84%
Progress_Planner\Suggested_Tasks\Providers\User 4.55% 18.18% +13.63%
Progress_Planner\Suggested_Tasks\Tasks_Manager 62.83% 70.18% +7.35%
Progress_Planner\Badges\Monthly 72.17% 73.04% +0.87%
Progress_Planner\Utils\System_Status 91.95% 92.71% +0.76%
Progress_Planner\Base 45.40% 45.73% +0.33%
ℹ️ About this report
  • All tests run in a single job with Xdebug coverage
  • Security tests excluded from coverage to prevent output issues
  • Coverage calculated from line coverage percentages

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

🔍 WordPress Plugin Check Report

❌ Status: Failed

📊 Report

🎯 Total Issues ❌ Errors ⚠️ Warnings
12 1 11

❌ Errors (1)

📁 readme.txt (1 error)
📍 Line 🔖 Check 💬 Message
0 outdated_tested_upto_header Tested up to: 6.9 < 7.0. The "Tested up to" value in your plugin is not set to the current version of WordPress. This means your plugin will not show up in searches, as we require plugins to be compatible and documented as tested up to the most recent version of WordPress.

⚠️ Warnings (11)

📁 classes/utils/class-security-update-monitor.php (1 warning)
📍 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://wpvip.com/documentation/performance-improvements-by-removing-usage-of-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://wpvip.com/documentation/performance-improvements-by-removing-usage-of-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://wpvip.com/documentation/performance-improvements-by-removing-usage-of-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://wpvip.com/documentation/performance-improvements-by-removing-usage-of-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://wpvip.com/documentation/performance-improvements-by-removing-usage-of-post__not_in/ for more information.
📁 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.
📁 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-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/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.

🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

tacoverdo and others added 2 commits August 14, 2026 11:47
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>
tacoverdo and others added 3 commits August 14, 2026 12:12
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>
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