From 9fb8354cfe803dbb38794d82fcfe8ae57f4a84cf Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Tue, 26 May 2026 12:12:53 +0200 Subject: [PATCH 1/6] Fix PHPStan errors and phpunit CVE on main Brings main's static analysis and dependency security checks back to green: - Static Analysis: clear 25 pre-existing PHPStan errors. Ports develop's typed @return on Date::get_periods()/get_range() (which also resolves the Chart modify() errors), takes develop's exact versions of class-page-settings, class-activity-scores, class-chart and class-update-140, converts the WP-core require_once ignores to the @phpstan-ignore-next-line form that suppresses under PHPStan 2.1.x, and adds inline ignores elsewhere. - Security check: bump phpunit/phpunit 9.6.30 -> 9.6.34 in composer.lock to resolve CVE-2026-24765 (unsafe deserialization in PHPT code coverage). --- classes/admin/class-page-settings.php | 162 ++++++------------ .../admin/widgets/class-activity-scores.php | 3 +- classes/class-base.php | 9 +- classes/class-suggested-tasks.php | 1 + classes/suggested-tasks/class-task.php | 6 +- .../data-collector/class-inactive-plugins.php | 3 +- .../providers/class-core-update.php | 3 +- .../providers/class-fewer-tags.php | 3 +- .../providers/class-select-locale.php | 6 +- classes/ui/class-chart.php | 131 ++++++++++---- classes/update/class-update-140.php | 6 +- classes/utils/class-color-customizer.php | 2 +- classes/utils/class-date.php | 18 +- composer.lock | 102 +++++------ 14 files changed, 245 insertions(+), 210 deletions(-) diff --git a/classes/admin/class-page-settings.php b/classes/admin/class-page-settings.php index 7d7d42c8f0..e0976691cb 100644 --- a/classes/admin/class-page-settings.php +++ b/classes/admin/class-page-settings.php @@ -14,42 +14,6 @@ */ class Page_Settings { - /** - * Constructor. - */ - public function __construct() { - // Add the admin menu page. - \add_action( 'admin_menu', [ $this, 'add_admin_menu_page' ] ); - - // Add AJAX hooks to save options. - \add_action( 'wp_ajax_prpl_settings_form', [ $this, 'store_settings_form_options' ] ); - } - - /** - * Add admin-menu page, as a submenu in the progress-planner menu. - * - * @return void - */ - public function add_admin_menu_page() { - \add_submenu_page( - 'progress-planner', - \esc_html__( 'Settings', 'progress-planner' ), - \esc_html__( 'Settings', 'progress-planner' ), - 'manage_options', - 'progress-planner-settings', - [ $this, 'add_admin_page_content' ] - ); - } - - /** - * Add content to the admin page of the free plugin. - * - * @return void - */ - public function add_admin_page_content() { - require_once PROGRESS_PLANNER_DIR . '/views/admin-page-settings.php'; - } - /** * Get an array of settings. * @@ -58,27 +22,28 @@ public function add_admin_page_content() { public function get_settings() { $settings = []; foreach ( \progress_planner()->get_page_types()->get_page_types() as $page_type ) { - if ( ! $this->should_show_setting( $page_type['slug'] ) ) { + $slug = (string) $page_type['slug']; // @phpstan-ignore offsetAccess.invalidOffset + if ( ! $this->should_show_setting( $slug ) ) { continue; } - $settings[ $page_type['slug'] ] = [ - 'id' => $page_type['slug'], + $settings[ $slug ] = [ + 'id' => $slug, 'value' => '_no_page_needed', 'isset' => 'no', - 'title' => $page_type['title'], - 'description' => $page_type['description'] ?? '', + 'title' => $page_type['title'], // @phpstan-ignore offsetAccess.invalidOffset + 'description' => $page_type['description'] ?? '', // @phpstan-ignore offsetAccess.invalidOffset 'type' => 'page-select', - 'page' => $page_type['slug'], + 'page' => $slug, ]; - if ( \progress_planner()->get_page_types()->is_page_needed( $page_type['slug'] ) ) { - $type_pages = \progress_planner()->get_page_types()->get_posts_by_type( 'any', $page_type['slug'] ); + if ( \progress_planner()->get_page_types()->is_page_needed( $slug ) ) { + $type_pages = \progress_planner()->get_page_types()->get_posts_by_type( 'any', $slug ); if ( empty( $type_pages ) ) { - $settings[ $page_type['slug'] ]['value'] = \progress_planner()->get_page_types()->get_default_page_id_by_type( $page_type['slug'] ); + $settings[ $slug ]['value'] = \progress_planner()->get_page_types()->get_default_page_id_by_type( $slug ); } else { - $settings[ $page_type['slug'] ]['value'] = $type_pages[0]->ID; - $settings[ $page_type['slug'] ]['isset'] = 'yes'; + $settings[ $slug ]['value'] = $type_pages[0]->ID; + $settings[ $slug ]['isset'] = 'yes'; // If there is more than one page, we need to check if the page has a parent with the same page-type assigned. if ( 1 < \count( $type_pages ) ) { @@ -89,7 +54,7 @@ public function get_settings() { foreach ( $type_pages as $type_page ) { $parent = \get_post_field( 'post_parent', $type_page->ID ); if ( $parent && \in_array( (int) $parent, $type_pages_ids, true ) ) { - $settings[ $page_type['slug'] ]['value'] = $parent; + $settings[ $slug ]['value'] = $parent; break; } } @@ -123,95 +88,76 @@ public function should_show_setting( $page_type ) { } /** - * Store the settings form options. + * Set the page value. + * + * @param array $pages The pages. * * @return void */ - public function store_settings_form_options() { + public function set_page_values( $pages ) { - if ( ! \current_user_can( 'manage_options' ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'You do not have permission to update settings.', 'progress-planner' ) ] ); + if ( empty( $pages ) ) { + return; } - // Use check_ajax_referer instead of check_admin_referer for AJAX handlers. - // check_admin_referer is designed for form submissions, not AJAX requests. - if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); - } - - if ( isset( $_POST['pages'] ) ) { - // Sanitize the pages array at point of reception. - $pages = \map_deep( \wp_unslash( $_POST['pages'] ), 'sanitize_text_field' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - - foreach ( $pages as $type => $page_args ) { - $need_page = isset( $page_args['have_page'] ) ? $page_args['have_page'] : ''; + foreach ( $pages as $type => $page_args ) { + $need_page = isset( $page_args['have_page'] ) ? $page_args['have_page'] : ''; - \progress_planner()->get_page_types()->set_no_page_needed( - $type, - 'not-applicable' === $need_page - ); - - // Remove the post-meta from the existing posts. - $existing_posts = \progress_planner()->get_page_types()->get_posts_by_type( 'any', $type ); - foreach ( $existing_posts as $post ) { - if ( $post->ID === (int) $page_args['id'] && 'no' !== $page_args['have_page'] ) { - continue; - } + \progress_planner()->get_page_types()->set_no_page_needed( + $type, + 'not-applicable' === $need_page + ); - // Get the term-ID for the type. - $term = \get_term_by( 'slug', $type, Page_Types::TAXONOMY_NAME ); - if ( ! $term instanceof \WP_Term ) { - continue; - } - - // Remove the assigned terms from the `progress_planner_page_types` taxonomy. - \wp_remove_object_terms( $post->ID, $term->term_id, Page_Types::TAXONOMY_NAME ); + // Remove the post-meta from the existing posts. + $existing_posts = \progress_planner()->get_page_types()->get_posts_by_type( 'any', $type ); + foreach ( $existing_posts as $post ) { + if ( $post->ID === (int) $page_args['id'] && 'no' !== $page_args['have_page'] ) { + continue; } - // Skip if the ID is not set. - if ( ! isset( $page_args['id'] ) || 1 > (int) $page_args['id'] ) { + // Get the term-ID for the type. + $term = \get_term_by( 'slug', $type, Page_Types::TAXONOMY_NAME ); + if ( ! $term instanceof \WP_Term ) { continue; } - if ( 'no' !== $page_args['have_page'] ) { - // Add the term to the `progress_planner_page_types` taxonomy. - \progress_planner()->get_page_types()->set_page_type_by_id( (int) $page_args['id'], $type ); - } + // Remove the assigned terms from the `progress_planner_page_types` taxonomy. + \wp_remove_object_terms( $post->ID, $term->term_id, Page_Types::TAXONOMY_NAME ); } - } - - $this->save_settings(); - $this->save_post_types(); - \do_action( 'progress_planner_settings_form_options_stored' ); + // Skip if the ID is not set. + if ( ! isset( $page_args['id'] ) || 1 > (int) $page_args['id'] ) { + continue; + } - \wp_send_json_success( \esc_html__( 'Options stored successfully', 'progress-planner' ) ); + if ( 'no' !== $page_args['have_page'] ) { + // Add the term to the `progress_planner_page_types` taxonomy. + \progress_planner()->get_page_types()->set_page_type_by_id( (int) $page_args['id'], $type ); + } + } } /** - * Save the settings. + * Save the redirect on login setting. + * + * @param bool $redirect_on_login Whether to redirect on login. * * @return void */ - public function save_settings() { - // Nonce is already checked in store_settings_form_options() which calls this method. - $redirect_on_login = isset( $_POST['prpl-redirect-on-login'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing - ? \sanitize_text_field( \wp_unslash( $_POST['prpl-redirect-on-login'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Missing - : false; - - \update_user_meta( \get_current_user_id(), 'prpl_redirect_on_login', (bool) $redirect_on_login ); + public function save_redirect_on_login( $redirect_on_login = false ) { + \update_user_meta( \get_current_user_id(), 'prpl_redirect_on_login', $redirect_on_login ); } /** * Save the post types. * + * @param array $post_types The post types. + * * @return void */ - public function save_post_types() { - // Nonce is already checked in store_settings_form_options() which calls this method. - $include_post_types = isset( $_POST['prpl-post-types-include'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing - ? \array_map( 'sanitize_text_field', \wp_unslash( $_POST['prpl-post-types-include'] ) ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing - // If no post types are selected, use the default post types (post and page can be deregistered). + public function save_post_types( $post_types = [] ) { + $include_post_types = ! empty( $post_types ) + ? $post_types : \array_intersect( [ 'post', 'page' ], \progress_planner()->get_settings()->get_public_post_types() ); \progress_planner()->get_settings()->set( 'include_post_types', $include_post_types ); diff --git a/classes/admin/widgets/class-activity-scores.php b/classes/admin/widgets/class-activity-scores.php index 974c7abba8..8adcde6448 100644 --- a/classes/admin/widgets/class-activity-scores.php +++ b/classes/admin/widgets/class-activity-scores.php @@ -98,7 +98,8 @@ public function get_checklist_results() { $items = $this->get_checklist(); $results = []; foreach ( $items as $item ) { - $results[ $item['label'] ] = $item['callback'](); + $label = (string) $item['label']; // @phpstan-ignore offsetAccess.invalidOffset + $results[ $label ] = $item['callback'](); // @phpstan-ignore offsetAccess.invalidOffset } return $results; } diff --git a/classes/class-base.php b/classes/class-base.php index 2aa14bbd4b..a9e8f6b384 100644 --- a/classes/class-base.php +++ b/classes/class-base.php @@ -86,10 +86,12 @@ class Base { */ public function init() { if ( ! \function_exists( 'current_user_can' ) ) { - require_once ABSPATH . 'wp-includes/capabilities.php'; // @phpstan-ignore requireOnce.fileNotFound + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-includes/capabilities.php'; } if ( ! \function_exists( 'wp_get_current_user' ) ) { - require_once ABSPATH . 'wp-includes/pluggable.php'; // @phpstan-ignore requireOnce.fileNotFound + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-includes/pluggable.php'; } if ( \defined( '\IS_PLAYGROUND_PREVIEW' ) && \constant( '\IS_PLAYGROUND_PREVIEW' ) === true ) { @@ -380,7 +382,8 @@ public function get_file_version( $file ) { // Otherwise, use the plugin header. if ( ! \function_exists( 'get_file_data' ) ) { - require_once ABSPATH . 'wp-includes/functions.php'; // @phpstan-ignore requireOnce.fileNotFound + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-includes/functions.php'; } if ( ! self::$plugin_version ) { diff --git a/classes/class-suggested-tasks.php b/classes/class-suggested-tasks.php index c79e308ece..01a6ee7066 100644 --- a/classes/class-suggested-tasks.php +++ b/classes/class-suggested-tasks.php @@ -479,6 +479,7 @@ public function rest_api_tax_query( $args, $request ) { // Handle sorting parameters. if ( isset( $request['filter']['orderby'] ) ) { + // @phpstan-ignore-next-line argument.templateType $args['orderby'] = \sanitize_sql_orderby( $request['filter']['orderby'] ); } if ( isset( $request['filter']['order'] ) ) { diff --git a/classes/suggested-tasks/class-task.php b/classes/suggested-tasks/class-task.php index aadebf1383..3bf174c3ca 100644 --- a/classes/suggested-tasks/class-task.php +++ b/classes/suggested-tasks/class-task.php @@ -183,12 +183,14 @@ public function get_rest_formatted_data( $post_id = null ): array { // Make sure WP_REST_Posts_Controller is loaded. if ( ! \class_exists( 'WP_REST_Posts_Controller' ) ) { - require_once ABSPATH . 'wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php'; // @phpstan-ignore requireOnce.fileNotFound + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php'; } // Make sure WP_REST_Request is loaded. if ( ! \class_exists( 'WP_REST_Request' ) ) { - require_once ABSPATH . 'wp-includes/rest-api/class-wp-rest-request.php'; // @phpstan-ignore requireOnce.fileNotFound + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-includes/rest-api/class-wp-rest-request.php'; } // Use the appropriate controller for the post type. diff --git a/classes/suggested-tasks/data-collector/class-inactive-plugins.php b/classes/suggested-tasks/data-collector/class-inactive-plugins.php index 2495f48da6..abcb264766 100644 --- a/classes/suggested-tasks/data-collector/class-inactive-plugins.php +++ b/classes/suggested-tasks/data-collector/class-inactive-plugins.php @@ -47,7 +47,8 @@ public function update_inactive_plugins_cache() { */ protected function calculate_data() { if ( ! \function_exists( 'get_plugins' ) ) { - require_once ABSPATH . 'wp-admin/includes/plugin.php'; // @phpstan-ignore requireOnce.fileNotFound + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-admin/includes/plugin.php'; } // Clear the plugins cache, so get_plugins() returns the latest plugins. diff --git a/classes/suggested-tasks/providers/class-core-update.php b/classes/suggested-tasks/providers/class-core-update.php index 714ace08ea..0a681b4373 100644 --- a/classes/suggested-tasks/providers/class-core-update.php +++ b/classes/suggested-tasks/providers/class-core-update.php @@ -107,7 +107,8 @@ public function add_core_update_link( $update_actions ) { public function should_add_task() { // Without this \wp_get_update_data() might not return correct data for the core updates (depending on the timing). if ( ! \function_exists( 'get_core_updates' ) ) { - require_once ABSPATH . 'wp-admin/includes/update.php'; // @phpstan-ignore requireOnce.fileNotFound + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-admin/includes/update.php'; } // For wp_get_update_data() to return correct data it needs to be called after the 'admin_init' action (with priority 10). diff --git a/classes/suggested-tasks/providers/class-fewer-tags.php b/classes/suggested-tasks/providers/class-fewer-tags.php index 643e3cb6c6..21a929474c 100644 --- a/classes/suggested-tasks/providers/class-fewer-tags.php +++ b/classes/suggested-tasks/providers/class-fewer-tags.php @@ -154,7 +154,8 @@ public function is_task_completed( $task_id = '' ) { protected function is_plugin_active() { if ( null === $this->is_plugin_active ) { if ( ! \function_exists( 'get_plugins' ) ) { - require_once ABSPATH . 'wp-admin/includes/plugin.php'; // @phpstan-ignore requireOnce.fileNotFound + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $plugins = \get_plugins(); diff --git a/classes/suggested-tasks/providers/class-select-locale.php b/classes/suggested-tasks/providers/class-select-locale.php index 88ac6bfcd7..313d4aa5cf 100644 --- a/classes/suggested-tasks/providers/class-select-locale.php +++ b/classes/suggested-tasks/providers/class-select-locale.php @@ -205,7 +205,8 @@ public function print_popover_instructions() { public function print_popover_form_contents() { if ( ! \function_exists( 'wp_get_available_translations' ) ) { - require_once ABSPATH . 'wp-admin/includes/translation-install.php'; // @phpstan-ignore requireOnce.fileNotFound + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-admin/includes/translation-install.php'; } $languages = \get_available_languages(); @@ -277,7 +278,8 @@ public function handle_interactive_task_specific_submit() { // Handle translation installation. if ( \current_user_can( 'install_languages' ) ) { - require_once ABSPATH . 'wp-admin/includes/translation-install.php'; // @phpstan-ignore requireOnce.fileNotFound + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-admin/includes/translation-install.php'; if ( \wp_can_install_language_pack() ) { $language = \wp_download_language_pack( $language_for_update ); diff --git a/classes/ui/class-chart.php b/classes/ui/class-chart.php index 71f2b3a05e..587bd452ee 100644 --- a/classes/ui/class-chart.php +++ b/classes/ui/class-chart.php @@ -15,8 +15,11 @@ class Chart { /** * Build a chart for the stats. * - * @param array $args The arguments for the chart. - * See `get_chart_data` for the available parameters. + * @param array $args { + * The arguments for the chart. See `get_chart_data` for all available parameters. + * + * @type string $type Chart type (e.g., 'line', 'bar'). + * } * * @return void */ @@ -28,23 +31,49 @@ public function the_chart( $args = [] ) { /** * Get data for the chart. * - * @param array $args The arguments for the chart. - * ['items_callback'] The callback to get items. - * ['filter_results'] The callback to filter the results. Leave empty/null to skip filtering. - * ['dates_params'] The dates parameters for the query. - * ['start_date'] The start date for the chart. - * ['end_date'] The end date for the chart. - * ['frequency'] The frequency for the chart nodes. - * ['format'] The format for the label. + * Normalized charts: + * When $args['normalized'] is true, the chart implements a "decay" algorithm that carries + * forward previous period's activities into the current period with decaying values. + * This creates a rolling momentum effect where past activities continue to contribute + * to current scores, gradually diminishing over time. + * + * Example: If a user published 10 posts in January, the normalized chart for February + * will include both February's new posts plus a decayed value from January's posts. + * This encourages consistent activity by showing how past work continues to have impact. + * + * @param array $args { + * The arguments for the chart. + * + * @type callable $items_callback Callback to fetch items for a date range. + * Signature: function( DateTime $start_date, DateTime $end_date ): array + * @type callable|null $filter_results Optional callback to filter results after fetching. + * Signature: function( array $activities ): array + * @type array $dates_params { + * Date range and frequency parameters. * - * @return array + * @type DateTime $start_date The start date for the chart. + * @type DateTime $end_date The end date for the chart. + * @type string $frequency The frequency for chart nodes (e.g., 'day', 'week', 'month'). + * @type string $format The label format (e.g., 'Y-m-d', 'M j'). + * } + * @type bool $normalized Whether to use normalized scoring with decay from previous periods. + * Default false. + * @type callable $color Callback to determine bar/line color. + * Signature: function(): string (hex color code) + * @type callable $count_callback Callback to calculate score from activities. + * Signature: function( array $activities, DateTime|null $date ): int|float + * @type int|null $max Optional maximum value for chart scaling. + * @type string $type Chart type: 'line' or 'bar'. Default 'line'. + * @type array $return_data Which data fields to return in output. + * Default ['label', 'score', 'color']. + * } + * + * @return array Array of chart data points, each containing requested fields (label, score, color, etc). */ public function get_chart_data( $args = [] ) { $activities = []; - /* - * Set default values for the arguments. - */ + // Set default values for the arguments. $args = \wp_parse_args( $args, [ @@ -61,7 +90,7 @@ public function get_chart_data( $args = [] ) { ] ); - // Get the periods for the chart. + // Get the periods for the chart (e.g., months, weeks, days based on frequency). $periods = \progress_planner()->get_utils__date()->get_periods( $args['dates_params']['start_date'], $args['dates_params']['end_date'], @@ -69,15 +98,25 @@ public function get_chart_data( $args = [] ) { ); /* - * "Normalized" charts decay the score of previous months activities, - * and add them to the current month score. - * This means that for "normalized" charts, we need to get activities - * for the month prior to the first period. + * For "normalized" charts, implement a decay algorithm: + * - Previous period's activities "decay" and carry forward into current period + * - This creates momentum: past productivity continues to boost current scores + * - We need to fetch activities from the month BEFORE the chart starts + * - These previous activities will be added (with decay) to the first period's score + * + * Example: For a chart starting Feb 1, fetch Jan 1-31 activities to contribute + * to February's normalized score. */ $previous_period_activities = []; if ( $args['normalized'] ) { - $previous_month_start = ( clone $periods[0]['start_date'] )->modify( '-1 month' ); - $previous_month_end = ( clone $periods[0]['start_date'] )->modify( '-1 day' ); + /** + * The start date of the first period. + * + * @var \DateTime $first_period_start + */ + $first_period_start = $periods[0]['start_date']; + $previous_month_start = ( clone $first_period_start )->modify( '-1 month' ); + $previous_month_end = ( clone $first_period_start )->modify( '-1 day' ); $previous_period_activities = $args['items_callback']( $previous_month_start, $previous_month_end ); if ( $args['filter_results'] ) { $activities = $args['filter_results']( $activities ); @@ -92,7 +131,8 @@ public function get_chart_data( $args = [] ) { $previous_period_activities = $period_data['previous_period_activities']; $period_data_filtered = []; foreach ( $args['return_data'] as $key ) { - $period_data_filtered[ $key ] = $period_data[ $key ]; + $key_string = (string) $key; // @phpstan-ignore offsetAccess.invalidOffset + $period_data_filtered[ $key_string ] = $period_data[ $key_string ]; // @phpstan-ignore offsetAccess.invalidOffset } $data[] = $period_data_filtered; } @@ -101,30 +141,55 @@ public function get_chart_data( $args = [] ) { } /** - * Get the data for a period. + * Get the data for a single period in the chart. * - * @param array $period The period. - * @param array $args The arguments for the chart. - * @param array $previous_period_activities The activities for the previous month. + * For normalized charts, this implements the decay algorithm: + * 1. Calculate score from current period's activities (normal scoring) + * 2. Add decayed score from previous period's activities (normalized bonus) + * 3. Save current activities to decay into next period * - * @return array + * The decay is handled by the count_callback, which typically reduces scores + * based on how old the activities are relative to the current period. + * + * @param array $period { + * The time period being processed. + * + * @type DateTime $start_date Period start date. + * @type DateTime $end_date Period end date. + * @type string $label Human-readable label for this period. + * } + * @param array $args The chart arguments (see get_chart_data). + * @param array $previous_period_activities Activities from the previous period to apply decay to. + * + * @return array { + * Period data with score and metadata. + * + * @type string $label Period label (e.g., "Jan 2025"). + * @type int|float $score Calculated score for this period. + * @type string $color Color for this data point. + * @type array $previous_period_activities Activities to carry forward to next period. + * } */ public function get_period_data( $period, $args, $previous_period_activities ) { - // Get the activities for the period. + // Get the activities for the current period. $activities = $args['items_callback']( $period['start_date'], $period['end_date'] ); - // Filter the results if a callback is provided. + + // Apply optional filtering callback. if ( $args['filter_results'] ) { $activities = $args['filter_results']( $activities ); } - // Calculate the score for the period. + // Calculate the base score from current period's activities. $period_score = $args['count_callback']( $activities, $period['start_date'] ); - // If this is a "normalized" chart, we need to calculate the score for the previous month activities. + // For normalized charts, apply decay algorithm. if ( $args['normalized'] ) { - // Add the previous month activities to the current month score. + // Add decayed score from previous period's activities to current score. + // The count_callback determines the decay rate based on activity age. $period_score += $args['count_callback']( $previous_period_activities, $period['start_date'] ); - // Update the previous month activities for the next iteration of the loop. + + // Save current activities to decay into the next period. + // This creates a rolling momentum effect across time periods. $previous_period_activities = $activities; } diff --git a/classes/update/class-update-140.php b/classes/update/class-update-140.php index 9a25b2c968..aeb83429c4 100644 --- a/classes/update/class-update-140.php +++ b/classes/update/class-update-140.php @@ -41,10 +41,12 @@ private function rename_tasks_option() { // This is to ensure that we don't lose any tasks, and at the same time we don't have duplicate tasks. $tasks = []; foreach ( $new_tasks as $new_task ) { - $tasks[ isset( $new_task['task_id'] ) ? $new_task['task_id'] : \md5( \maybe_serialize( $new_task ) ) ] = $new_task; + $key = isset( $new_task['task_id'] ) ? (string) $new_task['task_id'] : \md5( \maybe_serialize( $new_task ) ); // @phpstan-ignore offsetAccess.invalidOffset + $tasks[ $key ] = $new_task; } foreach ( $old_tasks as $old_task ) { - $tasks[ isset( $old_task['task_id'] ) ? $old_task['task_id'] : \md5( \maybe_serialize( $old_task ) ) ] = $old_task; + $key = isset( $old_task['task_id'] ) ? (string) $old_task['task_id'] : \md5( \maybe_serialize( $old_task ) ); // @phpstan-ignore offsetAccess.invalidOffset + $tasks[ $key ] = $old_task; } // Set the tasks option. diff --git a/classes/utils/class-color-customizer.php b/classes/utils/class-color-customizer.php index 6097b86fba..ee216cd837 100644 --- a/classes/utils/class-color-customizer.php +++ b/classes/utils/class-color-customizer.php @@ -130,7 +130,7 @@ private function save_colors() { $post_value = isset( $_POST[ $key ] ) ? \sanitize_text_field( \wp_unslash( $_POST[ $key ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing $color_value = \sanitize_text_field( \wp_unslash( $post_value ) ); if ( ! empty( $color_value ) ) { - $colors[ $variable ] = $color_value; + $colors[ $variable ] = $color_value; // @phpstan-ignore offsetAccess.invalidOffset } } } diff --git a/classes/utils/class-date.php b/classes/utils/class-date.php index 42f6cba79a..2810fa8401 100644 --- a/classes/utils/class-date.php +++ b/classes/utils/class-date.php @@ -18,10 +18,7 @@ class Date { * @param \DateTime $start_date The start date. * @param \DateTime $end_date The end date. * - * @return array [ - * 'start_date' => \DateTime, - * 'end_date' => \DateTime, - * ]. + * @return array > */ public function get_range( $start_date, $end_date ) { $dates = \iterator_to_array( new \DatePeriod( $start_date, new \DateInterval( 'P1D' ), $end_date ), false ); @@ -38,7 +35,7 @@ public function get_range( $start_date, $end_date ) { * @param \DateTime $end_date The end date. * @param string $frequency The frequency. Can be 'daily', 'weekly', 'monthly'. * - * @return array + * @return array */ public function get_periods( $start_date, $end_date, $frequency ) { $end_date->modify( '+1 day' ); @@ -71,8 +68,15 @@ public function get_periods( $start_date, $end_date, $frequency ) { if ( empty( $date_ranges ) ) { return []; } - if ( $end_date->format( 'z' ) !== \end( $date_ranges )['end_date']->format( 'z' ) ) { - $final_end = clone \end( $date_ranges )['end_date']; + $last_range = \end( $date_ranges ); + /** + * The end date of the last range. + * + * @var \DateTime $last_end_date + */ + $last_end_date = $last_range['end_date']; + if ( $end_date->format( 'z' ) !== $last_end_date->format( 'z' ) ) { + $final_end = clone $last_end_date; $date_ranges[] = $this->get_range( $final_end->modify( '+1 day' ), $end_date ); } diff --git a/composer.lock b/composer.lock index ebc2aed9f9..e95cd186fb 100644 --- a/composer.lock +++ b/composer.lock @@ -1038,16 +1038,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.6.2", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "3a454ca033b9e06b63282ce19562e892747449bb" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb", - "reference": "3a454ca033b9e06b63282ce19562e892747449bb", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -1090,9 +1090,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2025-10-21T19:32:17+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "phar-io/manifest", @@ -1863,16 +1863,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.0", + "version": "2.3.2", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495" + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495", - "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", "shasum": "" }, "require": { @@ -1904,9 +1904,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" }, - "time": "2025-08-30T15:50:23+00:00" + "time": "2026-01-25T14:56:51+00:00" }, { "name": "phpstan/phpstan", @@ -2282,16 +2282,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.30", + "version": "9.6.34", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "b69489b312503bf8fa6d75a76916919d7d2fa6d4" + "reference": "b36f02317466907a230d3aa1d34467041271ef4a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b69489b312503bf8fa6d75a76916919d7d2fa6d4", - "reference": "b69489b312503bf8fa6d75a76916919d7d2fa6d4", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b36f02317466907a230d3aa1d34467041271ef4a", + "reference": "b36f02317466907a230d3aa1d34467041271ef4a", "shasum": "" }, "require": { @@ -2313,7 +2313,7 @@ "phpunit/php-timer": "^5.0.3", "sebastian/cli-parser": "^1.0.2", "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.9", + "sebastian/comparator": "^4.0.10", "sebastian/diff": "^4.0.6", "sebastian/environment": "^5.1.5", "sebastian/exporter": "^4.0.8", @@ -2365,7 +2365,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.30" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.34" }, "funding": [ { @@ -2389,7 +2389,7 @@ "type": "tidelift" } ], - "time": "2025-12-01T07:35:08+00:00" + "time": "2026-01-27T05:45:00+00:00" }, { "name": "psr/container", @@ -3180,6 +3180,7 @@ "type": "github" } ], + "abandoned": true, "time": "2020-10-26T13:08:54+00:00" }, { @@ -3235,20 +3236,21 @@ "type": "github" } ], + "abandoned": true, "time": "2020-09-28T05:30:19+00:00" }, { "name": "sebastian/comparator", - "version": "4.0.9", + "version": "4.0.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5" + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/67a2df3a62639eab2cc5906065e9805d4fd5dfc5", - "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", "shasum": "" }, "require": { @@ -3301,7 +3303,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.9" + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" }, "funding": [ { @@ -3321,7 +3323,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T06:51:50+00:00" + "time": "2026-01-24T09:22:56+00:00" }, { "name": "sebastian/complexity", @@ -4381,16 +4383,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -4403,7 +4405,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -4428,7 +4430,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -4439,12 +4441,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { "name": "symfony/event-dispatcher", @@ -4818,16 +4824,16 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -4877,7 +4883,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -4897,7 +4903,7 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", @@ -5153,16 +5159,16 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -5213,7 +5219,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -5233,7 +5239,7 @@ "type": "tidelift" } ], - "time": "2025-01-02T08:10:11+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php81", @@ -6080,13 +6086,13 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, - "platform": [], - "platform-dev": [], + "platform": {}, + "platform-dev": {}, "platform-overrides": { "php": "8.3" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } From 5a0358cc4da6f00fe44b94eb9f1cf48b31dee6c7 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Tue, 26 May 2026 12:19:15 +0200 Subject: [PATCH 2/6] Fix abstract method fatal in test-class-security.php The anonymous classes extending the abstract Tasks_Interactive did not implement the abstract Tasks::should_add_task() method. phpunit 9.6.30 did not surface this, but 9.6.34 (the CVE-2026-24765 fix) does, causing a fatal when the test class loads. Implement should_add_task() in all 8 anonymous task providers. --- tests/phpunit/test-class-security.php | 72 +++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/phpunit/test-class-security.php b/tests/phpunit/test-class-security.php index c9073f9d7d..57f2f441ff 100644 --- a/tests/phpunit/test-class-security.php +++ b/tests/phpunit/test-class-security.php @@ -189,6 +189,15 @@ public function print_popover_form_contents() {} public function evaluate() { return false; } + + /** + * Check if the task condition is satisfied. + * + * @return bool + */ + public function should_add_task() { + return true; + } }; // Set initial value. @@ -273,6 +282,15 @@ public function print_popover_form_contents() {} public function evaluate() { return false; } + + /** + * Check if the task condition is satisfied. + * + * @return bool + */ + public function should_add_task() { + return true; + } }; // Test without nonce. @@ -334,6 +352,15 @@ public function print_popover_form_contents() {} public function evaluate() { return false; } + + /** + * Check if the task condition is satisfied. + * + * @return bool + */ + public function should_add_task() { + return true; + } }; $_POST['nonce'] = \wp_create_nonce( 'progress_planner' ); @@ -394,6 +421,15 @@ public function print_popover_form_contents() {} public function evaluate() { return false; } + + /** + * Check if the task condition is satisfied. + * + * @return bool + */ + public function should_add_task() { + return true; + } }; // Set up a nested option. @@ -470,6 +506,15 @@ public function print_popover_form_contents() {} public function evaluate() { return false; } + + /** + * Check if the task condition is satisfied. + * + * @return bool + */ + public function should_add_task() { + return true; + } }; // Test 1: Try to update a non-whitelisted option (should FAIL with fix). @@ -548,6 +593,15 @@ public function print_popover_form_contents() {} public function evaluate() { return false; } + + /** + * Check if the task condition is satisfied. + * + * @return bool + */ + public function should_add_task() { + return true; + } }; // Test updating a whitelisted option (should SUCCEED). @@ -621,6 +675,15 @@ public function print_popover_form_contents() {} public function evaluate() { return false; } + + /** + * Check if the task condition is satisfied. + * + * @return bool + */ + public function should_add_task() { + return true; + } }; // Test updating the custom whitelisted option (should SUCCEED). @@ -687,6 +750,15 @@ public function print_popover_form_contents() {} public function evaluate() { return false; } + + /** + * Check if the task condition is satisfied. + * + * @return bool + */ + public function should_add_task() { + return true; + } }; $critical_options = [ From adf51ac1d54fc9cf759d8445f4d4e8d8c70a5c48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Ili=C4=87?= Date: Fri, 29 May 2026 14:27:04 +0200 Subject: [PATCH 3/6] v1.9.1 (#763) * Sanitize and escape prpl_recommendations title An authenticated Editor (or higher) could create a recommendation via POST /wp/v2/prpl_recommendations with an HTML payload in the `title` field (e.g. ``). The dashboard JS template (views/js-templates/suggested-task.html) renders `title.rendered` with Underscore's unescaped `{{{ }}}` syntax, so the payload executed when an admin loaded the dashboard. Defense in depth: - Input: add a `rest_pre_insert_prpl_recommendations` filter that strips tags from `post_title` on every REST insert/update, regardless of the user's `unfiltered_html` capability. Recommendation titles are plain text, so this neutralizes the payload at the source. - Output (JS): route the two raw `{{{ }}}` title sinks through a new `prplSuggestedTask.sanitizeTitle()` helper, which inert-parses the value with DOMParser (no script/resource side effects) and re-escapes it, preserving legitimate entities like `&` without double-encoding the server-side `esc_html`'d provider titles. - Output (admin bar): the PRPL debug tool printed `post_title` unescaped into a `WP_Admin_Bar` node id (an HTML attribute) and title (rendered as raw HTML), firing the payload on every admin page in debug mode. Escape the title with `esc_html()`, use the post ID for the node id, and escape the activities node title too. - Also switch `updateTaskTitle` to set `.textContent` instead of `.innerHTML` for the screen-reader label, closing a self-XSS sink. Adds tests/phpunit/test-class-rest-recommendations-xss.php covering Editor and Administrator payloads plus a plain-text regression check. * Bump version to 1.9.1 * add migration script and revert JS title escaping * add inline comment, cc @tacoverdo * Delete recommendation when sanitized title is empty A title that is pure markup strips to an empty string. wp_update_post() rejects an update that would leave the title, content, and excerpt all empty, so the malicious title was left in the DB. The plugin never stores title-less recommendations, so delete such rows instead. Co-Authored-By: Claude Opus 4.7 (1M context) * update readme.txt --------- Co-authored-by: Claude Opus 4.7 (1M context) --- assets/js/suggested-task.js | 4 +- classes/class-suggested-tasks.php | 25 +++ classes/update/class-update-191.php | 63 +++++++ classes/utils/class-debug-tools.php | 8 +- progress-planner.php | 2 +- readme.txt | 7 +- .../test-class-rest-recommendations-xss.php | 155 ++++++++++++++++++ .../test-class-upgrade-migration-191.php | 129 +++++++++++++++ views/js-templates/suggested-task.html | 2 + 9 files changed, 389 insertions(+), 6 deletions(-) create mode 100644 classes/update/class-update-191.php create mode 100644 tests/phpunit/test-class-rest-recommendations-xss.php create mode 100644 tests/phpunit/test-class-upgrade-migration-191.php diff --git a/assets/js/suggested-task.js b/assets/js/suggested-task.js index 42bd863074..6b27d085b9 100644 --- a/assets/js/suggested-task.js +++ b/assets/js/suggested-task.js @@ -472,11 +472,13 @@ prplSuggestedTask = { } ) ) ); + // Use textContent (not innerHTML) so a title typed into the + // contenteditable field cannot inject markup. el .closest( 'li.prpl-suggested-task' ) .querySelector( 'label:has(.prpl-suggested-task-checkbox) .screen-reader-text' - ).innerHTML = `${ title }: ${ prplL10n( 'markAsComplete' ) }`; + ).textContent = `${ title }: ${ prplL10n( 'markAsComplete' ) }`; }, 300 ); }, diff --git a/classes/class-suggested-tasks.php b/classes/class-suggested-tasks.php index 01a6ee7066..ae75b46383 100644 --- a/classes/class-suggested-tasks.php +++ b/classes/class-suggested-tasks.php @@ -67,6 +67,9 @@ public function __construct() { // Filter the REST API response. \add_filter( 'rest_prepare_prpl_recommendations', [ $this, 'rest_prepare_recommendation' ], 10, 2 ); + // Sanitize the recommendation title on insert/update via the REST API, to prevent stored XSS. + \add_filter( 'rest_pre_insert_prpl_recommendations', [ $this, 'rest_sanitize_recommendation' ], 10, 2 ); + \add_filter( 'wp_trash_post_days', [ $this, 'change_trashed_posts_lifetime' ], 10, 2 ); } @@ -491,6 +494,28 @@ public function rest_api_tax_query( $args, $request ) { return $args; } + /** + * Sanitize a recommendation before it is inserted or updated via the REST API. + * + * Recommendation titles are plain text (they are rendered unescaped in JS + * templates such as views/js-templates/suggested-task.html), so we strip any + * HTML tags here to prevent stored XSS. This runs regardless of the user's + * `unfiltered_html` capability, which WordPress would otherwise honor for the + * post title. + * + * @param \stdClass $prepared_post An object representing a single post prepared for inserting or updating the database. + * @param \WP_REST_Request $request The request object. + * + * @return \stdClass The sanitized post object. + */ + public function rest_sanitize_recommendation( $prepared_post, $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + if ( isset( $prepared_post->post_title ) ) { + $prepared_post->post_title = \sanitize_text_field( \wp_strip_all_tags( $prepared_post->post_title ) ); + } + + return $prepared_post; + } + /** * Filter the REST API response. * diff --git a/classes/update/class-update-191.php b/classes/update/class-update-191.php new file mode 100644 index 0000000000..1924ee45cf --- /dev/null +++ b/classes/update/class-update-191.php @@ -0,0 +1,63 @@ +sanitize_recommendation_titles(); + } + + /** + * Strip HTML tags from existing recommendation titles. + * + * Titles are now sanitized on write, but rows stored before that may still + * contain markup. This cleans them up. + * + * @return void + */ + private function sanitize_recommendation_titles() { + $db = \progress_planner()->get_suggested_tasks_db(); + + // get() defaults to all relevant statuses (publish, trash, draft, future, pending). + $recommendations = $db->get(); + + foreach ( $recommendations as $recommendation ) { + $sanitized = \sanitize_text_field( \wp_strip_all_tags( $recommendation->post_title ) ); + + // Nothing to do if stripping didn't change the title. + if ( $sanitized === $recommendation->post_title ) { + continue; + } + + // A title that was pure markup strips to an empty string. The plugin + // never stores title-less recommendations (Suggested_Tasks_DB::add() + // rejects them), so such a row is junk - delete it. This also avoids + // WordPress's empty-content guard, which would otherwise reject an + // update that leaves the title empty and leave the markup in place. + if ( '' === $sanitized ) { + $db->delete_recommendation( (int) $recommendation->ID ); + continue; + } + + $db->update_recommendation( $recommendation->ID, [ 'post_title' => $sanitized ] ); + } + } +} diff --git a/classes/utils/class-debug-tools.php b/classes/utils/class-debug-tools.php index 9a416d9497..efdf9c3bba 100644 --- a/classes/utils/class-debug-tools.php +++ b/classes/utils/class-debug-tools.php @@ -273,9 +273,11 @@ protected function add_suggested_tasks_submenu_item( $admin_bar ) { $admin_bar->add_node( [ - 'id' => 'prpl-suggested-' . $key . '-' . $title, + // Use the post ID (not the title) for the node ID, and escape the + // title for display - the admin bar renders 'title' as raw HTML. + 'id' => 'prpl-suggested-' . $key . '-' . $task->ID, 'parent' => 'prpl-suggested-' . $key, - 'title' => $title . ' ×', + 'title' => \esc_html( $title ) . ' ×', ] ); } @@ -307,7 +309,7 @@ protected function add_activities_submenu_item( $admin_bar ) { [ 'id' => 'prpl-activity-' . $activity->id, 'parent' => 'prpl-activities', - 'title' => $activity->data_id . ' - ' . $activity->category, + 'title' => \esc_html( $activity->data_id . ' - ' . $activity->category ), ] ); } diff --git a/progress-planner.php b/progress-planner.php index b7eb260332..0687f969f0 100644 --- a/progress-planner.php +++ b/progress-planner.php @@ -9,7 +9,7 @@ * Description: A plugin to help you fight procrastination and get things done. * Requires at least: 6.6 * Requires PHP: 7.4 - * Version: 1.9.0 + * Version: 1.9.1 * Author: Team Emilia Projects * Author URI: https://prpl.fyi/about * License: GPL-3.0+ diff --git a/readme.txt b/readme.txt index d0fd83b9bc..8a932d1a44 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: planning, maintenance, writing, blogging Requires at least: 6.3 Tested up to: 6.8 Requires PHP: 7.4 -Stable tag: 1.9.0 +Stable tag: 1.9.1 License: GPL3+ License URI: https://www.gnu.org/licenses/gpl-3.0.en.html @@ -83,6 +83,11 @@ https://youtu.be/e1bmxZYyXFY == Changelog == += 1.9.1 = + +- Security fix: Authenticated (Editor and above) Stored Cross-Site Scripting (XSS) via recommendation titles. Titles are now sanitized when saved, and existing recommendations are cleaned up via an update script. +- Thanks to [hongdo](https://patchstack.com/database/researchers/b19114df-00a1-4c42-b2f1-627b22001d57) for responsibly disclosing this issue via the Patchstack Bug Bounty Program. + = 1.9.0 = In this release we've added an integration with the **All In One Seo** plugin so you’ll now see personalized suggestions based on your current SEO configuration. diff --git a/tests/phpunit/test-class-rest-recommendations-xss.php b/tests/phpunit/test-class-rest-recommendations-xss.php new file mode 100644 index 0000000000..033587b599 --- /dev/null +++ b/tests/phpunit/test-class-rest-recommendations-xss.php @@ -0,0 +1,155 @@ +`) which was later rendered + * unescaped in the admin dashboard. + * + * @package Progress_Planner\Tests + */ + +namespace Progress_Planner\Tests; + +/** + * Class Rest_Recommendations_Xss_Test + */ +class Rest_Recommendations_Xss_Test extends \WP_UnitTestCase { + + /** + * Set up the REST server before each test. + * + * @return void + */ + public function setUp(): void { + parent::setUp(); + + global $wp_rest_server; + $wp_rest_server = new \WP_REST_Server(); + \do_action( 'rest_api_init' ); + } + + /** + * Tear down the REST server after each test. + * + * @return void + */ + public function tearDown(): void { + global $wp_rest_server; + $wp_rest_server = null; + parent::tearDown(); + } + + /** + * Submit a recommendation via the REST API and return the raw response. + * + * @param string $title The title to submit. + * @return \WP_REST_Response The REST response. + */ + private function submit_recommendation_via_rest( $title ) { + $request = new \WP_REST_Request( 'POST', '/wp/v2/prpl_recommendations' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + (string) \wp_json_encode( + [ + 'title' => $title, + 'status' => 'publish', + ] + ) + ); + + return \rest_get_server()->dispatch( $request ); + } + + /** + * Create a recommendation via the REST API and return the created WP_Post. + * + * @param string $title The title to submit. + * @return \WP_Post The created post. + */ + private function create_recommendation_via_rest( $title ) { + $response = $this->submit_recommendation_via_rest( $title ); + $this->assertSame( 201, $response->get_status(), 'Recommendation should be created.' ); + + $data = $response->get_data(); + return \get_post( $data['id'] ); + } + + /** + * An Editor submitting a title that is purely an HTML payload must not result + * in a stored post that contains the markup. + * + * Stripping the tags leaves an empty title, so WordPress rejects the request + * outright (the malicious post is never created) - an acceptable, even + * preferable, outcome. We assert that either nothing was stored, or if it was, + * the markup is gone. + * + * @return void + */ + public function test_editor_xss_title_is_stripped() { + $editor_id = self::factory()->user->create( [ 'role' => 'editor' ] ); + \wp_set_current_user( $editor_id ); + + $response = $this->submit_recommendation_via_rest( '' ); + + if ( 201 === $response->get_status() ) { + $post = \get_post( $response->get_data()['id'] ); + $this->assertStringNotContainsString( 'post_title ); + $this->assertStringNotContainsString( 'onerror', $post->post_title ); + $this->assertStringNotContainsString( '<', $post->post_title ); + } else { + // An empty title (after stripping) is rejected; no post is created. + $this->assertSame( 400, $response->get_status() ); + } + } + + /** + * A Hello' ); + + $this->assertStringNotContainsString( 'post_title ); + $this->assertStringNotContainsString( 'post_title ); + // The plain-text remainder is preserved. + $this->assertStringContainsString( 'Hello', $post->post_title ); + } + + /** + * Even an Administrator (who has the `unfiltered_html` capability) should + * have HTML stripped from recommendation titles, since they are rendered as + * plain text in JS templates. + * + * @return void + */ + public function test_admin_with_unfiltered_html_title_is_stripped() { + $admin_id = self::factory()->user->create( [ 'role' => 'administrator' ] ); + \wp_set_current_user( $admin_id ); + + $post = $this->create_recommendation_via_rest( 'Title' ); + + $this->assertStringNotContainsString( 'post_title ); + $this->assertStringNotContainsString( 'onerror', $post->post_title ); + $this->assertStringContainsString( 'Title', $post->post_title ); + } + + /** + * Legitimate plain-text titles (including ampersands) must be preserved. + * + * @return void + */ + public function test_plain_text_title_is_preserved() { + $editor_id = self::factory()->user->create( [ 'role' => 'editor' ] ); + \wp_set_current_user( $editor_id ); + + $post = $this->create_recommendation_via_rest( 'Buy milk & eggs' ); + + $this->assertSame( 'Buy milk & eggs', $post->post_title ); + } +} diff --git a/tests/phpunit/test-class-upgrade-migration-191.php b/tests/phpunit/test-class-upgrade-migration-191.php new file mode 100644 index 0000000000..bb912968ec --- /dev/null +++ b/tests/phpunit/test-class-upgrade-migration-191.php @@ -0,0 +1,129 @@ +post->create( + [ + 'post_type' => 'prpl_recommendations', + 'post_title' => 'placeholder', + 'post_content' => '', + 'post_excerpt' => '', + 'post_status' => 'publish', + ] + ); + + $wpdb->update( $wpdb->posts, [ 'post_title' => $raw_title ], [ 'ID' => $post_id ] ); // phpcs:ignore WordPress.DB + \clean_post_cache( $post_id ); + + return $post_id; + } + + /** + * Test that HTML tags are stripped from existing recommendation titles. + * + * @return void + */ + public function test_migration_strips_tags_from_titles() { + \progress_planner()->get_suggested_tasks_db()->delete_all_recommendations(); + + $task_id = $this->create_recommendation_with_raw_title( 'Hello' ); + + // Sanity check: the malicious markup is stored before the migration. + $this->assertStringContainsString( 'post_title ); + + ( new \Progress_Planner\Update\Update_191() )->run(); + + $title = \get_post( $task_id )->post_title; + $this->assertStringNotContainsString( 'assertStringNotContainsString( 'onerror', $title ); + $this->assertStringContainsString( 'Hello', $title ); + } + + /** + * Test that a recommendation whose title is *pure* markup is deleted, even + * when post_content and post_excerpt are empty. + * + * Stripping leaves an empty title. The plugin never stores title-less + * recommendations, so the row is junk and is removed. (Updating it in place + * would hit WordPress's empty-content guard and leave the markup behind.) + * + * @return void + */ + public function test_migration_deletes_pure_markup_title_with_empty_content() { + \progress_planner()->get_suggested_tasks_db()->delete_all_recommendations(); + + $task_id = $this->create_recommendation_with_raw_title( '' ); + + ( new \Progress_Planner\Update\Update_191() )->run(); + + $this->assertNull( \get_post( $task_id ), 'Pure-markup recommendation should be deleted.' ); + } + + /** + * Test that a Title' ); + + ( new \Progress_Planner\Update\Update_191() )->run(); + + $title = \get_post( $task_id )->post_title; + $this->assertStringNotContainsString( 'assertStringContainsString( 'Title', $title ); + } + + /** + * Test that a legitimate plain-text title is left unchanged. + * + * @return void + */ + public function test_migration_preserves_plain_text_titles() { + \progress_planner()->get_suggested_tasks_db()->delete_all_recommendations(); + + $task_id = $this->create_recommendation_with_raw_title( 'Buy milk & eggs' ); + + $modified_before = \get_post( $task_id )->post_modified; + + ( new \Progress_Planner\Update\Update_191() )->run(); + + $post = \get_post( $task_id ); + $this->assertSame( 'Buy milk & eggs', $post->post_title ); + // A title that needs no change must not be re-saved. + $this->assertSame( $modified_before, $post->post_modified, 'Clean title should not be modified.' ); + } +} diff --git a/views/js-templates/suggested-task.html b/views/js-templates/suggested-task.html index f4723088c2..ec40d3b55e 100644 --- a/views/js-templates/suggested-task.html +++ b/views/js-templates/suggested-task.html @@ -4,6 +4,7 @@ <# if ( 'user' === data.post.prpl_provider.slug ) { #> <# } else { #> @@ -15,6 +16,7 @@

+ <# /* title.rendered is already sanitized, so it is safe to print raw. */ #> contenteditable="plaintext-only" role="textbox" aria-label="Edit task title" aria-multiline="false" onkeydown="prplSuggestedTask.preventEnterKey( event ); prplSuggestedTask.updateTaskTitle( this );" data-post-id="{{ data.post.id }}"<# } #>>{{{ data.post.title.rendered }}}

From cb62309e077eb00b84a80c6335736658f01b1330 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Fri, 29 May 2026 14:51:19 +0200 Subject: [PATCH 4/6] Fix plain-text title test to pass on multisite On multisite, editors lack the unfiltered_html capability, so core's kses encodes the ampersand in the test title and the byte-for-byte assertion fails. Grant the capability (via super admin on multisite) so the test isolates our XSS sanitization rather than core's kses behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../phpunit/test-class-rest-recommendations-xss.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/phpunit/test-class-rest-recommendations-xss.php b/tests/phpunit/test-class-rest-recommendations-xss.php index 033587b599..bf293a0355 100644 --- a/tests/phpunit/test-class-rest-recommendations-xss.php +++ b/tests/phpunit/test-class-rest-recommendations-xss.php @@ -148,6 +148,18 @@ public function test_plain_text_title_is_preserved() { $editor_id = self::factory()->user->create( [ 'role' => 'editor' ] ); \wp_set_current_user( $editor_id ); + // This test isolates our XSS sanitization: a legitimate plain-text title + // must survive `wp_strip_all_tags()` + `sanitize_text_field()` unchanged. + // Core's kses would additionally encode the ampersand, but only for users + // without `unfiltered_html` — which on multisite excludes editors. Ensure + // the current user has the capability so the assertion is environment- + // independent and tests our code, not core's kses. + if ( \is_multisite() ) { + \grant_super_admin( $editor_id ); + } else { + ( new \WP_User( $editor_id ) )->add_cap( 'unfiltered_html' ); + } + $post = $this->create_recommendation_via_rest( 'Buy milk & eggs' ); $this->assertSame( 'Buy milk & eggs', $post->post_title ); From dc1405d0446794fb0ded751fe491acebbeeb1342 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Fri, 29 May 2026 14:57:16 +0200 Subject: [PATCH 5/6] Grant unfiltered_html before switching user in title test kses_init() runs on the set_current_user hook and decides whether to attach the kses filters at switch time. The capability must be granted before wp_set_current_user(), otherwise the filters are already attached and the multisite assertion still sees the ampersand encoded. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/phpunit/test-class-rest-recommendations-xss.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/test-class-rest-recommendations-xss.php b/tests/phpunit/test-class-rest-recommendations-xss.php index bf293a0355..0f93afa705 100644 --- a/tests/phpunit/test-class-rest-recommendations-xss.php +++ b/tests/phpunit/test-class-rest-recommendations-xss.php @@ -146,20 +146,22 @@ public function test_admin_with_unfiltered_html_title_is_stripped() { */ public function test_plain_text_title_is_preserved() { $editor_id = self::factory()->user->create( [ 'role' => 'editor' ] ); - \wp_set_current_user( $editor_id ); // This test isolates our XSS sanitization: a legitimate plain-text title // must survive `wp_strip_all_tags()` + `sanitize_text_field()` unchanged. // Core's kses would additionally encode the ampersand, but only for users - // without `unfiltered_html` — which on multisite excludes editors. Ensure - // the current user has the capability so the assertion is environment- - // independent and tests our code, not core's kses. + // without `unfiltered_html` — which on multisite excludes editors. Grant + // the capability *before* switching the current user, because `kses_init()` + // (hooked on `set_current_user`) decides whether to attach the kses filters + // at switch time; granting it afterwards would be too late. if ( \is_multisite() ) { \grant_super_admin( $editor_id ); } else { ( new \WP_User( $editor_id ) )->add_cap( 'unfiltered_html' ); } + \wp_set_current_user( $editor_id ); + $post = $this->create_recommendation_via_rest( 'Buy milk & eggs' ); $this->assertSame( 'Buy milk & eggs', $post->post_title ); From e9532e1e43a626c67327c3d08c049267435fecf5 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Fri, 29 May 2026 15:04:59 +0200 Subject: [PATCH 6/6] Bump composer/composer 2.9.2 -> 2.10.0 to clear dev-dependency CVEs Resolves the Security check failure: composer/composer 2.9.2 (pulled in transitively via wp-cli/wp-cli-bundle in require-dev) carried CVE-2026-40176, CVE-2026-40261, and CVE-2026-45793. Targeted `composer update composer/composer --with-dependencies`; composer.json (runtime deps) unchanged. `composer audit` now reports no advisories. Co-Authored-By: Claude Opus 4.7 (1M context) --- composer.lock | 210 +++++++++++++++++++++++++------------------------- 1 file changed, 103 insertions(+), 107 deletions(-) diff --git a/composer.lock b/composer.lock index 6db82b5a51..5447c8c5fa 100644 --- a/composer.lock +++ b/composer.lock @@ -245,16 +245,16 @@ }, { "name": "composer/ca-bundle", - "version": "1.5.10", + "version": "1.5.12", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "961a5e4056dd2e4a2eedcac7576075947c28bf63" + "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/961a5e4056dd2e4a2eedcac7576075947c28bf63", - "reference": "961a5e4056dd2e4a2eedcac7576075947c28bf63", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", + "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", "shasum": "" }, "require": { @@ -301,7 +301,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.5.10" + "source": "https://github.com/composer/ca-bundle/tree/1.5.12" }, "funding": [ { @@ -313,20 +313,20 @@ "type": "github" } ], - "time": "2025-12-08T15:06:51+00:00" + "time": "2026-05-19T11:26:22+00:00" }, { "name": "composer/class-map-generator", - "version": "1.7.0", + "version": "1.7.3", "source": { "type": "git", "url": "https://github.com/composer/class-map-generator.git", - "reference": "2373419b7709815ed323ebf18c3c72d03ff4a8a6" + "reference": "86d8208fc3c649a3a999daf1a63c25201be2990f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/class-map-generator/zipball/2373419b7709815ed323ebf18c3c72d03ff4a8a6", - "reference": "2373419b7709815ed323ebf18c3c72d03ff4a8a6", + "url": "https://api.github.com/repos/composer/class-map-generator/zipball/86d8208fc3c649a3a999daf1a63c25201be2990f", + "reference": "86d8208fc3c649a3a999daf1a63c25201be2990f", "shasum": "" }, "require": { @@ -370,7 +370,7 @@ ], "support": { "issues": "https://github.com/composer/class-map-generator/issues", - "source": "https://github.com/composer/class-map-generator/tree/1.7.0" + "source": "https://github.com/composer/class-map-generator/tree/1.7.3" }, "funding": [ { @@ -382,20 +382,20 @@ "type": "github" } ], - "time": "2025-11-19T10:41:15+00:00" + "time": "2026-05-05T09:17:07+00:00" }, { "name": "composer/composer", - "version": "2.9.2", + "version": "2.10.0", "source": { "type": "git", "url": "https://github.com/composer/composer.git", - "reference": "8d5358f147c63a3a681b002076deff8c90e0b19d" + "reference": "c13824d95608b15913a7c0def0a3dea4474b71fc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/8d5358f147c63a3a681b002076deff8c90e0b19d", - "reference": "8d5358f147c63a3a681b002076deff8c90e0b19d", + "url": "https://api.github.com/repos/composer/composer/zipball/c13824d95608b15913a7c0def0a3dea4474b71fc", + "reference": "c13824d95608b15913a7c0def0a3dea4474b71fc", "shasum": "" }, "require": { @@ -448,7 +448,7 @@ ] }, "branch-alias": { - "dev-main": "2.9-dev" + "dev-main": "2.10-dev" } }, "autoload": { @@ -483,7 +483,7 @@ "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/composer/issues", "security": "https://github.com/composer/composer/security/policy", - "source": "https://github.com/composer/composer/tree/2.9.2" + "source": "https://github.com/composer/composer/tree/2.10.0" }, "funding": [ { @@ -495,7 +495,7 @@ "type": "github" } ], - "time": "2025-11-19T20:57:25+00:00" + "time": "2026-05-28T09:22:08+00:00" }, { "name": "composer/metadata-minifier", @@ -724,24 +724,24 @@ }, { "name": "composer/spdx-licenses", - "version": "1.5.9", + "version": "1.6.0", "source": { "type": "git", "url": "https://github.com/composer/spdx-licenses.git", - "reference": "edf364cefe8c43501e21e88110aac10b284c3c9f" + "reference": "5ecd0cb4177696f9fd48f1605dda81db3dee7889" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/edf364cefe8c43501e21e88110aac10b284c3c9f", - "reference": "edf364cefe8c43501e21e88110aac10b284c3c9f", + "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/5ecd0cb4177696f9fd48f1605dda81db3dee7889", + "reference": "5ecd0cb4177696f9fd48f1605dda81db3dee7889", "shasum": "" }, "require": { - "php": "^5.3.2 || ^7.0 || ^8.0" + "php": "^7.2 || ^8.0" }, "require-dev": { "phpstan/phpstan": "^1.11", - "symfony/phpunit-bridge": "^3 || ^7" + "symfony/phpunit-bridge": "^6.4.25 || ^7.3.3 || ^8.0" }, "type": "library", "extra": { @@ -784,7 +784,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/spdx-licenses/issues", - "source": "https://github.com/composer/spdx-licenses/tree/1.5.9" + "source": "https://github.com/composer/spdx-licenses/tree/1.6.0" }, "funding": [ { @@ -794,13 +794,9 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2025-05-12T21:07:07+00:00" + "time": "2026-04-08T20:18:39+00:00" }, { "name": "composer/xdebug-handler", @@ -1512,16 +1508,16 @@ }, { "name": "justinrainbow/json-schema", - "version": "6.6.3", + "version": "6.8.2", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "134e98916fa2f663afa623970af345cd788e8967" + "reference": "2c89ebb95ca9cedc9347f780333f7b25792dcb76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/134e98916fa2f663afa623970af345cd788e8967", - "reference": "134e98916fa2f663afa623970af345cd788e8967", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/2c89ebb95ca9cedc9347f780333f7b25792dcb76", + "reference": "2c89ebb95ca9cedc9347f780333f7b25792dcb76", "shasum": "" }, "require": { @@ -1531,7 +1527,7 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "3.3.0", - "json-schema/json-schema-test-suite": "^23.2", + "json-schema/json-schema-test-suite": "dev-main", "marc-mabe/php-enum-phpstan": "^2.0", "phpspec/prophecy": "^1.19", "phpstan/phpstan": "^1.12", @@ -1581,9 +1577,9 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.6.3" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.8.2" }, - "time": "2025-12-02T10:21:33+00:00" + "time": "2026-05-05T05:39:01+00:00" }, { "name": "marc-mabe/php-enum", @@ -5317,16 +5313,16 @@ }, { "name": "symfony/console", - "version": "v7.4.0", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8" + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8", - "reference": "0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8", + "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217", + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217", "shasum": "" }, "require": { @@ -5391,7 +5387,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.0" + "source": "https://github.com/symfony/console/tree/v7.4.13" }, "funding": [ { @@ -5411,7 +5407,7 @@ "type": "tidelift" } ], - "time": "2025-11-27T13:27:24+00:00" + "time": "2026-05-24T08:56:14+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5647,16 +5643,16 @@ }, { "name": "symfony/filesystem", - "version": "v7.4.0", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "d551b38811096d0be9c4691d406991b47c0c630a" + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d551b38811096d0be9c4691d406991b47c0c630a", - "reference": "d551b38811096d0be9c4691d406991b47c0c630a", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d721ea61b4a5fba8c5b6e7c1feda19efea144b50", + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50", "shasum": "" }, "require": { @@ -5693,7 +5689,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.0" + "source": "https://github.com/symfony/filesystem/tree/v7.4.11" }, "funding": [ { @@ -5713,20 +5709,20 @@ "type": "tidelift" } ], - "time": "2025-11-27T13:27:24+00:00" + "time": "2026-05-11T16:38:44+00:00" }, { "name": "symfony/finder", - "version": "v7.4.0", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "340b9ed7320570f319028a2cbec46d40535e94bd" + "reference": "e0be088d22278583a82da281886e8c3592fbf149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/340b9ed7320570f319028a2cbec46d40535e94bd", - "reference": "340b9ed7320570f319028a2cbec46d40535e94bd", + "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", + "reference": "e0be088d22278583a82da281886e8c3592fbf149", "shasum": "" }, "require": { @@ -5761,7 +5757,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.0" + "source": "https://github.com/symfony/finder/tree/v7.4.8" }, "funding": [ { @@ -5781,7 +5777,7 @@ "type": "tidelift" } ], - "time": "2025-11-05T05:42:40+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/options-resolver", @@ -5939,16 +5935,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.33.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", "shasum": "" }, "require": { @@ -5997,7 +5993,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" }, "funding": [ { @@ -6017,20 +6013,20 @@ "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-05-26T05:58:03+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.33.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { @@ -6082,7 +6078,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -6102,20 +6098,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", "shasum": "" }, "require": { @@ -6167,7 +6163,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" }, "funding": [ { @@ -6187,11 +6183,11 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php73", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php73.git", @@ -6247,7 +6243,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php73/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php73/tree/v1.37.0" }, "funding": [ { @@ -6355,16 +6351,16 @@ }, { "name": "symfony/polyfill-php81", - "version": "v1.33.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", "shasum": "" }, "require": { @@ -6411,7 +6407,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1" }, "funding": [ { @@ -6431,20 +6427,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-26T12:45:58+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.33.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { @@ -6491,7 +6487,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -6511,20 +6507,20 @@ "type": "tidelift" } ], - "time": "2025-06-24T13:30:11+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/process", - "version": "v7.4.0", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8" + "reference": "f5804be144caceb570f6747519999636b664f24c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", - "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", + "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", + "reference": "f5804be144caceb570f6747519999636b664f24c", "shasum": "" }, "require": { @@ -6556,7 +6552,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.0" + "source": "https://github.com/symfony/process/tree/v7.4.13" }, "funding": [ { @@ -6576,20 +6572,20 @@ "type": "tidelift" } ], - "time": "2025-10-16T11:21:06+00:00" + "time": "2026-05-23T16:05:06+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "shasum": "" }, "require": { @@ -6607,7 +6603,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -6643,7 +6639,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" }, "funding": [ { @@ -6663,7 +6659,7 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-03-28T09:44:51+00:00" }, { "name": "symfony/stopwatch", @@ -6733,16 +6729,16 @@ }, { "name": "symfony/string", - "version": "v7.4.0", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003" + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/d50e862cb0a0e0886f73ca1f31b865efbb795003", - "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003", + "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", "shasum": "" }, "require": { @@ -6800,7 +6796,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.0" + "source": "https://github.com/symfony/string/tree/v7.4.13" }, "funding": [ { @@ -6820,7 +6816,7 @@ "type": "tidelift" } ], - "time": "2025-11-27T13:27:24+00:00" + "time": "2026-05-23T15:23:29+00:00" }, { "name": "szepeviktor/phpstan-wordpress",