Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions environments/plugin-directory/.wp-env.test.json
Original file line number Diff line number Diff line change
Expand Up @@ -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_"
}
}
6 changes: 5 additions & 1 deletion environments/plugin-directory/bin/after-start-test.sh
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -138,21 +138,19 @@ public static function save_post( $post_id ) {
// and to make the security boundary explicit.
check_admin_referer( 'update-post_' . $post_id );

$version = get_post_meta( $post->ID, 'version', true );
$submitted_version = sanitize_text_field( wp_unslash( $_POST['force_release_version'] ) );
if ( $submitted_version !== $version ) {
// Submitted version doesn't match current — a newer commit landed since the form was rendered.
return;
}

$reason = isset( $_POST['force_release_reason'] )
? trim( sanitize_textarea_field( wp_unslash( $_POST['force_release_reason'] ) ) )
: '';
if ( ! $reason ) {
return;
}

API_Update_Updater::force_release( $post->post_name, $reason );
// serve_release_now() refuses a version that's no longer current.
API_Update_Updater::serve_release_now(
$post->post_name,
sanitize_text_field( wp_unslash( $_POST['force_release_version'] ) ),
$reason
);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
use WP_Http;

/**
* Callback endpoint for advisory Gandalf scans.
* Callback endpoint for Gandalf scans.
*
* @package WordPressdotorg_Plugin_Directory
*/
Expand All @@ -31,9 +31,53 @@ public function __construct() {
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'scan_callback' ],
'args' => [
'plugin_slug' => [
'plugin_slug' => [
'validate_callback' => [ $this, 'validate_plugin_slug_callback' ],
],
'scan_id' => [
'required' => true,
'type' => 'string',
],
'status' => [
'required' => true,
'type' => 'string',
'validate_callback' => [ $this, 'validate_status_callback' ],
],
'version' => [
'required' => true,
'type' => 'string',
],
'release_ref' => [
'required' => true,
'type' => 'string',
],

/*
* A clean scan lets a release skip the rest of its delay, so the count has to
* be a real number: `(int) 'abc'` is 0, which would read as clean. The minimum
* rejects a negative count, which is a contract violation rather than a verdict.
*/
'findings_count' => [
'type' => 'integer',
'minimum' => 0,
],
'severity_counts' => [
'type' => 'object',
],
'verdict_hash' => [
'type' => 'string',
],
'report_url' => [
'type' => 'string',
'format' => 'uri',
],
'error' => [
'type' => 'object',
'properties' => [
'kind' => [ 'type' => 'string' ],
'message' => [ 'type' => 'string' ],
],
],
],
'permission_callback' => function ( $request ) {
return $this->permission_check_api_bearer( $request, 'WP_GANDALF_SCAN_SHARED_SECRET' );
Expand All @@ -53,11 +97,7 @@ public function scan_callback( $request ) {

$data = $request->get_json_params();
if ( ! is_array( $data ) ) {
$error = new WP_Error(
'invalid_gandalf_scan_callback',
__( 'Invalid Gandalf scan callback.', 'wporg-plugins' ),
[ 'status' => WP_Http::BAD_REQUEST ]
);
$error = new WP_Error( 'invalid_gandalf_scan_callback', 'Invalid Gandalf scan callback.', [ 'status' => WP_Http::BAD_REQUEST ] );

Plugin_Scan_Gandalf::record_invalid_callback( $plugin, $error );
return $error;
Expand All @@ -72,4 +112,38 @@ public function scan_callback( $request ) {
'success' => true,
];
}

/**
* Validate `status`, and with it the fields that a given status has to carry.
*
* The args schema can express "findings_count must be a non-negative integer" but not
* "and it must be present when the scan completed", which is the case that matters: the
* handler reads the count to decide whether a release skips the rest of its delay.
*
* @param mixed $value The status.
* @param \WP_REST_Request $request The request.
* @param string $param The parameter name.
* @return true|WP_Error True when the status and its companion fields are usable.
*/
public function validate_status_callback( $value, $request, $param ) {
$valid = rest_validate_request_arg( $value, $request, $param );
if ( is_wp_error( $valid ) ) {
return $valid;
}

if ( 'completed' === $value ) {
if ( null === $request->get_param( 'findings_count' ) ) {
return new WP_Error( 'invalid_gandalf_findings_count', 'A completed Gandalf scan must report a findings_count.', [ 'status' => WP_Http::BAD_REQUEST ] );
}

return true;
}

$error = $request->get_param( 'error' );
if ( ! isset( $error['kind'], $error['message'] ) ) {
return new WP_Error( 'invalid_gandalf_scan_error', 'A Gandalf scan that did not complete must describe the error.', [ 'status' => WP_Http::BAD_REQUEST ] );
}

return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1732,8 +1732,8 @@ public static function add_release( $plugin, $data ) {
'committer' => [],
'revision' => [],
// Captures the release cooldown active at creation time so future filter/constant
// changes don't retroactively affect in-flight releases. Reviewers force-release
// by overriding this to 0 — see API_Update_Updater::force_release().
// changes don't retroactively affect in-flight releases. Overridden to 0 to serve
// a release without the wait — see API_Update_Updater::serve_release_now().
'release_delay' => get_release_cooldown_delay( $plugin->post_name ),
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,24 +86,14 @@ 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 );

/*
* Defer the write for new versions still inside the cooldown window. While
* deferred, the existing `update_source` row (carrying the previous version)
* continues to be served by the update API. Reviewers force-release by setting
* `release_delay = 0` on the release meta.
*
* The deferred cron fires at exactly $cooldown_until, so by definition this
* gate is false when called from cron_trigger_release() and no explicit bypass
* is needed.
* Defer new versions still inside the cooldown; the existing `update_source` row keeps
* serving the previous version meanwhile. The deferred cron fires at exactly
* $cooldown_until, so this gate is always false when called from cron_trigger_release().
*/
if ( $release_delay && $existing_version !== (string) $version ) {
$cooldown_until = $release_time + $release_delay;
Expand All @@ -113,11 +103,8 @@ public static function update_single_plugin( $plugin_slug ) {
}
}

// When publishing a new version under an active cooldown, anchor `release_time`
// to now — that's the moment the version is actually available to sites. Keeps
// phased_rollout()'s `manual-updates-24hr` window measuring from public availability,
// even if the commit/confirmation was long ago because the cooldown deferred the write.
if ( $release_delay && $existing_version !== (string) $version ) {
// Anchor to now: phased_rollout()'s 24h window measures from public availability, not the commit.
if ( $existing_version !== (string) $version ) {
$release_time = time();
}

Expand Down Expand Up @@ -145,8 +132,7 @@ public static function update_single_plugin( $plugin_slug ) {
);
}

// The deferred event (if any) has either fired or been pre-empted by a force-release
// or status change. Clear any leftover schedule so the cron table doesn't grow.
// Clear any leftover deferred schedule so the cron table doesn't grow.
wp_clear_scheduled_hook( "release_to_update_api:{$post->post_name}" );

$data = array(
Expand Down Expand Up @@ -208,6 +194,23 @@ 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
)
);
}

/**
* Determine the release timestamp for a plugin version.
*
Expand Down Expand Up @@ -256,41 +259,61 @@ public static function cron_trigger_release() {
}

/**
* Reviewer force-release: clear the cooldown for a plugin's current version and
* write it to `update_source` immediately. Logs the action with the supplied reason.
* Write a version to `update_source` now, without waiting out its release delay. Used by
* reviewers force-releasing from wp-admin and by a clean Gandalf scan; what separates the
* two is $reason, not the behaviour.
*
* Capability checks must be performed by the caller.
* $version must still be the plugin's current version — callers evaluated it earlier, and
* serving a stale one would skip the wait on a release nobody looked at. Authentication
* and capability checks are the caller's.
*
* @param string $plugin_slug The plugin slug.
* @param string $version The version that was evaluated.
* @param string $reason Free-text reason recorded in the audit log.
* @param \WP_User $user The acting user. Defaults to the current user.
* @return bool True on success.
* @return bool True when the version was served.
*/
public static function force_release( $plugin_slug, $reason, $user = null ) {
if ( ! $user ) {
$user = wp_get_current_user();
public static function serve_release_now( $plugin_slug, $version, $reason, $user = false ) {
if ( ! $version || ! $reason ) {
return false;
}

$post = Plugin_Directory::get_plugin_post( $plugin_slug );
if ( ! $post ) {
return false;
}

$version = get_post_meta( $post->ID, 'version', true );
$release = Plugin_Directory::get_release( $post, $version );
// A newer release landed since the caller evaluated this version.
$current_version = (string) get_post_meta( $post->ID, 'version', true );
if ( $current_version !== (string) $version ) {
return false;
}

$release = Plugin_Directory::get_release( $post, $version );
if ( ! $release ) {
return false;
}

// Nothing to skip: no delay was captured at release creation.
$release_delay = (int) ( $release['release_delay'] ?? 0 );
if ( ! $release_delay ) {
return false;
}

// Already served: the delay elapsed on its own; leave `update_source` and its `release_time` untouched.
if ( self::get_served_version( $post->post_name ) === (string) $version ) {
return false;
}

Tools::audit_log(
sprintf(
'Force-released version %s, bypassing the %d-hour release cooldown. Reason: %s',
'Served version %1$s immediately, skipping the %2$d-hour release delay. Reason: %3$s',
$version,
(int) ( $release['release_delay'] ?? 0 ) / HOUR_IN_SECONDS,
(int) round( $release_delay / HOUR_IN_SECONDS ),
$reason
),
$post
$post,
$user
);

Plugin_Directory::add_release(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php
/**
* Advisory Gandalf scan integration for plugin updates.
* Gandalf scan integration for plugin updates.
*
* @package WordPressdotorg\Plugin_Directory\Jobs
*/
Expand All @@ -12,7 +12,11 @@
use WP_Http;

/**
* Sends plugin updates to Gandalf for advisory security scans.
* Sends plugin updates to Gandalf for security scans and acts on the verdict.
*
* Findings raise a Slack alert for a reviewer. A scan that completes with no findings
* lets the version be served immediately, without waiting out its release delay. A scan
* that never reports back leaves the delay to elapse on its own.
*
* @package WordPressdotorg\Plugin_Directory\Jobs
*/
Expand Down Expand Up @@ -155,9 +159,13 @@ public static function dispatch( $plugin, $request_data ) {
/**
* Handle a completed or failed scan callback.
*
* Expects a payload already checked against the route's args schema, and so reads the
* verdict fields without guarding them.
*
* @param \WP_Post $plugin The plugin post.
* @param array $data The Gandalf callback data.
* @return true|WP_Error True on success, or an error when the scan is unknown.
* @return true|WP_Error True on success, or an error when the callback can't be matched
* to a pending scan.
*/
public static function handle_callback( $plugin, $data ) {
$scan_id = $data['scan_id'];
Expand All @@ -178,7 +186,19 @@ public static function handle_callback( $plugin, $data ) {
}

if ( 'completed' === $data['status'] ) {
if ( $data['findings_count'] > 0 ) {
if ( 0 === $data['findings_count'] ) {
// serve_release_now() re-checks the version, so a release imported mid-scan keeps its delay.
API_Update_Updater::serve_release_now(
$plugin->post_name,
$pending_record['version'],
sprintf(
'Gandalf scan %s reported no findings for %s.',
$scan_id,
$pending_record['release_ref']
)
);
} else {
// Anything but a clean scan goes to a human, including a nonsensical negative count.
self::notify_slack(
$plugin,
[
Expand Down
Loading
Loading