From e8a9f0b747b3034a59da6e1a8debe9fc289d778f Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Mon, 20 Jul 2026 13:23:52 -0500 Subject: [PATCH 1/3] Plugin Directory: Fix the test environment so DB and REST tests can run. The test environment was missing three things the development environment already had, which between them made whole classes of test impossible: - `update_source` was never created, so anything touching the update API's table failed. - `wp-content/env-bin` was not mapped, so `database-tables.sql` was not reachable from the test container to import. - `PLUGINS_TABLE_PREFIX` was undefined, so `API\Base::load_routes()` fatalled and no `plugins/v1` route was registered. Shared verbatim with #717; whichever of the two PRs lands second should drop this commit. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UNnLdnE7etihGNvsWY1qGE --- environments/plugin-directory/.wp-env.test.json | 6 ++++++ environments/plugin-directory/bin/after-start-test.sh | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/environments/plugin-directory/.wp-env.test.json b/environments/plugin-directory/.wp-env.test.json index 6bffb7da9c..145db9cad1 100644 --- a/environments/plugin-directory/.wp-env.test.json +++ b/environments/plugin-directory/.wp-env.test.json @@ -4,7 +4,13 @@ "plugins": [ "../wordpress.org/public_html/wp-content/plugins/plugin-directory" ], + "mappings": { + "wp-content/env-bin": "./plugin-directory/bin" + }, "lifecycleScripts": { "afterStart": "bash plugin-directory/bin/after-start-test.sh" + }, + "config": { + "PLUGINS_TABLE_PREFIX": "wp_" } } diff --git a/environments/plugin-directory/bin/after-start-test.sh b/environments/plugin-directory/bin/after-start-test.sh index 13d416d04a..b68d50afcb 100755 --- a/environments/plugin-directory/bin/after-start-test.sh +++ b/environments/plugin-directory/bin/after-start-test.sh @@ -1,7 +1,7 @@ #!/bin/bash # # Runs after wp-env start for the test environment. -# Installs PHPUnit 11 and Yoast polyfills in the test container. +# Installs PHPUnit 11 and Yoast polyfills, and creates the stub tables tests read. # CONFIG="--config plugin-directory/.wp-env.test.json" @@ -10,3 +10,7 @@ RUN="npx wp-env $CONFIG run tests-cli" echo "Installing PHPUnit 11 and polyfills..." $RUN composer global require -W phpunit/phpunit:^11.0 2>&1 $RUN composer require --dev yoast/phpunit-polyfills:^4.0 --working-dir=/wordpress-phpunit 2>&1 + +# Create stub database tables that exist outside WordPress on production. +echo "Creating stub database tables..." +$RUN -- wp db import wp-content/env-bin/database-tables.sql From 3ee59102d3969608c5a1878385bf98cc62be9b3c Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Mon, 20 Jul 2026 13:24:13 -0500 Subject: [PATCH 2/3] Plugin Directory: Block a release when a security scan reports a high risk score. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Gandalf scan callback reports a `risk_score` at or above Plugin_Scan_Gandalf::RISK_SCORE_BLOCK_THRESHOLD (8) for a version still inside its release cooldown, that version is held out of the `update_source` table indefinitely — the previously served version keeps being served — until a reviewer force-releases it. - The hold is recorded on the release row as `release_block`; a single gate in API_Update_Updater::update_single_plugin() honours it, so both the deferred cooldown cron and the backup reconciliation cron keep the version back. - Blocking only applies while the version is still within its cooldown. A verdict for a version that is already live, was superseded by a newer commit, or had no delay can't hold anything, and is escalated to Slack for manual action instead. - A reviewer force-release clears the block and serves the version; the override is recorded in the audit log. - `risk_score` is read defensively, so scans that don't send one — every scan until Gandalf starts to — keep today's behaviour. The two scan Slack notifications (findings and block) now share one sender, and their user-facing wording says "security scan" rather than naming the scanner. Covered by a direct handler test (Gandalf_Risk_Score_Block_Test) and a full-chain REST-callback test built on a shared Gandalf_Callback_Test_Case base. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UNnLdnE7etihGNvsWY1qGE --- phpcs.xml.dist | 10 + .../admin/metabox/class-controls.php | 43 +- .../class-plugin-directory.php | 9 + .../jobs/class-api-update-updater.php | 75 +++- .../jobs/class-plugin-scan-gandalf.php | 196 +++++++-- .../plugins/plugin-directory/phpunit.xml | 1 + .../tests/Gandalf_Callback_Test_Case.php | 299 ++++++++++++++ ...Gandalf_Risk_Score_Block_Callback_Test.php | 119 ++++++ .../tests/Gandalf_Risk_Score_Block_Test.php | 384 ++++++++++++++++++ .../plugin-directory/tests/bootstrap.php | 5 + 10 files changed, 1077 insertions(+), 64 deletions(-) create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Callback_Test_Case.php create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Risk_Score_Block_Callback_Test.php create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Risk_Score_Block_Test.php diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 447c8ac037..5960c0ab05 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -138,4 +138,14 @@ + + + + + + + + + diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php index 3d85569f28..51e51ed934 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php @@ -46,11 +46,11 @@ static function display() { } /** - * Display the release cooldown status and (for reviewers) a force-release control. + * Display the release hold status and (for reviewers) a force-release control. * - * Bails when there's no current release to gate, when the release has no cooldown - * delay (feature off at release-creation, or already force-released), or when the - * cooldown window has elapsed. + * Shows one of two messages: a countdown while a release is cooling down, or a block + * notice when a Gandalf scan is holding it (which outlasts the cooldown window). Bails + * when there's no current release, or when it's neither held nor still cooling down. */ protected static function display_release_cooldown() { $post = get_post(); @@ -65,13 +65,13 @@ protected static function display_release_cooldown() { return; } - $release_delay = (int) ( $release['release_delay'] ?? 0 ); - if ( ! $release_delay ) { - return; - } - + $blocked = API_Update_Updater::is_release_blocked( $release ); + $release_delay = (int) ( $release['release_delay'] ?? 0 ); $cooldown_until = API_Update_Updater::compute_release_time( $post, $release ) + $release_delay; - if ( $cooldown_until <= time() ) { + $in_cooldown = $release_delay && $cooldown_until > time(); + + // Nothing to surface unless the release is held or still cooling down. + if ( ! $blocked && ! $in_cooldown ) { return; } @@ -79,13 +79,22 @@ protected static function display_release_cooldown() {

diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php index 105c678dfc..d889980d83 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php @@ -1754,6 +1754,15 @@ public static function add_release( $plugin, $data ) { unset( $release['discarded'] ); } + /* + * Clear a high-risk Gandalf block so the release can be served. + * See Jobs\API_Update_Updater::force_release(). + */ + if ( ! empty( $data['unblock'] ) ) { + unset( $release['release_block'] ); + } + unset( $release['unblock'] ); + $releases = self::get_releases( $plugin ); // Find any other releases using this slug (as in the case of updates) and remove it. diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php index f24821258a..c884788849 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php @@ -86,15 +86,16 @@ public static function update_single_plugin( $plugin_slug ) { $requires_plugins = get_post_meta( $post->ID, 'requires_plugins', true ); $release = Plugin_Directory::get_release( $post, $version ); $release_time = self::compute_release_time( $post, $release ); - $existing_version = (string) $wpdb->get_var( - $wpdb->prepare( - "SELECT version FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s", - $post->post_name - ) - ); + $existing_version = self::get_served_version( $post->post_name ); $release_delay = (int) ( $release['release_delay'] ?? 0 ); + if ( self::is_release_blocked( $release ) && $existing_version !== (string) $version ) { + wp_clear_scheduled_hook( "release_to_update_api:{$post->post_name}" ); + + return true; + } + /* * Defer the write for new versions still inside the cooldown window. While * deferred, the existing `update_source` row (carrying the previous version) @@ -198,7 +199,7 @@ public static function update_single_plugin( $plugin_slug ) { // Sync the latest version to Stats. if ( function_exists( '\WordPressdotorg\Stats\sync_latest_version' ) ) { \WordPressdotorg\Stats\sync_latest_version( - 'plugin', + 'plugin', array( $plugin_slug => $version ) @@ -208,6 +209,33 @@ public static function update_single_plugin( $plugin_slug ) { return true; } + /** + * The version currently served from `update_source`. + * + * @param string $plugin_slug The plugin slug. + * @return string The served version, or '' when the plugin isn't in `update_source`. + */ + public static function get_served_version( $plugin_slug ) { + global $wpdb; + + return (string) $wpdb->get_var( + $wpdb->prepare( + "SELECT version FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s", + $plugin_slug + ) + ); + } + + /** + * Whether a release is blocked by a high-risk Gandalf scan. + * + * @param array|bool $release The release row from Plugin_Directory::get_release(), or false. + * @return bool True when the release is being held out of `update_source`. + */ + public static function is_release_blocked( $release ) { + return is_array( $release ) && ! empty( $release['release_block'] ); + } + /** * Determine the release timestamp for a plugin version. * @@ -283,21 +311,36 @@ public static function force_release( $plugin_slug, $reason, $user = null ) { return false; } - Tools::audit_log( - sprintf( - 'Force-released version %s, bypassing the %d-hour release cooldown. Reason: %s', - $version, - (int) ( $release['release_delay'] ?? 0 ) / HOUR_IN_SECONDS, - $reason - ), - $post - ); + // A force-release also overrides a high-risk Gandalf block; note that in the audit trail. + if ( self::is_release_blocked( $release ) ) { + Tools::audit_log( + sprintf( + 'Force-released version %1$s, overriding the security-scan block (risk score %2$s). Reason: %3$s', + $version, + $release['release_block']['risk_score'] ?? '?', + $reason + ), + $post + ); + } else { + Tools::audit_log( + sprintf( + 'Force-released version %s, bypassing the %d-hour release cooldown. Reason: %s', + $version, + (int) ( $release['release_delay'] ?? 0 ) / HOUR_IN_SECONDS, + $reason + ), + $post + ); + } Plugin_Directory::add_release( $post, array( 'tag' => $release['tag'], 'release_delay' => 0, + // Clear any Gandalf block so update_single_plugin() serves the version. + 'unblock' => true, ) ); diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-scan-gandalf.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-scan-gandalf.php index aacb3d77af..190a504a74 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-scan-gandalf.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-scan-gandalf.php @@ -7,7 +7,9 @@ namespace WordPressdotorg\Plugin_Directory\Jobs; +use WordPressdotorg\Plugin_Directory\Plugin_Directory; use WordPressdotorg\Plugin_Directory\Template; +use WordPressdotorg\Plugin_Directory\Tools; use WP_Error; use WP_Http; @@ -30,6 +32,9 @@ class Plugin_Scan_Gandalf { /** Gandalf scan endpoint. */ const ENDPOINT = 'https://gandalf.wordpress.org/scan'; + /** Risk score at or above which a completed scan blocks the release from being served. */ + const RISK_SCORE_BLOCK_THRESHOLD = 8; + /** * Dispatch a Gandalf scan from the importer context carried through cron. * @@ -178,8 +183,27 @@ public static function handle_callback( $plugin, $data ) { } if ( 'completed' === $data['status'] ) { - if ( $data['findings_count'] > 0 ) { - self::notify_slack( + /* + * Precedence: a high enough risk score blocks the release outright; otherwise findings + * raise the usual advisory alert. `risk_score` is read defensively because the callback + * route doesn't validate it, and because Gandalf doesn't send it yet. + */ + $risk_score = ( isset( $data['risk_score'] ) && is_numeric( $data['risk_score'] ) ) ? (float) $data['risk_score'] : null; + + if ( null !== $risk_score && $risk_score >= self::RISK_SCORE_BLOCK_THRESHOLD ) { + $held = self::block_release( $plugin, $pending_record, $scan_id, $risk_score ); + self::notify_slack_blocked( + $plugin, + [ + 'version' => $pending_record['version'], + 'release_ref' => $pending_record['release_ref'], + 'risk_score' => $risk_score, + 'held' => $held, + 'report_url' => $data['report_url'] ?? '', + ] + ); + } elseif ( $data['findings_count'] > 0 ) { + self::notify_slack_findings( $plugin, [ 'version' => $pending_record['version'], @@ -201,6 +225,65 @@ public static function handle_callback( $plugin, $data ) { return true; } + /** + * Record a block on the scanned release and re-run the update to hold the version back. + * + * @param \WP_Post $plugin The plugin post. + * @param array $pending_record The pending scan record (version, release_ref). + * @param string $scan_id The Gandalf scan ID. + * @param float $risk_score The reported risk score. + * @return bool True when the release was held, false when there was nothing to hold. + */ + protected static function block_release( $plugin, $pending_record, $scan_id, $risk_score ) { + $version = (string) $pending_record['version']; + + // A newer release landed since this scan was dispatched; the scanned version is moot. + if ( (string) get_post_meta( $plugin->ID, 'version', true ) !== $version ) { + return false; + } + + $release = Plugin_Directory::get_release( $plugin, $version ); + if ( ! $release ) { + return false; + } + + // No cooldown was captured at release creation, so the version was served at import. + if ( empty( $release['release_delay'] ) ) { + return false; + } + + // Already live: the cooldown elapsed before this verdict arrived. + if ( API_Update_Updater::get_served_version( $plugin->post_name ) === $version ) { + return false; + } + + Plugin_Directory::add_release( + $plugin, + [ + 'tag' => $release['tag'], + 'release_block' => [ + 'scan_id' => $scan_id, + 'risk_score' => $risk_score, + 'blocked_at' => time(), + ], + ] + ); + + // Re-run so a version scheduled to serve at cooldown-end is held now instead. + API_Update_Updater::update_single_plugin( $plugin->post_name ); + + Tools::audit_log( + sprintf( + 'A security scan blocked version %1$s from being served (risk score %2$s).', + $version, + $risk_score + ), + $plugin + ); + + return true; + } + /** * Record a valid-secret callback that failed validation. * @@ -236,12 +319,16 @@ protected static function dispatch_failed( $plugin, $request_data, $message, $ki } /** - * Notify Slack about a Gandalf scan with findings. + * Alert Slack about a scan that reported findings. + * + * Dedupes on the verdict hash so an unchanged result isn't reported twice, and is skipped + * when no hash is present, since there's nothing to dedupe on. * * @param \WP_Post $plugin The plugin post. - * @param array $record The completed scan summary. + * @param array $record 'version', 'release_ref', 'findings_count', 'severity_counts', + * 'verdict_hash', 'report_url'. */ - protected static function notify_slack( $plugin, $record ) { + protected static function notify_slack_findings( $plugin, $record ) { if ( empty( $record['verdict_hash'] ) ) { return; } @@ -261,46 +348,93 @@ protected static function notify_slack( $plugin, $record ) { $already_notified[ $record['verdict_hash'] ] = time(); update_post_meta( $plugin->ID, self::NOTIFIED_META_KEY, $already_notified ); - if ( ! defined( 'PLUGIN_REVIEW_ALERT_SLACK_CHANNEL' ) || ! function_exists( 'slack_dm' ) ) { - return; - } + $detail = [ sprintf( 'Findings: %d', $record['findings_count'] ) ]; - $active_installs = (int) get_post_meta( $plugin->ID, 'active_installs', true ); - $install_line = sprintf( '%s+ active installs', number_format_i18n( $active_installs ) ); - if ( $active_installs >= 10000 ) { - $install_line = ":bangbang::bangbang::bangbang: {$install_line} :bangbang::bangbang::bangbang:"; + $severity_summary = []; + foreach ( (array) ( $record['severity_counts'] ?? [] ) as $severity => $count ) { + if ( $count > 0 ) { + $severity_summary[] = "{$severity}: {$count}"; + } + } + if ( $severity_summary ) { + $detail[] = 'Severity: ' . implode( ', ', $severity_summary ); } - $title = $plugin->post_title; - if ( 'closed' === $plugin->post_status ) { - $title .= ' (closed)'; + self::send_slack_alert( + $plugin, + 'A security scan detected findings in *%s*', + $record['version'], + $record['release_ref'], + $detail, + $record['report_url'] + ); + } + + /** + * Alert Slack about a high-risk verdict. Always sends: a block needs a human either way. + * + * @param \WP_Post $plugin The plugin post. + * @param array $record 'version', 'release_ref', 'risk_score', 'report_url', and 'held' + * (whether the release was held, or was already live). + */ + protected static function notify_slack_blocked( $plugin, $record ) { + if ( ! empty( $record['held'] ) ) { + $headline = 'A security scan *blocked* a release of *%s*'; + $status = 'Held out of the update API until a reviewer force-releases it.'; + } else { + $headline = 'A security scan flagged an *already-served* release of *%s*'; + $status = 'The release delay had already elapsed, so this version is live. Manual action (close or roll back) may be required.'; } - $body = sprintf( - "Gandalf scan detected findings in *%s*\n%s\nVersion: %s (%s)\nFindings: %d\n", - $title, - $install_line, + self::send_slack_alert( + $plugin, + $headline, $record['version'], $record['release_ref'], - $record['findings_count'] + [ + sprintf( 'Risk score: %s (blocks at %s)', $record['risk_score'], self::RISK_SCORE_BLOCK_THRESHOLD ), + $status, + ], + $record['report_url'] ); + } - if ( ! empty( $record['severity_counts'] ) ) { - $severity_summary = []; - foreach ( $record['severity_counts'] as $severity => $count ) { - if ( $count > 0 ) { - $severity_summary[] = "{$severity}: {$count}"; - } - } + /** + * Send a plugin-review Slack alert: the shared envelope for the scan notifications — the + * plugin title, active-install count, version line, and links. + * + * @param \WP_Post $plugin The plugin post. + * @param string $headline A sprintf format with a single %s for the plugin title. + * @param string $version The scanned version. + * @param string $release_ref The scanned release ref. + * @param string[] $detail Lines describing the result, placed after the version line. + * @param string $report_url Link to the scan report, or '' when there isn't one. + */ + private static function send_slack_alert( $plugin, $headline, $version, $release_ref, $detail, $report_url ) { + if ( ! defined( 'PLUGIN_REVIEW_ALERT_SLACK_CHANNEL' ) || ! function_exists( 'slack_dm' ) ) { + return; + } - if ( $severity_summary ) { - $body .= 'Severity: ' . implode( ', ', $severity_summary ) . "\n"; - } + $title = $plugin->post_title; + if ( 'closed' === $plugin->post_status ) { + $title .= ' (closed)'; } + $active_installs = (int) get_post_meta( $plugin->ID, 'active_installs', true ); + $install_line = sprintf( '%s+ active installs', number_format_i18n( $active_installs ) ); + if ( $active_installs >= 10000 ) { + $install_line = ":warning: {$install_line}"; + } + + $body = sprintf( $headline, $title ) . "\n"; + $body .= $install_line . "\n"; + $body .= sprintf( "Version: %s (%s)\n", $version, $release_ref ); + $body .= implode( "\n", $detail ) . "\n"; $body .= sprintf( "Details: https://wordpress.org/plugins/wp-admin/post.php?post=%s&action=edit\n", $plugin->ID ); $body .= sprintf( "Plugin: https://wordpress.org/plugins/%s/\n", $plugin->post_name ); - $body .= sprintf( "Report: %s\n", $record['report_url'] ); + if ( ! empty( $report_url ) ) { + $body .= sprintf( "Report: %s\n", $report_url ); + } slack_dm( $body, PLUGIN_REVIEW_ALERT_SLACK_CHANNEL, true ); } diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/phpunit.xml b/wordpress.org/public_html/wp-content/plugins/plugin-directory/phpunit.xml index dfcbdaedc0..607da030f6 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/phpunit.xml +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/phpunit.xml @@ -7,6 +7,7 @@ tests/ tests/bootstrap.php + tests/Gandalf_Callback_Test_Case.php tests/wporg-url-schemes.php tests/wporg-plugin-api.php tests/wporg-plugin-api-performance.php diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Callback_Test_Case.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Callback_Test_Case.php new file mode 100644 index 0000000000..eae4e72155 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Callback_Test_Case.php @@ -0,0 +1,299 @@ + 'plugin', + 'post_name' => static::SLUG, + 'post_title' => 'Gandalf Callback Test', + 'post_status' => 'publish', + 'post_modified' => current_time( 'mysql' ), + 'post_modified_gmt' => current_time( 'mysql', 1 ), + ), + true + ); + + $this->assertNotInstanceOf( WP_Error::class, $plugin_id ); + + $this->plugin = get_post( $plugin_id ); + + update_post_meta( $plugin_id, 'version', static::NEW_VERSION ); + update_post_meta( $plugin_id, 'stable_tag', static::NEW_VERSION ); + update_post_meta( $plugin_id, 'header_name', 'Gandalf Callback Test' ); + update_post_meta( $plugin_id, 'header_author', 'WordPress' ); + update_post_meta( $plugin_id, 'version_date', current_time( 'mysql', 1 ) ); + + update_post_meta( $plugin_id, 'releases', array( $this->release() ) ); + + $this->set_pending_scan(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress; there is no API for it. + $wpdb->query( "TRUNCATE TABLE `{$wpdb->prefix}update_source`" ); + $this->serve( static::SERVED_VERSION ); + } + + /** + * Remove the plugin, its meta, any audit-log notes, and any deferred cron event. + */ + protected function tearDown(): void { + wp_clear_scheduled_hook( 'release_to_update_api:' . static::SLUG ); + + foreach ( get_comments( array( 'post_id' => $this->plugin->ID ) ) as $note ) { + wp_delete_comment( $note->comment_ID, true ); + } + + wp_delete_post( $this->plugin->ID, true ); + + parent::tearDown(); + } + + /** + * The release row seeded onto the plugin: NEW_VERSION, confirmed, inside its delay. + * + * @param array $overrides Values to override on the default release. + * @return array + */ + protected function release( $overrides = array() ) { + return array_merge( + array( + 'date' => time(), + 'tag' => static::NEW_VERSION, + 'version' => static::NEW_VERSION, + 'zips_built' => true, + 'confirmations' => array(), + 'confirmed' => true, + 'confirmations_required' => 0, + 'committer' => array(), + 'revision' => array(), + 'release_delay' => static::DELAY, + ), + $overrides + ); + } + + /** + * Record the in-flight scan the callback is correlated against. + */ + protected function set_pending_scan() { + update_post_meta( + $this->plugin->ID, + Plugin_Scan_Gandalf::PENDING_META_KEY, + array( + static::SCAN_ID => array( + 'version' => static::NEW_VERSION, + 'release_ref' => static::NEW_VERSION, + 'requested_at' => time(), + ), + ) + ); + } + + /** + * A well-formed completed-scan body, as Gandalf would send it. The scanned version has no + * findings; a verdict field like risk_score is added per-test via $overrides. + * + * @param array $overrides Values to override. + * @return array + */ + protected function callback_body( $overrides = array() ) { + return array_merge( + array( + 'scan_id' => static::SCAN_ID, + 'status' => 'completed', + 'version' => static::NEW_VERSION, + 'release_ref' => static::NEW_VERSION, + 'findings_count' => 0, + 'severity_counts' => array(), + 'verdict_hash' => 'abc123', + 'report_url' => 'https://gandalf.wordpress.org/report/abc123', + ), + $overrides + ); + } + + /** + * POST a callback to the route, as Gandalf would. + * + * @param array $body The callback body. + * @param string|null $secret The bearer secret, or null to send no Authorization header. + * @return \WP_REST_Response + */ + protected function post_callback( $body, $secret = null ) { + if ( null === $secret ) { + $secret = static::SECRET; + } + + $request = new WP_REST_Request( 'POST', '/plugins/v1/plugin/' . static::SLUG . '/gandalf-scan' ); + + if ( false !== $secret ) { + $request->set_header( 'authorization', 'Bearer ' . $secret ); + } + + $request->set_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $body ) ); + + return rest_do_request( $request ); + } + + /** + * Put a version into `update_source`, standing in for the currently-served release. + * + * @param string $version The version to serve. + */ + protected function serve( $version ) { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress; there is no API for it. + $wpdb->insert( + $wpdb->prefix . 'update_source', + array( + 'plugin_id' => $this->plugin->ID, + 'plugin_slug' => static::SLUG, + 'available' => 1, + 'version' => $version, + 'last_updated' => current_time( 'mysql' ), + ) + ); + } + + /** + * The version currently served from `update_source`. + * + * @return string|null + */ + protected function get_served_version() { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress, and a cached read would defeat the assertion. + return $wpdb->get_var( + $wpdb->prepare( + "SELECT `version` FROM `{$wpdb->prefix}update_source` WHERE `plugin_slug` = %s", + static::SLUG + ) + ); + } + + /** + * The pending scans still awaiting a callback. + * + * @return array + */ + protected function get_pending_scans() { + $pending = get_post_meta( $this->plugin->ID, Plugin_Scan_Gandalf::PENDING_META_KEY, true ); + + return $pending ? $pending : array(); + } + + /** + * A callback that authenticated and matched its pending scan is processed and no longer in flight. + */ + public function test_a_processed_callback_clears_the_pending_scan() { + $response = $this->post_callback( $this->callback_body() ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( array(), $this->get_pending_scans() ); + } + + /** + * The route is authenticated: a callback without the shared secret changes nothing. + */ + public function test_a_callback_without_the_shared_secret_is_rejected() { + $response = $this->post_callback( $this->callback_body(), false ); + + $this->assertSame( 401, $response->get_status() ); + $this->assertSame( static::SERVED_VERSION, $this->get_served_version() ); + } + + /** + * The secret is compared, not merely required. + */ + public function test_a_callback_with_the_wrong_shared_secret_is_rejected() { + $response = $this->post_callback( $this->callback_body(), 'not-the-secret' ); + + $this->assertSame( 401, $response->get_status() ); + $this->assertSame( static::SERVED_VERSION, $this->get_served_version() ); + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Risk_Score_Block_Callback_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Risk_Score_Block_Callback_Test.php new file mode 100644 index 0000000000..042e2ee83c --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Risk_Score_Block_Callback_Test.php @@ -0,0 +1,119 @@ +plugin->ID, 'releases', true ) as $release ) { + if ( static::NEW_VERSION === $release['tag'] ) { + return $release['release_block'] ?? null; + } + } + + return null; + } + + /** + * The headline behaviour: Gandalf reports a blocking risk score over the API, and the + * version it scanned is held back — the previous one keeps being served — and recorded. + */ + public function test_a_blocking_score_over_the_api_holds_the_version() { + $response = $this->post_callback( $this->callback_body( array( 'risk_score' => Plugin_Scan_Gandalf::RISK_SCORE_BLOCK_THRESHOLD ) ) ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( static::SERVED_VERSION, $this->get_served_version() ); + $this->assertSame( (float) Plugin_Scan_Gandalf::RISK_SCORE_BLOCK_THRESHOLD, $this->get_release_block()['risk_score'] ); + $this->assertSame( static::SCAN_ID, $this->get_release_block()['scan_id'] ); + } + + /** + * The block outlasts the cooldown: once the delay elapses, the reconciliation run that + * would normally serve the new version still leaves the old one in place. + */ + public function test_a_held_version_is_not_served_when_its_delay_later_elapses() { + $this->post_callback( $this->callback_body( array( 'risk_score' => 9 ) ) ); + + // Age the commit past the delay; without the block this run would serve NEW_VERSION. + update_post_meta( $this->plugin->ID, 'version_date', gmdate( 'Y-m-d H:i:s', time() - ( static::DELAY * 2 ) ) ); + API_Update_Updater::update_single_plugin( static::SLUG ); + + $this->assertSame( static::SERVED_VERSION, $this->get_served_version() ); + } + + /** + * A score below the threshold is not a block; the release is left to its normal delay. + */ + public function test_a_score_below_the_threshold_over_the_api_does_not_block() { + $response = $this->post_callback( $this->callback_body( array( 'risk_score' => Plugin_Scan_Gandalf::RISK_SCORE_BLOCK_THRESHOLD - 1 ) ) ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNull( $this->get_release_block() ); + } + + /** + * An absent risk_score — every scan until Gandalf sends one — never blocks. + */ + public function test_an_absent_risk_score_over_the_api_does_not_block() { + $response = $this->post_callback( $this->callback_body() ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNull( $this->get_release_block() ); + } + + /** + * A blocking score for a version that is already live can't un-ship it: `update_source` + * is left untouched and nothing is held. + */ + public function test_a_blocking_score_for_an_already_served_version_is_left_untouched() { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress; there is no API for it. + $wpdb->update( + $wpdb->prefix . 'update_source', + array( 'version' => static::NEW_VERSION ), + array( 'plugin_slug' => static::SLUG ) + ); + + $response = $this->post_callback( $this->callback_body( array( 'risk_score' => 9 ) ) ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( static::NEW_VERSION, $this->get_served_version() ); + $this->assertNull( $this->get_release_block() ); + } + + /** + * A reviewer force-release closes the loop: a version blocked over the API is served, and + * the block cleared. + */ + public function test_force_release_after_an_api_block_serves_the_version() { + $this->post_callback( $this->callback_body( array( 'risk_score' => 9 ) ) ); + + $result = API_Update_Updater::force_release( static::SLUG, 'Reviewed the scan; false positive.' ); + + $this->assertTrue( $result ); + $this->assertNull( $this->get_release_block() ); + $this->assertSame( static::NEW_VERSION, $this->get_served_version() ); + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Risk_Score_Block_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Risk_Score_Block_Test.php new file mode 100644 index 0000000000..087ef4b23c --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Risk_Score_Block_Test.php @@ -0,0 +1,384 @@ + 'plugin', + 'post_name' => self::SLUG, + 'post_title' => 'Gandalf Block Test', + 'post_status' => 'publish', + 'post_modified' => current_time( 'mysql' ), + 'post_modified_gmt' => current_time( 'mysql', 1 ), + ), + true + ); + + $this->assertNotInstanceOf( WP_Error::class, $plugin_id ); + + $this->plugin = get_post( $plugin_id ); + + update_post_meta( $plugin_id, 'version', self::NEW_VERSION ); + update_post_meta( $plugin_id, 'stable_tag', self::NEW_VERSION ); + update_post_meta( $plugin_id, 'header_name', 'Gandalf Block Test' ); + update_post_meta( $plugin_id, 'header_author', 'WordPress' ); + update_post_meta( $plugin_id, 'version_date', gmdate( 'Y-m-d H:i:s', time() ) ); + + $this->set_releases( array( $this->release() ) ); + $this->set_pending_scan( self::NEW_VERSION ); + + $wpdb->query( "TRUNCATE TABLE `{$wpdb->prefix}update_source`" ); + $this->serve( self::SERVED_VERSION ); + } + + /** + * Remove the plugin, its meta, any audit-log notes, and any deferred cron event. There's + * no transaction to roll back without WP_UnitTestCase, so state would otherwise leak. + */ + protected function tearDown(): void { + wp_clear_scheduled_hook( 'release_to_update_api:' . self::SLUG ); + + foreach ( get_comments( array( 'post_id' => $this->plugin->ID ) ) as $note ) { + wp_delete_comment( $note->comment_ID, true ); + } + + wp_delete_post( $this->plugin->ID, true ); + + parent::tearDown(); + } + + /** + * A complete release row. get_releases() reads keys beyond the ones under test. + * + * @param array $overrides Values to override on the default release. + * @return array + */ + protected function release( $overrides = array() ) { + return array_merge( + array( + 'date' => time(), + 'tag' => self::NEW_VERSION, + 'version' => self::NEW_VERSION, + 'zips_built' => true, + 'confirmations' => array(), + 'confirmed' => true, + 'confirmations_required' => 0, + 'committer' => array(), + 'revision' => array(), + 'release_delay' => self::DELAY, + ), + $overrides + ); + } + + /** + * Seed the releases meta directly: get_releases() otherwise falls back to + * prefill_releases_meta(), which reaches out to SVN. + * + * @param array $releases The releases to store. + */ + protected function set_releases( $releases ) { + update_post_meta( $this->plugin->ID, 'releases', $releases ); + } + + /** + * Record a pending Gandalf scan so handle_callback() recognizes the callback. + * + * @param string $version The version being scanned. + */ + protected function set_pending_scan( $version ) { + update_post_meta( + $this->plugin->ID, + Plugin_Scan_Gandalf::PENDING_META_KEY, + array( + self::SCAN_ID => array( + 'version' => $version, + 'release_ref' => $version, + 'requested_at' => time(), + ), + ) + ); + } + + /** + * Build a completed-scan callback payload for version 2.0. + * + * @param array $overrides Values to override on the default payload. + * @return array + */ + protected function callback_data( $overrides = array() ) { + return array_merge( + array( + 'scan_id' => self::SCAN_ID, + 'version' => self::NEW_VERSION, + 'release_ref' => self::NEW_VERSION, + 'status' => 'completed', + 'findings_count' => 0, + 'severity_counts' => array(), + 'verdict_hash' => 'hash', + 'report_url' => 'https://gandalf.wordpress.org/report/abc', + ), + $overrides + ); + } + + /** + * Put a version into `update_source`, standing in for the currently-served release. + * + * @param string $version The version to serve. + */ + protected function serve( $version ) { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress; there is no API for it. + $wpdb->insert( + $wpdb->prefix . 'update_source', + array( + 'plugin_id' => $this->plugin->ID, + 'plugin_slug' => self::SLUG, + 'available' => 1, + 'version' => $version, + 'last_updated' => current_time( 'mysql' ), + ) + ); + } + + /** + * The version currently served from `update_source`. + * + * @return string|null + */ + protected function get_served_version() { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress, and a cached read would defeat the assertion. + return $wpdb->get_var( + $wpdb->prepare( + "SELECT `version` FROM `{$wpdb->prefix}update_source` WHERE `plugin_slug` = %s", + self::SLUG + ) + ); + } + + /** + * The block recorded against the current release, if any. + * + * @param string $tag The release tag. + * @return array|null The `release_block` value, or null when the release isn't held. + */ + protected function get_release_block( $tag = self::NEW_VERSION ) { + foreach ( (array) get_post_meta( $this->plugin->ID, 'releases', true ) as $release ) { + if ( $tag === $release['tag'] ) { + return $release['release_block'] ?? null; + } + } + + return null; + } + + /** + * A score at the threshold holds the version: the previous one keeps being served, + * the block is recorded, and any deferred serve is cancelled. + */ + public function test_a_blocking_score_holds_the_version() { + Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->callback_data( array( 'risk_score' => 8 ) ) ); + + $this->assertSame( self::SERVED_VERSION, $this->get_served_version() ); + $this->assertSame( 8.0, $this->get_release_block()['risk_score'] ); + $this->assertSame( self::SCAN_ID, $this->get_release_block()['scan_id'] ); + $this->assertFalse( wp_next_scheduled( 'release_to_update_api:' . self::SLUG ) ); + } + + /** + * A score above the threshold blocks just the same. + */ + public function test_a_score_above_the_threshold_holds_the_version() { + Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->callback_data( array( 'risk_score' => 9.5 ) ) ); + + $this->assertSame( self::SERVED_VERSION, $this->get_served_version() ); + $this->assertNotNull( $this->get_release_block() ); + } + + /** + * A score below the threshold is not a block; the release follows its normal delay. + */ + public function test_a_score_below_the_threshold_does_not_block() { + Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->callback_data( array( 'risk_score' => 7.9 ) ) ); + + $this->assertNull( $this->get_release_block() ); + } + + /** + * An absent risk_score — every scan until Gandalf sends one — never blocks. + */ + public function test_an_absent_risk_score_does_not_block() { + Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->callback_data() ); + + $this->assertNull( $this->get_release_block() ); + } + + /** + * A non-numeric risk_score is ignored rather than read as a block. + */ + public function test_a_non_numeric_risk_score_does_not_block() { + Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->callback_data( array( 'risk_score' => 'high' ) ) ); + + $this->assertNull( $this->get_release_block() ); + } + + /** + * A reviewer force-release overrides the block: the held version is served and the block cleared. + */ + public function test_force_release_clears_the_block_and_serves() { + Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->callback_data( array( 'risk_score' => 8 ) ) ); + + $result = API_Update_Updater::force_release( self::SLUG, 'Reviewed the scan; false positive.' ); + + $this->assertTrue( $result ); + $this->assertNull( $this->get_release_block() ); + $this->assertSame( self::NEW_VERSION, $this->get_served_version() ); + } + + /** + * The override is recorded, naming the block it bypassed. + */ + public function test_force_release_records_the_override_in_the_audit_log() { + Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->callback_data( array( 'risk_score' => 8 ) ) ); + + API_Update_Updater::force_release( self::SLUG, 'Reviewed the scan; false positive.' ); + + $notes = get_comments( + array( + 'post_id' => $this->plugin->ID, + 'type' => 'internal-note', + ) + ); + + $override = array_filter( + $notes, + function ( $note ) { + return false !== strpos( $note->comment_content, 'overriding the security-scan block' ); + } + ); + + $this->assertCount( 1, $override ); + } + + /** + * When the version is already live — the delay elapsed before the verdict arrived — a + * blocking score can't un-ship it: `update_source` is left untouched and nothing is held. + */ + public function test_a_blocking_score_leaves_an_already_served_version_untouched() { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress; there is no API for it. + $wpdb->update( + $wpdb->prefix . 'update_source', + array( 'version' => self::NEW_VERSION ), + array( 'plugin_slug' => self::SLUG ) + ); + + Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->callback_data( array( 'risk_score' => 8 ) ) ); + + $this->assertSame( self::NEW_VERSION, $this->get_served_version() ); + $this->assertNull( $this->get_release_block() ); + } + + /** + * A verdict on a version that a newer commit has already superseded does not hold anything. + */ + public function test_a_blocking_score_for_a_superseded_version_does_not_block() { + update_post_meta( $this->plugin->ID, 'version', '3.0' ); + + Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->callback_data( array( 'risk_score' => 8 ) ) ); + + $this->assertNull( $this->get_release_block() ); + $this->assertSame( self::SERVED_VERSION, $this->get_served_version() ); + } + + /** + * With no delay captured at release creation, the version was served at import, so there's + * nothing left to hold. + */ + public function test_a_blocking_score_does_not_block_a_release_that_had_no_delay() { + $this->set_releases( array( $this->release( array( 'release_delay' => 0 ) ) ) ); + + Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->callback_data( array( 'risk_score' => 8 ) ) ); + + $this->assertNull( $this->get_release_block() ); + } + + /** + * A held version stays held across the reconciliation cron that would otherwise serve it. + */ + public function test_a_held_version_is_not_served_by_a_later_update_run() { + Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->callback_data( array( 'risk_score' => 8 ) ) ); + + // The delay has since elapsed; without the block this would serve 2.0. + update_post_meta( $this->plugin->ID, 'version_date', gmdate( 'Y-m-d H:i:s', time() - ( self::DELAY * 2 ) ) ); + + API_Update_Updater::update_single_plugin( self::SLUG ); + + $this->assertSame( self::SERVED_VERSION, $this->get_served_version() ); + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/bootstrap.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/bootstrap.php index 633d06519c..d58a914417 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/bootstrap.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/bootstrap.php @@ -52,3 +52,8 @@ function manually_load_plugin() { // Start up the WP testing environment. require $_tests_dir . '/includes/bootstrap.php'; + +// Load shared abstract test-case bases; they're excluded from the suite but subclasses need them. +foreach ( glob( __DIR__ . '/*_Test_Case.php' ) as $test_case_base ) { + require_once $test_case_base; +} From 44f3ef9ef07e05d5cffe6bc4a5cb7406f0a12d85 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 21 Jul 2026 08:56:16 -0500 Subject: [PATCH 3/3] Plugin Directory: Let reviewers block a release, and hold off on auto-blocking. Reviewers could already skip the release cooldown, but had no way to hold a release back short of closing the whole plugin. Add a Block control to the Controls metabox, alongside the force-release one and sharing a single reason field, so a version still inside its cooldown can be held out of `update_source` until it's force-released. - Blocking is consolidated into API_Update_Updater::block_release(), the counterpart to force_release(). The security-scan path delegates to it and keeps only the guards specific to an async verdict: that the scanned version is still current, and that a cooldown was captured at release creation. - The reviewer path therefore inherits the "already live, nothing to hold" guard, so a block on a served version is refused rather than recorded as a hold that never takes effect. - force_release() and the metabox notice describe both kinds of block, naming the risk score only when a scan set one. Auto-blocking on a scan risk score is left disabled, so this ships independently of any upstream Gandalf work: a high score still alerts Slack, but a human decides. The tests covering that path are skipped rather than removed, and the call is one line to restore. Covered by Reviewer_Release_Block_Test, which exercises the block through the same path the metabox uses. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UNnLdnE7etihGNvsWY1qGE --- .../admin/metabox/class-controls.php | 67 +++- .../jobs/class-api-update-updater.php | 89 ++++- .../jobs/class-plugin-scan-gandalf.php | 52 +-- ...Gandalf_Risk_Score_Block_Callback_Test.php | 6 + .../tests/Gandalf_Risk_Score_Block_Test.php | 10 + .../tests/Reviewer_Release_Block_Test.php | 326 ++++++++++++++++++ 6 files changed, 490 insertions(+), 60 deletions(-) create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Reviewer_Release_Block_Test.php diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php index 51e51ed934..b8ada657c3 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php @@ -46,10 +46,11 @@ static function display() { } /** - * Display the release hold status and (for reviewers) a force-release control. + * Display the release hold status and (for reviewers) force-release and block controls. * * Shows one of two messages: a countdown while a release is cooling down, or a block - * notice when a Gandalf scan is holding it (which outlasts the cooldown window). Bails + * notice when the release is being held (which outlasts the cooldown window). Reviewers + * can force-release either way, and can block a version that's still cooling down. Bails * when there's no current release, or when it's neither held nor still cooling down. */ protected static function display_release_cooldown() { @@ -80,12 +81,21 @@ protected static function display_release_cooldown() {

- +