From a1025301e5de4735892815d1e461865015f1f4cc Mon Sep 17 00:00:00 2001 From: Taco Verdonschot Date: Fri, 14 Aug 2026 10:44:33 +0200 Subject: [PATCH 1/7] Add critical security-update task with UI lockdown and admin alerts 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 --- classes/class-base.php | 4 + classes/class-suggested-tasks.php | 100 ++++- .../suggested-tasks/class-tasks-manager.php | 2 + .../providers/class-security-update.php | 267 +++++++++++++ .../utils/class-security-update-monitor.php | 339 ++++++++++++++++ classes/utils/class-system-status.php | 12 + .../test-class-security-update-monitor.php | 364 ++++++++++++++++++ .../test-class-security-update-provider.php | 310 +++++++++++++++ views/page-widgets/suggested-tasks.php | 24 +- 9 files changed, 1398 insertions(+), 24 deletions(-) create mode 100644 classes/suggested-tasks/providers/class-security-update.php create mode 100644 classes/utils/class-security-update-monitor.php create mode 100644 tests/phpunit/test-class-security-update-monitor.php create mode 100644 tests/phpunit/test-class-security-update-provider.php diff --git a/classes/class-base.php b/classes/class-base.php index 7fa27f1ff3..ecd25514e7 100644 --- a/classes/class-base.php +++ b/classes/class-base.php @@ -20,6 +20,7 @@ * @method \Progress_Planner\Rest\Tasks get_rest__tasks() * @method \Progress_Planner\Todo get_todo() * @method \Progress_Planner\Utils\Onboard get_utils__onboard() + * @method \Progress_Planner\Utils\Security_Update_Monitor get_utils__security_update_monitor() * @method \Progress_Planner\Utils\Playground get_utils__playground() * @method \Progress_Planner\Admin\Page get_admin__page() * @method \Progress_Planner\Admin\Tour get_admin__tour() @@ -120,6 +121,9 @@ public function init() { $this->get_suggested_tasks(); + // Watches for core security releases; must run on front-end/cron requests too. + $this->get_utils__security_update_monitor(); + $this->get_admin__editor(); $this->get_actions__content(); diff --git a/classes/class-suggested-tasks.php b/classes/class-suggested-tasks.php index 751bb4dd8f..4fd79e2422 100644 --- a/classes/class-suggested-tasks.php +++ b/classes/class-suggested-tasks.php @@ -156,23 +156,34 @@ public function delete_activity( string $task_id ): void { * @return void */ public function on_automatic_updates_complete(): void { - $pending_tasks = \progress_planner()->get_suggested_tasks_db()->get( - [ - 'numberposts' => 1, - 'post_status' => 'publish', - 'provider_id' => 'update-core', - 'date_query' => [ [ 'after' => 'this Monday' ] ], - ] - ); + $providers = [ + // The repetitive update-core task only counts within the current week. + 'update-core' => [ 'date_query' => [ [ 'after' => 'this Monday' ] ] ], + // The security-update task persists until the update is installed. + 'security-update' => [], + ]; - if ( empty( $pending_tasks ) ) { - return; - } + foreach ( $providers as $provider_id => $extra_args ) { + $pending_tasks = \progress_planner()->get_suggested_tasks_db()->get( + \array_merge( + [ + 'numberposts' => 1, + 'post_status' => 'publish', + 'provider_id' => $provider_id, + ], + $extra_args + ) + ); + + if ( empty( $pending_tasks ) ) { + continue; + } - \progress_planner()->get_suggested_tasks_db()->update_recommendation( $pending_tasks[0]->ID, [ 'post_status' => 'trash' ] ); + \progress_planner()->get_suggested_tasks_db()->update_recommendation( $pending_tasks[0]->ID, [ 'post_status' => 'trash' ] ); - // Insert an activity. - $this->insert_activity( \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $pending_tasks[0]->post_name ) ); + // Insert an activity. + $this->insert_activity( \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $pending_tasks[0]->post_name ) ); + } } /** @@ -484,6 +495,12 @@ public function rest_api_tax_query( $args, $request ) { $include_providers = \array_intersect( $include_providers, $request_providers ); } + // While a security-update task is pending, it is the only recommendation + // shown to users who can install it. + if ( $this->should_lock_down_recommendations( $request, $include_providers ) ) { + $include_providers = [ 'security-update' ]; + } + $tax_query[] = [ 'taxonomy' => 'prpl_recommendations_provider', 'field' => 'slug', @@ -508,6 +525,38 @@ public function rest_api_tax_query( $args, $request ) { return $args; } + /** + * Check whether a REST recommendations query must be locked down to the security-update task. + * + * The lockdown only applies to publish-status queries from users who can install + * the update. The user's own to-do list (provider "user") and pending-celebration + * queries are never locked down. + * + * @param \WP_REST_Request $request The request object. + * @param array $include_providers The provider IDs available to the current user. + * + * @return bool + */ + private function should_lock_down_recommendations( $request, $include_providers ) { + // Only publish-status queries are locked down (celebrations & snoozed tasks flow normally). + $statuses = isset( $request['status'] ) ? (array) $request['status'] : [ 'publish' ]; + if ( [ 'publish' ] !== \array_values( $statuses ) ) { + return false; + } + + // The user's own to-do list is never locked down. + if ( isset( $request['provider'] ) && [ 'user' ] === \explode( ',', $request['provider'] ) ) { + return false; + } + + // Only users who can install the update are locked down. + if ( ! \in_array( 'security-update', $include_providers, true ) ) { + return false; + } + + return \progress_planner()->get_utils__security_update_monitor()->is_security_lockdown_active(); + } + /** * Sanitize a recommendation before it is inserted or updated via the REST API. * @@ -553,7 +602,7 @@ public function rest_prepare_recommendation( $response, $post ) { if ( $provider ) { $response->data['prpl_provider'] = $provider_term[0]; // Link should be added during run time, since it is not added for users without required capability. - $response->data['meta']['prpl_url'] = $response->data['meta']['prpl_url'] && $provider->capability_required() + $response->data['meta']['prpl_url'] = ! empty( $response->data['meta']['prpl_url'] ) && $provider->capability_required() ? \esc_url( (string) $response->data['meta']['prpl_url'] ) : ''; @@ -600,6 +649,27 @@ public function get_tasks_in_rest_format( array $args = [] ) { ] ); + // While a security-update task is pending, it is the only recommendation + // served to users who can install it. User to-do lists and non-publish + // statuses (e.g. pending celebrations) are not affected. + if ( [ 'publish' ] === \array_values( (array) $args['post_status'] ) + && [ 'user' ] !== $args['include_provider'] + && \in_array( + 'security-update', + \array_map( + static function ( $provider ) { + return $provider->get_provider_id(); + }, + $this->tasks_manager->get_task_providers_available_for_user() + ), + true + ) + && \progress_planner()->get_utils__security_update_monitor()->is_security_lockdown_active() + ) { + $args['include_provider'] = [ 'security-update' ]; + $args['exclude_provider'] = []; + } + // Build query args for get_tasks_by. $query_args = [ 'post_status' => $args['post_status'], diff --git a/classes/suggested-tasks/class-tasks-manager.php b/classes/suggested-tasks/class-tasks-manager.php index 64b2aefb51..86331f8c30 100644 --- a/classes/suggested-tasks/class-tasks-manager.php +++ b/classes/suggested-tasks/class-tasks-manager.php @@ -22,6 +22,7 @@ use Progress_Planner\Suggested_Tasks\Providers\Permalink_Structure; use Progress_Planner\Suggested_Tasks\Providers\Php_Version; use Progress_Planner\Suggested_Tasks\Providers\Search_Engine_Visibility; +use Progress_Planner\Suggested_Tasks\Providers\Security_Update; use Progress_Planner\Suggested_Tasks\Tasks_Interface; use Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast\Add_Yoast_Providers; use Progress_Planner\Suggested_Tasks\Providers\Integrations\AIOSEO\Add_AIOSEO_Providers; @@ -64,6 +65,7 @@ public function __construct() { new Content_Create(), new Content_Review(), new Core_Update(), + new Security_Update(), new Blog_Description(), new Debug_Display(), new Disable_Comments(), diff --git a/classes/suggested-tasks/providers/class-security-update.php b/classes/suggested-tasks/providers/class-security-update.php new file mode 100644 index 0000000000..6077ef5833 --- /dev/null +++ b/classes/suggested-tasks/providers/class-security-update.php @@ -0,0 +1,267 @@ +get_version_from_task_id( $task_id ); + + return '' === $version + ? ! $this->should_add_task() + : \version_compare( Security_Update_Monitor::get_effective_installed_version(), $version, '>=' ); + } + + /** + * Check if the published task is still relevant. + * + * A task stays relevant while its version is still offered, or once it has been + * installed (so evaluation can celebrate it). A withdrawn offer makes the task + * irrelevant and it is deleted without celebration. + * + * @return bool + */ + public function is_task_relevant() { + $tasks = (array) \progress_planner()->get_suggested_tasks_db()->get_tasks_by( + [ + 'post_status' => 'publish', + 'provider_id' => static::PROVIDER_ID, + ] + ); + + if ( empty( $tasks ) ) { + return true; + } + + $version = $this->get_version_from_task_id( (string) $tasks[0]->post_name ); + if ( '' === $version ) { + return false; + } + + // Already installed: keep the task so evaluation can celebrate it. + if ( \version_compare( Security_Update_Monitor::get_effective_installed_version(), $version, '>=' ) ) { + return true; + } + + // Otherwise the task is only relevant while its version is still offered. + $pending = Security_Update_Monitor::get_pending_security_update(); + + return null !== $pending && $pending['offered'] === $version; + } + + /** + * Get an array of tasks to inject. + * + * Removes published tasks for superseded or withdrawn releases before + * injecting the task for the currently offered release. + * + * @return array + */ + public function get_tasks_to_inject() { + $current_task_id = $this->should_add_task() ? $this->get_task_id() : ''; + + foreach ( (array) \progress_planner()->get_suggested_tasks_db()->get_tasks_by( + [ + 'post_status' => 'publish', + 'provider_id' => static::PROVIDER_ID, + ] + ) as $task ) { + $task_id = \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( (string) $task->post_name ); + $version = $this->get_version_from_task_id( $task_id ); + + // Keep the task for the current offer, and completed-but-not-yet-celebrated tasks. + if ( $task_id === $current_task_id + || ( '' !== $version && \version_compare( Security_Update_Monitor::get_effective_installed_version(), $version, '>=' ) ) + ) { + continue; + } + + \progress_planner()->get_suggested_tasks_db()->delete_recommendation( $task->ID ); + } + + return parent::get_tasks_to_inject(); + } + + /** + * Add the offered and installed versions to the injected task data. + * + * @param array $task_data The task data. + * + * @return array + */ + protected function modify_injection_task_data( $task_data ) { + $pending = Security_Update_Monitor::get_pending_security_update(); + + if ( null !== $pending ) { + $task_data['offered_version'] = $pending['offered']; + $task_data['installed_version'] = $pending['installed']; + } + + return $task_data; + } + + /** + * Add task actions specific to this task. + * + * @param array $data The task data. + * @param array $actions The existing actions. + * + * @return array + */ + public function add_task_actions( $data = [], $actions = [] ) { + $actions[] = [ + 'priority' => 10, + 'html' => '' . \esc_html__( 'Go to the Updates page', 'progress-planner' ) . '', + ]; + + return $actions; + } + + /** + * Extract the version from a versioned task ID. + * + * @param string $task_id The task ID (e.g. security-update-6-8-2). + * + * @return string The version (e.g. 6.8.2), or an empty string. + */ + private function get_version_from_task_id( $task_id ) { + if ( ! \preg_match( '/^' . static::PROVIDER_ID . '-(\d+(?:-\d+)*)$/', $task_id, $matches ) ) { + return ''; + } + + return \str_replace( '-', '.', $matches[1] ); + } +} diff --git a/classes/utils/class-security-update-monitor.php b/classes/utils/class-security-update-monitor.php new file mode 100644 index 0000000000..4e95dc4424 --- /dev/null +++ b/classes/utils/class-security-update-monitor.php @@ -0,0 +1,339 @@ +maybe_alert( \get_site_transient( 'update_core' ) ); + } + + /** + * Alert site admins (and the SaaS) about a pending security update, once per offered version. + * + * @param mixed $transient The update_core site transient value. + * + * @return void + */ + public function maybe_alert( $transient ) { + if ( \is_multisite() && ! \is_main_site() ) { + return; + } + + $update = self::get_pending_security_update( \is_object( $transient ) ? $transient : null ); + if ( null === $update ) { + return; + } + + // One alert per offered version. + if ( \get_site_option( self::ALERTED_VERSION_OPTION ) === $update['offered'] ) { + return; + } + + // Cron and a concurrent admin pageview can both fire this; take a short-lived lock. + if ( ! \add_option( self::LOCK_OPTION, \time(), '', false ) ) { + $lock_time = (int) \get_option( self::LOCK_OPTION ); + if ( $lock_time > \time() - 30 ) { + return; + } + \update_option( self::LOCK_OPTION, \time(), false ); + } + + try { + $this->send_admin_email( $update['installed'], $update['offered'] ); + $this->send_saas_ping( $update['installed'], $update['offered'] ); + + // Mark as alerted even if sending failed, to avoid alert storms on every request. + \update_site_option( self::ALERTED_VERSION_OPTION, $update['offered'] ); + } finally { + \delete_option( self::LOCK_OPTION ); + } + } + + /** + * Email every user who can install core updates about the security release. + * + * @param string $installed The installed version. + * @param string $offered The offered version. + * + * @return void + */ + private function send_admin_email( $installed, $offered ) { + $subject = \sprintf( + /* translators: 1: The site name, 2: The offered WordPress version. */ + \__( '[%1$s] Critical: WordPress %2$s security update available', 'progress-planner' ), + \wp_specialchars_decode( (string) \get_option( 'blogname' ), ENT_QUOTES ), + $offered + ); + + $message = \sprintf( + /* translators: 1: The offered WordPress version, 2: The installed WordPress version, 3: The URL of the updates page. */ + \__( + 'A WordPress security release is available: version %1$s (your site runs %2$s). + +Security issues in WordPress are typically exploited within hours of a release, so please update as soon as possible: + +%3$s + +If your site has automatic updates enabled, it may install this update by itself — in that case, please verify on the page above that the update has been applied. + +This alert was sent by the Progress Planner plugin.', + 'progress-planner' + ), + $offered, + $installed, + \admin_url( 'update-core.php' ) + ); + + foreach ( $this->get_recipients() as $email ) { + \wp_mail( $email, $subject, $message ); + } + } + + /** + * Get the email addresses of all users who can install core updates. + * + * @return string[] + */ + private function get_recipients() { + if ( \is_multisite() ) { + $emails = []; + foreach ( \get_super_admins() as $login ) { + $user = \get_user_by( 'login', $login ); + if ( $user ) { + $emails[] = $user->user_email; + } + } + + return \array_values( \array_unique( $emails ) ); + } + + $emails = []; + foreach ( \get_users( [ 'capability' => 'update_core' ] ) as $user ) { + if ( $user instanceof \WP_User ) { + $emails[] = $user->user_email; + } + } + + return \array_values( \array_unique( $emails ) ); + } + + /** + * Notify the Progress Planner SaaS so it can email the registered subscriber. + * + * @param string $installed The installed version. + * @param string $offered The offered version. + * + * @return void + */ + private function send_saas_ping( $installed, $offered ) { + $license_key = \get_option( 'progress_planner_license_key' ); + if ( ! $license_key || 'no-license' === $license_key ) { + return; + } + + $onboard = \progress_planner()->get_utils__onboard(); + + // Fire-and-forget: the SaaS emails the registered subscriber. + \wp_remote_post( + $onboard->get_remote_url( 'security-update-alert' ), + [ + 'timeout' => 10, + 'body' => [ + 'license_key' => $license_key, + 'site' => \set_url_scheme( \site_url() ), + 'installed_version' => $installed, + 'offered_version' => $offered, + 'nonce' => $onboard->get_remote_nonce(), + ], + ] + ); + } + + /** + * Check whether a security-update task is currently published. + * + * While one is published, the recommendations UI is locked down to that task. + * + * @return bool + */ + public function is_security_lockdown_active() { + return ! empty( + \progress_planner()->get_suggested_tasks_db()->get_tasks_by( + [ + 'post_status' => 'publish', + 'provider_id' => 'security-update', + 'numberposts' => 1, + ] + ) + ); + } + + /** + * Check whether an offered version is a security release, compared to the installed one. + * + * A security release is a patch release on the same branch: same major.minor, + * with a higher patch number. Non-stable versions (alpha/beta/RC) never qualify. + * + * @param string $installed The installed WordPress version. + * @param string $offered The offered WordPress version. + * + * @return bool + */ + public static function is_security_release( $installed, $offered ) { + // Non-stable versions (e.g. 6.9-alpha-59000, 6.9-RC1) never qualify. + if ( false !== \strpos( $installed, '-' ) || false !== \strpos( $offered, '-' ) ) { + return false; + } + + $installed_parts = self::get_version_parts( $installed ); + $offered_parts = self::get_version_parts( $offered ); + + if ( null === $installed_parts || null === $offered_parts ) { + return false; + } + + return $installed_parts[0] === $offered_parts[0] + && $installed_parts[1] === $offered_parts[1] + && $offered_parts[2] > $installed_parts[2]; + } + + /** + * Get the pending security update from an update_core transient, if any. + * + * @param object|null $transient The update_core site transient. Defaults to the stored transient. + * @param string|null $installed The installed version. Defaults to the running WordPress version. + * + * @return array{installed: string, offered: string}|null + */ + public static function get_pending_security_update( $transient = null, $installed = null ) { + if ( null === $transient ) { + $transient = \get_site_transient( 'update_core' ); + } + + if ( null === $installed ) { + $installed = self::get_installed_version(); + } + + if ( ! \is_object( $transient ) || ! isset( $transient->updates ) || ! \is_array( $transient->updates ) ) { + return null; + } + + $offered = null; + foreach ( $transient->updates as $update ) { + if ( ! \is_object( $update ) + || ! isset( $update->response, $update->current ) + || 'upgrade' !== $update->response + || ! \is_string( $update->current ) + || ! self::is_security_release( $installed, $update->current ) + ) { + continue; + } + + if ( null === $offered || \version_compare( $update->current, $offered, '>' ) ) { + $offered = $update->current; + } + } + + return null === $offered + ? null + : [ + 'installed' => $installed, + 'offered' => $offered, + ]; + } + + /** + * Get the effective installed WordPress version. + * + * Returns the higher of the running version and the version recorded by the + * last update check (the transient's version_checked), so a just-installed + * update is recognized as soon as the update check has run. + * + * @return string + */ + public static function get_effective_installed_version() { + $installed = self::get_installed_version(); + $transient = \get_site_transient( 'update_core' ); + + if ( \is_object( $transient ) + && isset( $transient->version_checked ) + && \is_string( $transient->version_checked ) + && \version_compare( $transient->version_checked, $installed, '>' ) + ) { + return $transient->version_checked; + } + + return $installed; + } + + /** + * Get the installed WordPress version. + * + * @return string + */ + private static function get_installed_version() { + // wp_get_wp_version() exists since WP 6.7; the plugin supports 6.6. + if ( \function_exists( 'wp_get_wp_version' ) ) { + return \wp_get_wp_version(); + } + + return isset( $GLOBALS['wp_version'] ) && \is_string( $GLOBALS['wp_version'] ) ? $GLOBALS['wp_version'] : ''; + } + + /** + * Split a version string into major, minor and patch integers. + * + * @param string $version The version string. + * + * @return array{int, int, int}|null Null when the version is not parsable. + */ + private static function get_version_parts( $version ) { + if ( ! \preg_match( '/^(\d+)\.(\d+)(?:\.(\d+))?$/', $version, $matches ) ) { + return null; + } + + return [ (int) $matches[1], (int) $matches[2], isset( $matches[3] ) ? (int) $matches[3] : 0 ]; + } +} diff --git a/classes/utils/class-system-status.php b/classes/utils/class-system-status.php index a7d7192963..ddf7740273 100644 --- a/classes/utils/class-system-status.php +++ b/classes/utils/class-system-status.php @@ -9,6 +9,7 @@ use Progress_Planner\Base; use Progress_Planner\Admin\Widgets\Activity_Scores; +use Progress_Planner\Utils\Security_Update_Monitor; /** * System_Status class. @@ -26,6 +27,17 @@ public function get_system_status() { // Get the number of pending updates. $data['pending_updates'] = \wp_get_update_data()['counts']['total']; + // Pending WordPress core security update, if any. + $security_update = Security_Update_Monitor::get_pending_security_update(); + $data['security_updates'] = [ + 'pending' => null !== $security_update, + 'installed_version' => null !== $security_update + ? $security_update['installed'] + : Security_Update_Monitor::get_effective_installed_version(), + 'offered_version' => null !== $security_update ? $security_update['offered'] : null, + 'last_alerted_version' => (string) \get_site_option( Security_Update_Monitor::ALERTED_VERSION_OPTION, '' ), + ]; + // Get number of content from any public post-type, published in the past week. $data['weekly_posts'] = \count( \get_posts( diff --git a/tests/phpunit/test-class-security-update-monitor.php b/tests/phpunit/test-class-security-update-monitor.php new file mode 100644 index 0000000000..7dae9a5734 --- /dev/null +++ b/tests/phpunit/test-class-security-update-monitor.php @@ -0,0 +1,364 @@ + + */ + public function data_is_security_release() { + return [ + 'patch release' => [ '6.8.1', '6.8.2', true ], + 'first patch on a new branch' => [ '6.8', '6.8.1', true ], + 'patch jump of several versions' => [ '6.8.1', '6.8.4', true ], + 'new major.minor' => [ '6.7.2', '6.8.0', false ], + 'cross-branch jump' => [ '6.7.2', '6.8.2', false ], + 'same version' => [ '6.8.2', '6.8.2', false ], + 'downgrade' => [ '6.8.2', '6.8.1', false ], + 'installed alpha build' => [ '6.9-alpha-59000', '6.9.1', false ], + 'offered release candidate' => [ '6.8.1', '6.9-RC1', false ], + 'malformed offered version' => [ '6.8.1', 'not-a-version', false ], + 'empty installed version' => [ '', '6.8.2', false ], + ]; + } + + /** + * Test the is_security_release version heuristic. + * + * @dataProvider data_is_security_release + * + * @param string $installed The installed version. + * @param string $offered The offered version. + * @param bool $expected The expected result. + */ + public function test_is_security_release( $installed, $offered, $expected ) { + $this->assertSame( $expected, Security_Update_Monitor::is_security_release( $installed, $offered ) ); + } + + /** + * Test that get_pending_security_update picks the same-branch patch from a multi-offer transient. + */ + public function test_get_pending_security_update_picks_same_branch_patch() { + $result = Security_Update_Monitor::get_pending_security_update( + $this->get_mock_transient( [ '6.9.0', '6.8.2' ] ), + '6.8.1' + ); + + $this->assertSame( + [ + 'installed' => '6.8.1', + 'offered' => '6.8.2', + ], + $result + ); + } + + /** + * Test that get_pending_security_update returns the highest matching patch. + */ + public function test_get_pending_security_update_returns_highest_patch() { + $result = Security_Update_Monitor::get_pending_security_update( + $this->get_mock_transient( [ '6.8.2', '6.8.3' ] ), + '6.8.1' + ); + + $this->assertNotNull( $result ); + $this->assertSame( '6.8.3', $result['offered'] ); + } + + /** + * Test that a feature release does not register as a security update. + */ + public function test_get_pending_security_update_ignores_feature_release() { + $this->assertNull( + Security_Update_Monitor::get_pending_security_update( + $this->get_mock_transient( [ '6.9.0' ] ), + '6.8.2' + ) + ); + } + + /** + * Test that non-upgrade offers are ignored. + */ + public function test_get_pending_security_update_ignores_non_upgrade_offers() { + $transient = $this->get_mock_transient( [ '6.8.2' ] ); + + $transient->updates[0]->response = 'latest'; + + $this->assertNull( Security_Update_Monitor::get_pending_security_update( $transient, '6.8.1' ) ); + } + + /** + * Test that malformed transients are handled gracefully. + */ + public function test_get_pending_security_update_handles_malformed_transient() { + $this->assertNull( Security_Update_Monitor::get_pending_security_update( null, '6.8.1' ) ); + $this->assertNull( Security_Update_Monitor::get_pending_security_update( new \stdClass(), '6.8.1' ) ); + $this->assertNull( Security_Update_Monitor::get_pending_security_update( (object) [ 'updates' => 'nope' ], '6.8.1' ) ); + } + + /** + * Test that maybe_alert emails all update_core users exactly once per offered version. + */ + public function test_maybe_alert_sends_email_to_admins_once_per_version() { + $admin_id = self::factory()->user->create( [ 'role' => 'administrator' ] ); + $editor_id = self::factory()->user->create( [ 'role' => 'editor' ] ); + $mails = $this->capture_mails(); + + $monitor = new Security_Update_Monitor(); + $monitor->maybe_alert( $this->get_security_transient() ); + + $expected_recipients = []; + foreach ( \get_users( [ 'capability' => 'update_core' ] ) as $user ) { + $expected_recipients[] = $user->user_email; + } + \sort( $expected_recipients ); + + $actual_recipients = \array_column( $mails->calls, 'to' ); + \sort( $actual_recipients ); + + $this->assertNotEmpty( $expected_recipients ); + $this->assertSame( $expected_recipients, $actual_recipients ); + $this->assertNotContains( \get_user_by( 'id', $editor_id )->user_email, $actual_recipients ); + $this->assertContains( \get_user_by( 'id', $admin_id )->user_email, $actual_recipients ); + + // Second call with the same offered version must not send again. + $mails->calls = []; + $monitor->maybe_alert( $this->get_security_transient() ); + $this->assertSame( [], $mails->calls ); + + // A newer offered version must alert again. + $monitor->maybe_alert( $this->get_security_transient( 2 ) ); + $this->assertNotEmpty( $mails->calls ); + } + + /** + * Test that the alert email names the offered version and links to the updates page. + */ + public function test_alert_email_contains_version_and_update_link() { + $mails = $this->capture_mails(); + + ( new Security_Update_Monitor() )->maybe_alert( $this->get_security_transient() ); + + $this->assertNotEmpty( $mails->calls ); + $mail = $mails->calls[0]; + $this->assertStringContainsString( $this->get_offered_version(), $mail['subject'] . $mail['message'] ); + $this->assertStringContainsString( \admin_url( 'update-core.php' ), $mail['message'] ); + } + + /** + * Test that maybe_alert pings the SaaS when a license key is present. + */ + public function test_maybe_alert_pings_saas_when_license_key_present() { + \update_option( 'progress_planner_license_key', 'test-license-key' ); + $this->capture_mails(); + $requests = $this->capture_http_requests(); + + ( new Security_Update_Monitor() )->maybe_alert( $this->get_security_transient() ); + + $alert_requests = \array_values( + \array_filter( + $requests->calls, + static function ( $request ) { + return false !== \strpos( $request['url'], 'security-update-alert' ); + } + ) + ); + + $this->assertCount( 1, $alert_requests ); + $this->assertSame( 'test-license-key', $alert_requests[0]['body']['license_key'] ); + $this->assertSame( $this->get_offered_version(), $alert_requests[0]['body']['offered_version'] ); + $this->assertArrayHasKey( 'installed_version', $alert_requests[0]['body'] ); + $this->assertArrayHasKey( 'site', $alert_requests[0]['body'] ); + } + + /** + * Test that the SaaS ping is skipped without a license key. + */ + public function test_maybe_alert_skips_ping_without_license_key() { + \delete_option( 'progress_planner_license_key' ); + $this->capture_mails(); + $requests = $this->capture_http_requests(); + + ( new Security_Update_Monitor() )->maybe_alert( $this->get_security_transient() ); + + $this->assertSame( [], $requests->calls ); + } + + /** + * Test that maybe_alert ignores feature releases. + */ + public function test_maybe_alert_ignores_feature_releases() { + $mails = $this->capture_mails(); + + $parts = \explode( '.', \wp_get_wp_version() ); + $transient = $this->get_mock_transient( [ $parts[0] . '.' . ( (int) $parts[1] + 1 ) . '.0' ] ); + + ( new Security_Update_Monitor() )->maybe_alert( $transient ); + + $this->assertSame( [], $mails->calls ); + $this->assertFalse( \get_site_option( 'progress_planner_security_update_alerted_version' ) ); + } + + /** + * Test that the plugin bootstrap registers the monitor's detection hooks. + */ + public function test_monitor_hooks_are_registered_on_boot() { + $monitor = \progress_planner()->get_utils__security_update_monitor(); + + $this->assertInstanceOf( Security_Update_Monitor::class, $monitor ); + $this->assertNotFalse( \has_action( 'set_site_transient_update_core', [ $monitor, 'maybe_alert' ] ) ); + $this->assertNotFalse( \has_action( 'admin_init', [ $monitor, 'maybe_alert_from_stored_transient' ] ) ); + } + + /** + * Test that the system status exposes the pending security update to the SaaS. + */ + public function test_system_status_exposes_security_updates() { + \set_site_transient( 'update_core', $this->get_security_transient() ); + \update_site_option( 'progress_planner_security_update_alerted_version', $this->get_offered_version() ); + + $status = ( new \Progress_Planner\Utils\System_Status() )->get_system_status(); + + $this->assertArrayHasKey( 'security_updates', $status ); + $this->assertTrue( $status['security_updates']['pending'] ); + $this->assertSame( $this->get_offered_version(), $status['security_updates']['offered_version'] ); + $this->assertSame( $this->get_offered_version(), $status['security_updates']['last_alerted_version'] ); + $this->assertNotSame( '', $status['security_updates']['installed_version'] ); + } + + /** + * Test the system status without a pending security update. + */ + public function test_system_status_without_security_update() { + \delete_site_transient( 'update_core' ); + + $status = ( new \Progress_Planner\Utils\System_Status() )->get_system_status(); + + $this->assertFalse( $status['security_updates']['pending'] ); + $this->assertNull( $status['security_updates']['offered_version'] ); + } + + /** + * Capture outgoing mails via the pre_wp_mail short-circuit. + * + * @return object The recorder; captured calls in ->calls. + */ + private function capture_mails() { + $recorder = new \stdClass(); + $recorder->calls = []; + \add_filter( + 'pre_wp_mail', + static function ( $pre, $atts ) use ( $recorder ) { + $recorder->calls[] = $atts; + return true; + }, + 10, + 2 + ); + + return $recorder; + } + + /** + * Capture outgoing HTTP requests via the pre_http_request short-circuit. + * + * @return object The recorder; captured calls in ->calls. + */ + private function capture_http_requests() { + $recorder = new \stdClass(); + $recorder->calls = []; + \add_filter( + 'pre_http_request', + static function ( $pre, $args, $url ) use ( $recorder ) { + if ( false !== \strpos( $url, 'get-nonce' ) ) { + return [ + 'headers' => [], + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'body' => (string) \wp_json_encode( [ 'nonce' => 'remote-nonce' ] ), + 'cookies' => [], + ]; + } + + $recorder->calls[] = [ + 'url' => $url, + 'body' => isset( $args['body'] ) ? $args['body'] : [], + ]; + + return [ + 'headers' => [], + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'body' => (string) \wp_json_encode( [ 'status' => 'ok' ] ), + 'cookies' => [], + ]; + }, + 10, + 3 + ); + + return $recorder; + } + + /** + * Get an offered patch version relative to the running WordPress version. + * + * @param int $bump How many patch versions to bump. + * + * @return string + */ + private function get_offered_version( $bump = 1 ) { + $parts = \explode( '.', \wp_get_wp_version() ); + + return $parts[0] . '.' . $parts[1] . '.' . ( isset( $parts[2] ) ? (int) $parts[2] + $bump : $bump ); + } + + /** + * Build a transient offering a security release for the running WordPress version. + * + * @param int $bump How many patch versions to bump. + * + * @return \stdClass + */ + private function get_security_transient( $bump = 1 ) { + return $this->get_mock_transient( [ $this->get_offered_version( $bump ) ] ); + } + + /** + * Build a mock update_core transient object. + * + * @param string[] $offered_versions The offered versions. + * + * @return \stdClass + */ + private function get_mock_transient( $offered_versions ) { + $updates = []; + foreach ( $offered_versions as $version ) { + $updates[] = (object) [ + 'response' => 'upgrade', + 'current' => $version, + ]; + } + + return (object) [ 'updates' => $updates ]; + } +} diff --git a/tests/phpunit/test-class-security-update-provider.php b/tests/phpunit/test-class-security-update-provider.php new file mode 100644 index 0000000000..e7e8494612 --- /dev/null +++ b/tests/phpunit/test-class-security-update-provider.php @@ -0,0 +1,310 @@ +set_security_offer(); + $this->trait_set_up(); + + // The current user is reset between tests; capability checks need the admin. + \wp_set_current_user( 1 ); + } + + /** + * Complete the task. + * + * @return void + */ + protected function complete_task() { + // Simulate the update being installed: the refreshed transient reports the + // new version as installed and no longer offers an upgrade. + \set_site_transient( + 'update_core', + (object) [ + 'updates' => [], + 'version_checked' => $this->get_offered_version(), + ] + ); + } + + /** + * Test that the task ID carries the offered version. + */ + public function test_task_id_is_versioned() { + $expected = 'security-update-' . \str_replace( '.', '-', $this->get_offered_version() ); + $this->assertSame( $expected, $this->task_provider->get_task_id() ); + } + + /** + * Test that the task is neither dismissable nor snoozable and has top priority. + */ + public function test_task_is_locked_to_highest_urgency() { + $this->assertFalse( $this->task_provider->is_dismissable() ); + $this->assertFalse( $this->task_provider->is_snoozable() ); + $this->assertSame( 0, $this->task_provider->get_priority() ); + } + + /** + * Test that a superseded offer replaces the published task. + */ + public function test_superseded_offer_replaces_task() { + $this->task_provider->get_tasks_to_inject(); + $first_task_id = $this->task_provider->get_task_id(); + + // A newer patch release supersedes the first one. + $this->set_security_offer( 2 ); + $this->task_provider->get_tasks_to_inject(); + + $tasks = (array) \progress_planner()->get_suggested_tasks_db()->get_tasks_by( + [ + 'post_status' => 'publish', + 'provider' => $this->task_provider_id, + ] + ); + + $this->assertCount( 1, $tasks ); + $this->assertSame( $this->task_provider->get_task_id(), $tasks[0]->post_name ); + $this->assertNotSame( $first_task_id, $tasks[0]->post_name ); + } + + /** + * Test that a withdrawn offer deletes the published task without celebration. + */ + public function test_withdrawn_offer_deletes_task() { + $this->task_provider->get_tasks_to_inject(); + + // The offer is withdrawn; the installed version did not change. + \set_site_transient( 'update_core', (object) [ 'updates' => [] ] ); + + $completed = \progress_planner()->get_suggested_tasks()->get_tasks_manager()->evaluate_tasks(); + + $this->assertSame( [], $completed ); + $this->assertSame( + [], + (array) \progress_planner()->get_suggested_tasks_db()->get_tasks_by( + [ + 'post_status' => 'publish', + 'provider' => $this->task_provider_id, + ] + ) + ); + } + + /** + * Test that a new security release is injected even after an older one was completed. + */ + public function test_new_release_injected_after_previous_completion() { + $this->task_provider->get_tasks_to_inject(); + + // Complete the first task. + $tasks = (array) \progress_planner()->get_suggested_tasks_db()->get_tasks_by( + [ + 'post_status' => 'publish', + 'provider' => $this->task_provider_id, + ] + ); + \progress_planner()->get_suggested_tasks_db()->update_recommendation( $tasks[0]->ID, [ 'post_status' => 'trash' ] ); + + // A newer security release appears. + $this->set_security_offer( 2 ); + $this->task_provider->get_tasks_to_inject(); + + $tasks = (array) \progress_planner()->get_suggested_tasks_db()->get_tasks_by( + [ + 'post_status' => 'publish', + 'provider' => $this->task_provider_id, + ] + ); + + $this->assertCount( 1, $tasks ); + $this->assertSame( $this->task_provider->get_task_id(), $tasks[0]->post_name ); + } + + /** + * Test that a published security task hides all other tasks from the REST-format list. + */ + public function test_lockdown_limits_rest_format_to_security_task() { + $this->add_other_task(); + + // Without a published security task, the other task is listed. + $tasks = \progress_planner()->get_suggested_tasks()->get_tasks_in_rest_format( [ 'post_status' => 'publish' ] ); + $this->assertCount( 1, $tasks ); + + $this->task_provider->get_tasks_to_inject(); + \wp_cache_flush(); + + $tasks = \progress_planner()->get_suggested_tasks()->get_tasks_in_rest_format( [ 'post_status' => 'publish' ] ); + + $this->assertCount( 1, $tasks ); + $this->assertSame( $this->task_provider->get_task_id(), $tasks[0]['slug'] ); + } + + /** + * Test that the lockdown leaves non-publish statuses (celebrations, user tasks) alone. + */ + public function test_lockdown_does_not_affect_pending_celebration_tasks() { + // Celebration tasks transition publish → pending in production, keeping their slug. + $other_task_id = \progress_planner()->get_suggested_tasks_db()->add( + [ + 'task_id' => 'core-blogdescription', + 'provider_id' => 'core-blogdescription', + 'post_title' => 'Celebrate me', + 'post_status' => 'publish', + 'url' => \admin_url( 'options-general.php' ), + ] + ); + \progress_planner()->get_suggested_tasks_db()->update_recommendation( $other_task_id, [ 'post_status' => 'pending' ] ); + $this->task_provider->get_tasks_to_inject(); + \wp_cache_flush(); + + $tasks = \progress_planner()->get_suggested_tasks()->get_tasks_in_rest_format( [ 'post_status' => 'pending' ] ); + + $this->assertCount( 1, $tasks ); + $this->assertSame( 'core-blogdescription', $tasks[0]['slug'] ); + } + + /** + * Test that the REST collection query is constrained to the security provider under lockdown. + */ + public function test_lockdown_forces_rest_tax_query_to_security_provider() { + $this->task_provider->get_tasks_to_inject(); + + $request = new \WP_REST_Request( 'GET', '/wp/v2/prpl_recommendations' ); + $args = \progress_planner()->get_suggested_tasks()->rest_api_tax_query( [], $request ); + + $this->assertSame( [ 'security-update' ], $this->get_tax_query_include_terms( $args ) ); + } + + /** + * Test that users who cannot install updates keep their normal task list. + */ + public function test_lockdown_skips_users_without_update_capability() { + $this->task_provider->get_tasks_to_inject(); + + $editor_id = self::factory()->user->create( [ 'role' => 'editor' ] ); + \wp_set_current_user( $editor_id ); + + $request = new \WP_REST_Request( 'GET', '/wp/v2/prpl_recommendations' ); + $args = \progress_planner()->get_suggested_tasks()->rest_api_tax_query( [], $request ); + $terms = $this->get_tax_query_include_terms( $args ); + + \wp_set_current_user( 1 ); + + $this->assertNotSame( [ 'security-update' ], $terms ); + $this->assertNotEmpty( $terms ); + } + + /** + * Test that automatic core updates complete the security task without celebration. + */ + public function test_automatic_update_completes_security_task() { + $this->task_provider->get_tasks_to_inject(); + + $tasks = (array) \progress_planner()->get_suggested_tasks_db()->get_tasks_by( + [ + 'post_status' => 'publish', + 'provider' => $this->task_provider_id, + ] + ); + $this->assertCount( 1, $tasks ); + + \progress_planner()->get_suggested_tasks()->on_automatic_updates_complete(); + + $this->assertSame( 'trash', \get_post_status( $tasks[0]->ID ) ); + } + + /** + * Add a published task from another provider. + * + * @return void + */ + private function add_other_task() { + \progress_planner()->get_suggested_tasks_db()->add( + [ + 'task_id' => 'core-blogdescription', + 'provider_id' => 'core-blogdescription', + 'post_title' => 'Set your tagline', + 'post_status' => 'publish', + 'url' => \admin_url( 'options-general.php' ), + ] + ); + \wp_cache_flush(); + } + + /** + * Extract the IN-operator provider terms from REST query args. + * + * @param array $args The query args returned by rest_api_tax_query(). + * + * @return array + */ + private function get_tax_query_include_terms( $args ) { + foreach ( (array) ( $args['tax_query'] ?? [] ) as $clause ) { + if ( \is_array( $clause ) && isset( $clause['operator'] ) && 'IN' === $clause['operator'] ) { + return \array_values( (array) $clause['terms'] ); + } + } + + return []; + } + + /** + * Get an offered patch version relative to the running WordPress version. + * + * @param int $bump How many patch versions to bump. + * + * @return string + */ + private function get_offered_version( $bump = 1 ) { + $parts = \explode( '.', \wp_get_wp_version() ); + + return $parts[0] . '.' . $parts[1] . '.' . ( isset( $parts[2] ) ? (int) $parts[2] + $bump : $bump ); + } + + /** + * Store an update_core transient offering a security release. + * + * @param int $bump How many patch versions to bump. + * + * @return void + */ + private function set_security_offer( $bump = 1 ) { + \set_site_transient( + 'update_core', + (object) [ + 'updates' => [ + (object) [ + 'response' => 'upgrade', + 'current' => $this->get_offered_version( $bump ), + ], + ], + ] + ); + } +} diff --git a/views/page-widgets/suggested-tasks.php b/views/page-widgets/suggested-tasks.php index d0ca25a330..64099f4d2e 100644 --- a/views/page-widgets/suggested-tasks.php +++ b/views/page-widgets/suggested-tasks.php @@ -23,15 +23,21 @@ ); ?> -

- get_ui__branding()->get_ravi_name() ) - ); - ?> -

+ get_utils__security_update_monitor()->is_security_lockdown_active() && \current_user_can( 'update_core' ) ) : ?> +

+ +

+ +

+ get_ui__branding()->get_ravi_name() ) + ); + ?> +

+ From 56e89f0740b5114c56a7a1275504a0c6a62224cf Mon Sep 17 00:00:00 2001 From: Taco Verdonschot Date: Fri, 14 Aug 2026 11:47:24 +0200 Subject: [PATCH 2/7] Drop the SaaS security-alert ping in favor of the feed-driven approach 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 --- .../utils/class-security-update-monitor.php | 35 +--------------- .../test-class-security-update-monitor.php | 42 +------------------ 2 files changed, 4 insertions(+), 73 deletions(-) diff --git a/classes/utils/class-security-update-monitor.php b/classes/utils/class-security-update-monitor.php index 4e95dc4424..312bce8b7d 100644 --- a/classes/utils/class-security-update-monitor.php +++ b/classes/utils/class-security-update-monitor.php @@ -82,9 +82,10 @@ public function maybe_alert( $transient ) { try { $this->send_admin_email( $update['installed'], $update['offered'] ); - $this->send_saas_ping( $update['installed'], $update['offered'] ); // Mark as alerted even if sending failed, to avoid alert storms on every request. + // The subscriber email is sent by progressplanner.com, which watches the + // wordpress.org releases feed and reads this site's version via get-stats. \update_site_option( self::ALERTED_VERSION_OPTION, $update['offered'] ); } finally { \delete_option( self::LOCK_OPTION ); @@ -159,38 +160,6 @@ private function get_recipients() { return \array_values( \array_unique( $emails ) ); } - /** - * Notify the Progress Planner SaaS so it can email the registered subscriber. - * - * @param string $installed The installed version. - * @param string $offered The offered version. - * - * @return void - */ - private function send_saas_ping( $installed, $offered ) { - $license_key = \get_option( 'progress_planner_license_key' ); - if ( ! $license_key || 'no-license' === $license_key ) { - return; - } - - $onboard = \progress_planner()->get_utils__onboard(); - - // Fire-and-forget: the SaaS emails the registered subscriber. - \wp_remote_post( - $onboard->get_remote_url( 'security-update-alert' ), - [ - 'timeout' => 10, - 'body' => [ - 'license_key' => $license_key, - 'site' => \set_url_scheme( \site_url() ), - 'installed_version' => $installed, - 'offered_version' => $offered, - 'nonce' => $onboard->get_remote_nonce(), - ], - ] - ); - } - /** * Check whether a security-update task is currently published. * diff --git a/tests/phpunit/test-class-security-update-monitor.php b/tests/phpunit/test-class-security-update-monitor.php index 7dae9a5734..9020b5321d 100644 --- a/tests/phpunit/test-class-security-update-monitor.php +++ b/tests/phpunit/test-class-security-update-monitor.php @@ -161,41 +161,15 @@ public function test_alert_email_contains_version_and_update_link() { } /** - * Test that maybe_alert pings the SaaS when a license key is present. + * Test that maybe_alert makes no HTTP requests (subscriber email is feed-driven, SaaS-side). */ - public function test_maybe_alert_pings_saas_when_license_key_present() { + public function test_maybe_alert_makes_no_http_requests() { \update_option( 'progress_planner_license_key', 'test-license-key' ); $this->capture_mails(); $requests = $this->capture_http_requests(); ( new Security_Update_Monitor() )->maybe_alert( $this->get_security_transient() ); - $alert_requests = \array_values( - \array_filter( - $requests->calls, - static function ( $request ) { - return false !== \strpos( $request['url'], 'security-update-alert' ); - } - ) - ); - - $this->assertCount( 1, $alert_requests ); - $this->assertSame( 'test-license-key', $alert_requests[0]['body']['license_key'] ); - $this->assertSame( $this->get_offered_version(), $alert_requests[0]['body']['offered_version'] ); - $this->assertArrayHasKey( 'installed_version', $alert_requests[0]['body'] ); - $this->assertArrayHasKey( 'site', $alert_requests[0]['body'] ); - } - - /** - * Test that the SaaS ping is skipped without a license key. - */ - public function test_maybe_alert_skips_ping_without_license_key() { - \delete_option( 'progress_planner_license_key' ); - $this->capture_mails(); - $requests = $this->capture_http_requests(); - - ( new Security_Update_Monitor() )->maybe_alert( $this->get_security_transient() ); - $this->assertSame( [], $requests->calls ); } @@ -285,18 +259,6 @@ private function capture_http_requests() { \add_filter( 'pre_http_request', static function ( $pre, $args, $url ) use ( $recorder ) { - if ( false !== \strpos( $url, 'get-nonce' ) ) { - return [ - 'headers' => [], - 'response' => [ - 'code' => 200, - 'message' => 'OK', - ], - 'body' => (string) \wp_json_encode( [ 'nonce' => 'remote-nonce' ] ), - 'cookies' => [], - ]; - } - $recorder->calls[] = [ 'url' => $url, 'body' => isset( $args['body'] ) ? $args['body'] : [], From 49059a0eb83501478cd5421ace6056991d176fa1 Mon Sep 17 00:00:00 2001 From: Taco Verdonschot Date: Fri, 14 Aug 2026 11:54:37 +0200 Subject: [PATCH 3/7] Make the alert-recipients test multisite-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../test-class-security-update-monitor.php | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/test-class-security-update-monitor.php b/tests/phpunit/test-class-security-update-monitor.php index 9020b5321d..5063e6c640 100644 --- a/tests/phpunit/test-class-security-update-monitor.php +++ b/tests/phpunit/test-class-security-update-monitor.php @@ -122,9 +122,19 @@ public function test_maybe_alert_sends_email_to_admins_once_per_version() { $monitor = new Security_Update_Monitor(); $monitor->maybe_alert( $this->get_security_transient() ); + // On multisite only super admins can update core; on single site, all update_core users. $expected_recipients = []; - foreach ( \get_users( [ 'capability' => 'update_core' ] ) as $user ) { - $expected_recipients[] = $user->user_email; + if ( \is_multisite() ) { + foreach ( \get_super_admins() as $login ) { + $user = \get_user_by( 'login', $login ); + if ( $user ) { + $expected_recipients[] = $user->user_email; + } + } + } else { + foreach ( \get_users( [ 'capability' => 'update_core' ] ) as $user ) { + $expected_recipients[] = $user->user_email; + } } \sort( $expected_recipients ); @@ -134,7 +144,9 @@ public function test_maybe_alert_sends_email_to_admins_once_per_version() { $this->assertNotEmpty( $expected_recipients ); $this->assertSame( $expected_recipients, $actual_recipients ); $this->assertNotContains( \get_user_by( 'id', $editor_id )->user_email, $actual_recipients ); - $this->assertContains( \get_user_by( 'id', $admin_id )->user_email, $actual_recipients ); + if ( ! \is_multisite() ) { + $this->assertContains( \get_user_by( 'id', $admin_id )->user_email, $actual_recipients ); + } // Second call with the same offered version must not send again. $mails->calls = []; From 8e39c2b1b28f6b009e67e751f9687476b28f563e Mon Sep 17 00:00:00 2001 From: Taco Verdonschot Date: Fri, 14 Aug 2026 12:12:42 +0200 Subject: [PATCH 4/7] Detect same-branch security patches offered as autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../utils/class-security-update-monitor.php | 5 ++- .../test-class-security-update-monitor.php | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/classes/utils/class-security-update-monitor.php b/classes/utils/class-security-update-monitor.php index 312bce8b7d..6afe1e7e30 100644 --- a/classes/utils/class-security-update-monitor.php +++ b/classes/utils/class-security-update-monitor.php @@ -231,9 +231,12 @@ public static function get_pending_security_update( $transient = null, $installe $offered = null; foreach ( $transient->updates as $update ) { + // Same-branch point releases are offered with response "autoupdate" (only the + // newest release gets "upgrade"), so a branch-behind site (e.g. 6.9.1 when + // 7.0.x is current) receives its 6.9.x security patch as an autoupdate offer. if ( ! \is_object( $update ) || ! isset( $update->response, $update->current ) - || 'upgrade' !== $update->response + || ! \in_array( $update->response, [ 'upgrade', 'autoupdate' ], true ) || ! \is_string( $update->current ) || ! self::is_security_release( $installed, $update->current ) ) { diff --git a/tests/phpunit/test-class-security-update-monitor.php b/tests/phpunit/test-class-security-update-monitor.php index 5063e6c640..187912277b 100644 --- a/tests/phpunit/test-class-security-update-monitor.php +++ b/tests/phpunit/test-class-security-update-monitor.php @@ -91,6 +91,39 @@ public function test_get_pending_security_update_ignores_feature_release() { ); } + /** + * Test the real API shape for a branch-behind site: the same-branch patch is + * only offered with response "autoupdate" once a newer major exists. + * + * A 6.9.1 site gets: upgrade 7.0.4, autoupdate 7.0.4, autoupdate 6.9.7. + */ + public function test_get_pending_security_update_detects_autoupdate_offer_on_older_branch() { + $transient = (object) [ + 'updates' => [ + (object) [ + 'response' => 'upgrade', + 'current' => '7.0.4', + ], + (object) [ + 'response' => 'autoupdate', + 'current' => '7.0.4', + ], + (object) [ + 'response' => 'autoupdate', + 'current' => '6.9.7', + ], + ], + ]; + + $this->assertSame( + [ + 'installed' => '6.9.1', + 'offered' => '6.9.7', + ], + Security_Update_Monitor::get_pending_security_update( $transient, '6.9.1' ) + ); + } + /** * Test that non-upgrade offers are ignored. */ From 978aa20a1aab526a1d7a3fffd7cda55875ebd52a Mon Sep 17 00:00:00 2001 From: Taco Verdonschot Date: Fri, 14 Aug 2026 13:05:52 +0200 Subject: [PATCH 5/7] Add a one-click branch-pinned update button to the security task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../providers/class-security-update.php | 31 +++++++++++ .../utils/class-security-update-monitor.php | 44 ++++++++++++--- .../test-class-security-update-provider.php | 55 +++++++++++++++++++ 3 files changed, 121 insertions(+), 9 deletions(-) diff --git a/classes/suggested-tasks/providers/class-security-update.php b/classes/suggested-tasks/providers/class-security-update.php index 6077ef5833..966b7d5cc3 100644 --- a/classes/suggested-tasks/providers/class-security-update.php +++ b/classes/suggested-tasks/providers/class-security-update.php @@ -236,12 +236,43 @@ protected function modify_injection_task_data( $task_data ) { /** * Add task actions specific to this task. * + * The wp-admin Updates page only lists the latest release (get_core_updates() + * skips "autoupdate" offers), so a branch-behind site would only be offered the + * next major there. This form posts the exact branch version and locale to + * core's own do-core-upgrade handler, which accepts any offer in the transient. + * * @param array $data The task data. * @param array $actions The existing actions. * * @return array */ public function add_task_actions( $data = [], $actions = [] ) { + $offer = Security_Update_Monitor::get_pending_security_update_offer(); + + if ( null !== $offer ) { + $form_action = \is_multisite() + ? \network_admin_url( 'update-core.php?action=do-core-upgrade' ) + : \admin_url( 'update-core.php?action=do-core-upgrade' ); + $locale = $offer->locale ?? 'en_US'; + + $actions[] = [ + 'priority' => 5, + 'html' => '
' + . \wp_nonce_field( 'upgrade-core', '_wpnonce', true, false ) + . '' + . '' + . '' + . '' + . '
', + ]; + } + $actions[] = [ 'priority' => 10, 'html' => '' . \esc_html__( 'Go to the Updates page', 'progress-planner' ) . '', diff --git a/classes/utils/class-security-update-monitor.php b/classes/utils/class-security-update-monitor.php index 6afe1e7e30..e41b5c6091 100644 --- a/classes/utils/class-security-update-monitor.php +++ b/classes/utils/class-security-update-monitor.php @@ -217,6 +217,32 @@ public static function is_security_release( $installed, $offered ) { * @return array{installed: string, offered: string}|null */ public static function get_pending_security_update( $transient = null, $installed = null ) { + if ( null === $installed ) { + $installed = self::get_installed_version(); + } + + $offer = self::get_pending_security_update_offer( $transient, $installed ); + + return null === $offer + ? null + : [ + 'installed' => $installed, + 'offered' => $offer->current, + ]; + } + + /** + * Get the update offer object for the pending security update, if any. + * + * The offer carries the exact version and locale needed to run a + * version-pinned core upgrade through wp-admin/update-core.php. + * + * @param object|null $transient The update_core site transient. Defaults to the stored transient. + * @param string|null $installed The installed version. Defaults to the running WordPress version. + * + * @return object{response: string, current: string, locale?: string}|null + */ + public static function get_pending_security_update_offer( $transient = null, $installed = null ) { if ( null === $transient ) { $transient = \get_site_transient( 'update_core' ); } @@ -229,7 +255,12 @@ public static function get_pending_security_update( $transient = null, $installe return null; } - $offered = null; + /** + * The best matching offer. + * + * @var object{response: string, current: string, locale?: string}|null $offer + */ + $offer = null; foreach ( $transient->updates as $update ) { // Same-branch point releases are offered with response "autoupdate" (only the // newest release gets "upgrade"), so a branch-behind site (e.g. 6.9.1 when @@ -243,17 +274,12 @@ public static function get_pending_security_update( $transient = null, $installe continue; } - if ( null === $offered || \version_compare( $update->current, $offered, '>' ) ) { - $offered = $update->current; + if ( null === $offer || \version_compare( $update->current, $offer->current, '>' ) ) { + $offer = $update; } } - return null === $offered - ? null - : [ - 'installed' => $installed, - 'offered' => $offered, - ]; + return $offer; } /** diff --git a/tests/phpunit/test-class-security-update-provider.php b/tests/phpunit/test-class-security-update-provider.php index e7e8494612..4bb9b1c7c9 100644 --- a/tests/phpunit/test-class-security-update-provider.php +++ b/tests/phpunit/test-class-security-update-provider.php @@ -239,6 +239,61 @@ public function test_automatic_update_completes_security_task() { $this->assertSame( 'trash', \get_post_status( $tasks[0]->ID ) ); } + /** + * Test that the task actions include a form that updates to the branch version directly. + * + * The wp-admin Updates page only shows the latest major (get_core_updates() skips + * "autoupdate" offers), so the task must offer the branch-pinned update itself. + */ + public function test_task_action_contains_branch_pinned_update_form() { + $actions = $this->task_provider->add_task_actions( [], [] ); + $html = \implode( ' ', \array_column( $actions, 'html' ) ); + + $this->assertStringContainsString( 'update-core.php?action=do-core-upgrade', $html ); + $this->assertStringContainsString( 'name="version" value="' . $this->get_offered_version() . '"', $html ); + $this->assertStringContainsString( 'name="locale" value="en_US"', $html ); + $this->assertStringContainsString( 'name="upgrade"', $html ); + $this->assertStringContainsString( 'name="_wpnonce"', $html ); + // The fallback link to the Updates page remains. + $this->assertStringContainsString( 'Go to the Updates page', $html ); + } + + /** + * Test that the update form uses the locale of the matched offer. + */ + public function test_task_action_form_uses_offer_locale() { + \set_site_transient( + 'update_core', + (object) [ + 'updates' => [ + (object) [ + 'response' => 'autoupdate', + 'current' => $this->get_offered_version(), + 'locale' => 'de_DE', + ], + ], + ] + ); + + $actions = $this->task_provider->add_task_actions( [], [] ); + $html = \implode( ' ', \array_column( $actions, 'html' ) ); + + $this->assertStringContainsString( 'name="locale" value="de_DE"', $html ); + } + + /** + * Test that no update form is rendered without a pending security update. + */ + public function test_task_action_form_absent_without_pending_update() { + \set_site_transient( 'update_core', (object) [ 'updates' => [] ] ); + + $actions = $this->task_provider->add_task_actions( [], [] ); + $html = \implode( ' ', \array_column( $actions, 'html' ) ); + + $this->assertStringNotContainsString( 'do-core-upgrade', $html ); + $this->assertStringContainsString( 'Go to the Updates page', $html ); + } + /** * Add a published task from another provider. * From da7a54c0926dbacf5eeb77e0b1bf77cff5698c18 Mon Sep 17 00:00:00 2001 From: Taco Verdonschot Date: Fri, 14 Aug 2026 13:34:24 +0200 Subject: [PATCH 6/7] Recommend a fresh backup in the security task and alert email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../providers/class-security-update.php | 2 +- classes/utils/class-security-update-monitor.php | 7 +++++-- tests/phpunit/test-class-security-update-monitor.php | 3 +++ tests/phpunit/test-class-security-update-provider.php | 10 ++++++++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/classes/suggested-tasks/providers/class-security-update.php b/classes/suggested-tasks/providers/class-security-update.php index 966b7d5cc3..a0555c0f86 100644 --- a/classes/suggested-tasks/providers/class-security-update.php +++ b/classes/suggested-tasks/providers/class-security-update.php @@ -118,7 +118,7 @@ protected function get_title() { * @return string */ protected function get_description() { - return \esc_html__( 'A WordPress security release is available. Security issues are typically exploited within hours of a release, so install this update as soon as possible.', 'progress-planner' ); + return \esc_html__( 'A WordPress security release is available. Security issues are typically exploited within hours of a release, so install this update as soon as possible. If you have a backup solution, make a fresh backup first — but do not postpone the update if you have none.', 'progress-planner' ); } /** diff --git a/classes/utils/class-security-update-monitor.php b/classes/utils/class-security-update-monitor.php index e41b5c6091..ae72a0b19b 100644 --- a/classes/utils/class-security-update-monitor.php +++ b/classes/utils/class-security-update-monitor.php @@ -109,7 +109,7 @@ private function send_admin_email( $installed, $offered ) { ); $message = \sprintf( - /* translators: 1: The offered WordPress version, 2: The installed WordPress version, 3: The URL of the updates page. */ + /* translators: 1: The offered WordPress version, 2: The installed WordPress version, 3: The URL of the updates page, 4: The URL of the WordPress backups documentation. */ \__( 'A WordPress security release is available: version %1$s (your site runs %2$s). @@ -117,6 +117,8 @@ private function send_admin_email( $installed, $offered ) { %3$s +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: %4$s + If your site has automatic updates enabled, it may install this update by itself — in that case, please verify on the page above that the update has been applied. This alert was sent by the Progress Planner plugin.', @@ -124,7 +126,8 @@ private function send_admin_email( $installed, $offered ) { ), $offered, $installed, - \admin_url( 'update-core.php' ) + \admin_url( 'update-core.php' ), + 'https://wordpress.org/documentation/article/wordpress-backups/' ); foreach ( $this->get_recipients() as $email ) { diff --git a/tests/phpunit/test-class-security-update-monitor.php b/tests/phpunit/test-class-security-update-monitor.php index 187912277b..c0d9b6f019 100644 --- a/tests/phpunit/test-class-security-update-monitor.php +++ b/tests/phpunit/test-class-security-update-monitor.php @@ -203,6 +203,9 @@ public function test_alert_email_contains_version_and_update_link() { $mail = $mails->calls[0]; $this->assertStringContainsString( $this->get_offered_version(), $mail['subject'] . $mail['message'] ); $this->assertStringContainsString( \admin_url( 'update-core.php' ), $mail['message'] ); + // The backup advice must be present, but phrased not to delay the update. + $this->assertStringContainsString( 'backup', $mail['message'] ); + $this->assertStringContainsString( 'https://wordpress.org/documentation/article/wordpress-backups/', $mail['message'] ); } /** diff --git a/tests/phpunit/test-class-security-update-provider.php b/tests/phpunit/test-class-security-update-provider.php index 4bb9b1c7c9..58a76d4bee 100644 --- a/tests/phpunit/test-class-security-update-provider.php +++ b/tests/phpunit/test-class-security-update-provider.php @@ -239,6 +239,16 @@ public function test_automatic_update_completes_security_task() { $this->assertSame( 'trash', \get_post_status( $tasks[0]->ID ) ); } + /** + * Test that the task description recommends a backup without inviting delay. + */ + public function test_task_description_recommends_backup() { + $description = $this->task_provider->get_task_details()['description']; + + $this->assertStringContainsString( 'backup', $description ); + $this->assertStringContainsString( 'postpone', $description ); + } + /** * Test that the task actions include a form that updates to the branch version directly. * From 86be167a422964c757d745cf9cceec705a1761a1 Mon Sep 17 00:00:00 2001 From: Taco Verdonschot Date: Fri, 14 Aug 2026 13:54:34 +0200 Subject: [PATCH 7/7] Point the alert email at the PP dashboard for onboarded sites 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 --- .../utils/class-security-update-monitor.php | 54 +++++++++++++------ .../test-class-security-update-monitor.php | 17 ++++++ 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/classes/utils/class-security-update-monitor.php b/classes/utils/class-security-update-monitor.php index ae72a0b19b..ed8f5b6dc1 100644 --- a/classes/utils/class-security-update-monitor.php +++ b/classes/utils/class-security-update-monitor.php @@ -108,27 +108,47 @@ private function send_admin_email( $installed, $offered ) { $offered ); - $message = \sprintf( - /* translators: 1: The offered WordPress version, 2: The installed WordPress version, 3: The URL of the updates page, 4: The URL of the WordPress backups documentation. */ - \__( - 'A WordPress security release is available: version %1$s (your site runs %2$s). - -Security issues in WordPress are typically exploited within hours of a release, so please update as soon as possible: - -%3$s - -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: %4$s + // Onboarded sites go to the Progress Planner dashboard, which carries the + // one-click branch-pinned update button. The wp-admin Updates page only + // offers the latest major, so it is just the fallback for sites that have + // not onboarded yet (their dashboard would show the welcome screen). + $is_onboarded = \progress_planner()->is_privacy_policy_accepted(); + $action_url = $is_onboarded + ? \admin_url( 'admin.php?page=progress-planner' ) + : \admin_url( 'update-core.php' ); + + $paragraphs = [ + \sprintf( + /* translators: 1: The offered WordPress version, 2: The installed WordPress version. */ + \__( 'A WordPress security release is available: version %1$s (your site runs %2$s).', 'progress-planner' ), + $offered, + $installed + ), + \sprintf( + /* translators: %s: The URL where the update can be installed. */ + \__( + 'Security issues in WordPress are typically exploited within hours of a release, so please update as soon as possible: + +%s', + 'progress-planner' + ), + $action_url + ), + ]; -If your site has automatic updates enabled, it may install this update by itself — in that case, please verify on the page above that the update has been applied. + if ( $is_onboarded ) { + $paragraphs[] = \__( '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.', 'progress-planner' ); + } -This alert was sent by the Progress Planner plugin.', - 'progress-planner' - ), - $offered, - $installed, - \admin_url( 'update-core.php' ), + $paragraphs[] = \sprintf( + /* translators: %s: The URL of the WordPress backups documentation. */ + \__( '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: %s', 'progress-planner' ), 'https://wordpress.org/documentation/article/wordpress-backups/' ); + $paragraphs[] = \__( '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.', 'progress-planner' ); + $paragraphs[] = \__( 'This alert was sent by the Progress Planner plugin.', 'progress-planner' ); + + $message = \implode( "\n\n", $paragraphs ); foreach ( $this->get_recipients() as $email ) { \wp_mail( $email, $subject, $message ); diff --git a/tests/phpunit/test-class-security-update-monitor.php b/tests/phpunit/test-class-security-update-monitor.php index c0d9b6f019..3e410810e0 100644 --- a/tests/phpunit/test-class-security-update-monitor.php +++ b/tests/phpunit/test-class-security-update-monitor.php @@ -208,6 +208,23 @@ public function test_alert_email_contains_version_and_update_link() { $this->assertStringContainsString( 'https://wordpress.org/documentation/article/wordpress-backups/', $mail['message'] ); } + /** + * Test that onboarded sites are sent to the Progress Planner dashboard, + * where the one-click branch-pinned update button lives. + */ + public function test_alert_email_links_to_dashboard_when_onboarded() { + \update_option( 'progress_planner_license_key', 'test-license-key' ); + $mails = $this->capture_mails(); + + ( new Security_Update_Monitor() )->maybe_alert( $this->get_security_transient() ); + + $this->assertNotEmpty( $mails->calls ); + $message = $mails->calls[0]['message']; + $this->assertStringContainsString( \admin_url( 'admin.php?page=progress-planner' ), $message ); + $this->assertStringContainsString( 'one-click', $message ); + $this->assertStringNotContainsString( \admin_url( 'update-core.php' ), $message ); + } + /** * Test that maybe_alert makes no HTTP requests (subscriber email is feed-driven, SaaS-side). */