diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/email/class-security-scan-findings.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/email/class-security-scan-findings.php
new file mode 100644
index 0000000000..9ba75d91ba
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/email/class-security-scan-findings.php
@@ -0,0 +1,228 @@
+args['record'];
+
+ if ( 'blocked' === $record['action'] ) {
+ /* translators: 1: Plugin name. 2: Plugin version. */
+ $subject = __( '%1$s %2$s has been blocked due to security findings', 'wporg-plugins' );
+ } else {
+ /* translators: 1: Plugin name. 2: Plugin version. */
+ $subject = __( 'Security scan findings in %1$s %2$s', 'wporg-plugins' );
+ }
+
+ return sprintf( $subject, $this->plugin_title(), $record['version'] );
+ }
+
+ /**
+ * The Markdown content of the email.
+ *
+ * @return string The email content.
+ */
+ public function markdown(): string {
+ $record = $this->args['record'];
+
+ $greeting = sprintf(
+ /* translators: %s: Committer's display name. */
+ __( 'Howdy %s,', 'wporg-plugins' ),
+ $this->user_text( $this->user )
+ );
+
+ $intro = sprintf(
+ /* translators: 1: Plugin name. 2: Plugin version. 3: Maximum risk score, from 0 to 10. */
+ __( 'An automated security scan of %1$s %2$s reported findings with a maximum risk score of %3$s out of 10.', 'wporg-plugins' ),
+ $this->plugin_title(),
+ $record['version'],
+ number_format_i18n( (float) $record['max_risk_score'], 1 )
+ );
+
+ if ( 'blocked' === $record['action'] ) {
+ $action = __( 'A security review of this version found issues severe enough to block it from being offered as an update, protecting sites from receiving it. Sites running a previous version keep receiving that version. Please address the findings and release a new version.', 'wporg-plugins' );
+ } else {
+ $action = __( 'Please review the findings and address them in an upcoming release.', 'wporg-plugins' );
+ }
+
+ $parts = [ $greeting, $intro, $action ];
+
+ $findings = $this->findings_text( $record );
+ if ( '' !== $findings ) {
+ array_push( $parts, $findings, '---' );
+ }
+
+ $parts[] = __( 'If you have questions or believe these findings to be in error, please reply to this email or contact plugins@wordpress.org.', 'wporg-plugins' );
+
+ return implode( "\n\n", $parts );
+ }
+
+ /**
+ * The plain-text content for the email template.
+ *
+ * Decodes the entities prose() encodes into the Markdown source.
+ *
+ * @return string The plain-text content.
+ */
+ public function body(): string {
+ return html_entity_decode( $this->markdown(), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
+ }
+
+ /**
+ * Format the findings, highest risk first.
+ *
+ * Finding strings are untrusted scanner output; only the risk score is
+ * contractually guaranteed. The title line ends in two spaces to
+ * hard-break in Markdown.
+ *
+ * @param array $record The completed scan record.
+ * @return string The findings, or an empty string without findings.
+ */
+ private function findings_text( array $record ): string {
+ $items = [];
+
+ foreach ( $record['findings'] as $finding ) {
+ $title = $this->excerpt( (string) ( $finding['title'] ?? '' ), 300 );
+
+ $item = sprintf(
+ '**%1$s** — %2$s',
+ number_format_i18n( (float) ( $finding['risk_score'] ?? 0 ), 1 ),
+ $title ?: __( '(no summary provided)', 'wporg-plugins' )
+ );
+
+ if ( ! empty( $finding['file_path'] ) ) {
+ $file_path = $this->excerpt( (string) $finding['file_path'], 200 );
+ $line = (int) ( $finding['line'] ?? 0 );
+
+ // The excerpted label can't contain a `]`, the URL is percent-encoded; the link syntax stays intact.
+ $item .= sprintf(
+ " \n[%1\$s](%2\$s)",
+ $file_path . ( $line ? ':' . $line : '' ),
+ $this->file_url( (string) $record['release_ref'], (string) $finding['file_path'], $line )
+ );
+ }
+
+ $snippet = $this->snippet_text( (string) ( $finding['code_snippet'] ?? '' ) );
+ if ( '' !== $snippet ) {
+ $item .= "\n\n" . $snippet;
+ }
+
+ $explanation = $this->prose( (string) ( $finding['explanation'] ?? '' ), 2000 );
+ if ( '' !== $explanation ) {
+ $item .= "\n\n" . $explanation;
+ }
+
+ $items[] = $item;
+ }
+
+ if ( ! $items ) {
+ return '';
+ }
+
+ return '### ' . __( 'Findings', 'wporg-plugins' ) . "\n\n" . implode( "\n\n---\n\n", $items );
+ }
+
+ /**
+ * Return a link to the finding's file in the plugins Trac browser.
+ *
+ * @param string $release_ref The scanned release ref.
+ * @param string $file_path The file path, relative to the plugin root.
+ * @param int $line The line number, or 0 for none.
+ * @return string The Trac browser URL.
+ */
+ private function file_url( string $release_ref, string $file_path, int $line ): string {
+ $url = sprintf(
+ 'https://plugins.trac.wordpress.org/browser/%s/%s/%s',
+ $this->plugin->post_name,
+ 'trunk' === $release_ref ? 'trunk' : 'tags/' . rawurlencode( $release_ref ),
+ implode( '/', array_map( 'rawurlencode', explode( '/', ltrim( $file_path, '/' ) ) ) )
+ );
+
+ if ( $line ) {
+ $url .= '#L' . $line;
+ }
+
+ return $url;
+ }
+
+ /**
+ * Format an untrusted code snippet as an indented Markdown code block.
+ *
+ * Unlike a fence, an indented code block cannot be broken out of, and
+ * Markdown escapes its content in the HTML variant.
+ *
+ * @param string $snippet The code snippet.
+ * @return string The code block, or an empty string for an empty snippet.
+ */
+ private function snippet_text( string $snippet ): string {
+ $snippet = str_replace( "\r\n", "\n", trim( $snippet, "\n\r" ) );
+ $snippet = mb_strimwidth( implode( "\n", array_slice( explode( "\n", $snippet ), 0, 10 ) ), 0, 1000, '…' );
+
+ if ( '' === trim( $snippet ) ) {
+ return '';
+ }
+
+ return ' ' . str_replace( "\n", "\n ", $snippet );
+ }
+
+ /**
+ * Bound untrusted prose, preserving paragraphs, without live markup.
+ *
+ * Angle brackets are encoded rather than stripped, so text like ``
+ * or `prose( $text, $length ) );
+ }
+}
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 a0b58c457d..c306c17e89 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
@@ -1,18 +1,24 @@
0 ) {
- self::notify_slack(
- $plugin,
- [
- 'version' => $pending_record['version'],
- 'release_ref' => $pending_record['release_ref'],
- 'findings_count' => $data['findings_count'],
- 'severity_counts' => $data['severity_counts'],
- 'verdict_hash' => $data['verdict_hash'],
- 'report_url' => $data['report_url'],
- 'findings' => is_array( $data['findings'] ?? null ) ? array_filter( $data['findings'], 'is_array' ) : [],
- 'max_risk_score' => $data['max_risk_score'] ?? null,
- ]
- );
+ $record = [
+ 'scan_id' => $scan_id,
+ 'version' => $pending_record['version'],
+ 'release_ref' => $pending_record['release_ref'],
+ 'completed_at' => $data['completed_at'] ?? time(),
+ 'verdict_hash' => $data['verdict_hash'],
+ 'findings_count' => $data['findings_count'],
+ 'severity_counts' => $data['severity_counts'],
+ 'max_risk_score' => (float) $data['max_risk_score'],
+ 'report_url' => $data['report_url'],
+ 'action' => 'advisory',
+
+ /*
+ * Only the ten highest-risk findings ever surface (Slack shows five,
+ * the review note ten), and snippets and explanations are only in
+ * the scan report; storing more would bloat a post meta row that is
+ * loaded on every plugin page view.
+ */
+ 'findings' => array_map(
+ static function ( $finding ) {
+ unset( $finding['code_snippet'], $finding['explanation'] );
+ return $finding;
+ },
+ self::top_findings( $data['findings'], 10 )
+ ),
+ ];
+
+ /**
+ * Filters the risk score at which a completed security scan blocks the release.
+ *
+ * @param float $threshold The block threshold, from 0 to 10.
+ * @param \WP_Post $plugin The plugin post.
+ */
+ $threshold = (float) apply_filters( 'wporg_plugins_security_scan_block_risk_score', self::BLOCK_RISK_SCORE, $plugin );
+
+ if ( $record['max_risk_score'] >= $threshold && self::block_release( $plugin, $record ) ) {
+ $record['action'] = 'blocked';
+
+ self::record_review_note( $plugin, $record );
+ }
+
+ // A retry of a partially processed delivery must not downgrade the recorded action.
+ $previous_result = get_post_meta( $plugin->ID, self::LAST_RESULT_META_KEY, true );
+ if ( is_array( $previous_result ) && ( $previous_result['scan_id'] ?? '' ) === $scan_id && 'advisory' === $record['action'] ) {
+ $record['action'] = $previous_result['action'] ?? 'advisory';
}
+
+ // Persist the bounded evidence snapshot and the action taken on it.
+ update_post_meta( $plugin->ID, self::LAST_RESULT_META_KEY, $record );
+
+ if ( $record['findings_count'] > 0 || 'advisory' !== $record['action'] ) {
+ self::notify_slack( $plugin, $record );
+ }
+
+ self::notify_committers( $plugin, $record, $data['findings'] );
} else {
self::record_last_error( $plugin, $data['error']['kind'], $data['error']['message'], $scan_id );
}
@@ -290,6 +348,82 @@ protected static function ksort_deep( &$data ) {
unset( $value );
}
+ /**
+ * Block the scanned release, once the verdict is known to still apply to it.
+ *
+ * A verdict for a version that is no longer the plugin's current release,
+ * or that is already being served, can't un-ship anything — blocking is
+ * refused and the result stays advisory.
+ *
+ * @param \WP_Post $plugin The plugin post.
+ * @param array $record The completed scan record.
+ * @return bool Whether the release was blocked.
+ */
+ protected static function block_release( $plugin, $record ) {
+ // A newer release landed since this scan was dispatched; the scanned version is moot.
+ if ( (string) get_post_meta( $plugin->ID, 'version', true ) !== (string) $record['version'] ) {
+ return false;
+ }
+
+ return API_Update_Updater::block_release(
+ $plugin->post_name,
+ [
+ 'scan_id' => $record['scan_id'],
+ 'risk_score' => $record['max_risk_score'],
+ ]
+ );
+ }
+
+ /**
+ * Leave an internal note with the scan findings for the plugin review team.
+ *
+ * @param \WP_Post $plugin The plugin post.
+ * @param array $record The completed scan record.
+ */
+ protected static function record_review_note( $plugin, $record ) {
+ $note = sprintf(
+ 'Automatically blocked version %s (%s) from being served, pending review: security scan %s reported a maximum risk score of %s. Force-release to serve it.',
+ esc_html( $record['version'] ),
+ esc_html( $record['release_ref'] ),
+ esc_html( $record['scan_id'] ),
+ esc_html( $record['max_risk_score'] )
+ );
+
+ $note .= '
Findings:';
+ foreach ( self::top_findings( $record['findings'], 10 ) as $finding ) {
+ $note .= sprintf(
+ '
• %s — %s',
+ esc_html( number_format( (float) $finding['risk_score'], 1 ) ),
+ esc_html( self::excerpt( $finding['title'] ?? '', 200 ) )
+ );
+
+ if ( ! empty( $finding['file_path'] ) ) {
+ $note .= sprintf(
+ '
%s%s',
+ esc_html( $finding['file_path'] ),
+ empty( $finding['line'] ) ? '' : ':' . (int) $finding['line']
+ );
+ }
+
+ // The contract only requires a finding's risk_score; read the rest defensively.
+ $investigation = $finding['investigation'] ?? [];
+ if ( 'completed' === ( $investigation['status'] ?? '' ) && in_array( $investigation['result'] ?? '', [ 'reproduced', 'conditional' ], true ) ) {
+ $note .= sprintf(
+ '
Investigation (%s): %s',
+ esc_html( $investigation['result'] ),
+ esc_html( self::excerpt( $investigation['summary'] ?? '', 200 ) )
+ );
+ }
+ }
+
+ $note .= '
Report: ' . esc_url( $record['report_url'] );
+
+ $wordpressdotorg = get_user_by( 'slug', 'wordpressdotorg' );
+
+ // wp_insert_comment() unslashes; slash so backslashes in finding strings survive.
+ Tools::audit_log( wp_slash( $note ), $plugin, $wordpressdotorg ? $wordpressdotorg : false );
+ }
+
/**
* Record a valid-secret callback that failed validation.
*
@@ -328,7 +462,7 @@ protected static function dispatch_failed( $plugin, $request_data, $message, $ki
* Notify Slack about a Gandalf scan with findings.
*
* @param \WP_Post $plugin The plugin post.
- * @param array $record The completed scan summary.
+ * @param array $record The completed scan record.
*/
protected static function notify_slack( $plugin, $record ) {
if ( empty( $record['verdict_hash'] ) ) {
@@ -342,7 +476,8 @@ protected static function notify_slack( $plugin, $record ) {
}
}
- if ( isset( $already_notified[ $record['verdict_hash'] ] ) ) {
+ // Release blocks always alert; only advisory results deduplicate.
+ if ( 'advisory' === $record['action'] && isset( $already_notified[ $record['verdict_hash'] ] ) ) {
update_post_meta( $plugin->ID, self::NOTIFIED_META_KEY, $already_notified );
return;
}
@@ -379,11 +514,16 @@ protected static function notify_slack( $plugin, $record ) {
$meta_links .= ' · ' . htmlspecialchars( $record['release_ref'], ENT_NOQUOTES );
}
+ $summary_text = sprintf( '%s · %s', 1 === $findings_count ? '*1 finding*' : "*{$findings_count} findings*", $install_text );
+ if ( isset( $record['max_risk_score'] ) ) {
+ $summary_text .= sprintf( ' · max risk %s', number_format( (float) $record['max_risk_score'], 1 ) );
+ }
+
$summary = [
'type' => 'section',
'text' => [
'type' => 'mrkdwn',
- 'text' => sprintf( '%s · %s', 1 === $findings_count ? '*1 finding*' : "*{$findings_count} findings*", $install_text ),
+ 'text' => $summary_text,
],
];
@@ -408,14 +548,25 @@ protected static function notify_slack( $plugin, $record ) {
'text' => self::excerpt( trim( $title . ' ' . $record['version'] ), 150 ),
],
],
- $summary,
- [
- 'type' => 'context',
- 'elements' => [
- [
- 'type' => 'mrkdwn',
- 'text' => $meta_links,
- ],
+ ];
+
+ if ( 'blocked' === $record['action'] ) {
+ $blocks[] = [
+ 'type' => 'section',
+ 'text' => [
+ 'type' => 'mrkdwn',
+ 'text' => ':rotating_light: *Automatically blocked from release pending review.*',
+ ],
+ ];
+ }
+
+ $blocks[] = $summary;
+ $blocks[] = [
+ 'type' => 'context',
+ 'elements' => [
+ [
+ 'type' => 'mrkdwn',
+ 'text' => $meta_links,
],
],
];
@@ -456,12 +607,20 @@ protected static function notify_slack( $plugin, $record ) {
$attachments[] = $attachment;
}
- $fallback = sprintf(
- 'Security scan found %s in %s %s',
- 1 === $findings_count ? '1 finding' : "{$findings_count} findings",
- htmlspecialchars( $title, ENT_NOQUOTES ),
- htmlspecialchars( $record['version'], ENT_NOQUOTES )
- );
+ if ( 'blocked' === $record['action'] ) {
+ $fallback = sprintf(
+ 'Security scan automatically blocked %s %s from release pending review',
+ htmlspecialchars( $title, ENT_NOQUOTES ),
+ htmlspecialchars( $record['version'], ENT_NOQUOTES )
+ );
+ } else {
+ $fallback = sprintf(
+ 'Security scan found %s in %s %s',
+ 1 === $findings_count ? '1 finding' : "{$findings_count} findings",
+ htmlspecialchars( $title, ENT_NOQUOTES ),
+ htmlspecialchars( $record['version'], ENT_NOQUOTES )
+ );
+ }
if ( isset( $record['max_risk_score'] ) ) {
$fallback .= sprintf( ' (max risk %s)', number_format( (float) $record['max_risk_score'], 1 ) );
}
@@ -478,6 +637,69 @@ protected static function notify_slack( $plugin, $record ) {
);
}
+ /**
+ * Email the plugin committers about a completed scan's findings.
+ *
+ * @param \WP_Post $plugin The plugin post.
+ * @param array $record The completed scan record.
+ * @param array $findings The reported findings, with code snippets and explanations intact.
+ */
+ protected static function notify_committers( $plugin, $record, $findings ) {
+ if ( empty( $record['verdict_hash'] ) ) {
+ return;
+ }
+
+ /**
+ * Filters the risk score at which a completed security scan emails the plugin committers.
+ *
+ * @param float $threshold The notification threshold, from 0 to 10. Above 10 disables the emails.
+ * @param \WP_Post $plugin The plugin post.
+ */
+ $threshold = (float) apply_filters( 'wporg_plugins_security_scan_notify_risk_score', self::NOTIFY_RISK_SCORE, $plugin );
+
+ if ( $record['max_risk_score'] < $threshold ) {
+ return;
+ }
+
+ $already_emailed = get_post_meta( $plugin->ID, self::EMAILED_META_KEY, true ) ?: [];
+ foreach ( $already_emailed as $hash => $time ) {
+ if ( $time < time() - MONTH_IN_SECONDS ) {
+ unset( $already_emailed[ $hash ] );
+ }
+ }
+
+ // Release blocks always email; only advisory results deduplicate.
+ if ( 'advisory' === $record['action'] && isset( $already_emailed[ $record['verdict_hash'] ] ) ) {
+ update_post_meta( $plugin->ID, self::EMAILED_META_KEY, $already_emailed );
+ return;
+ }
+
+ $committers = array_diff(
+ Tools::get_plugin_committers( $plugin ),
+ $GLOBALS['bot_accounts'] ?? [],
+ $GLOBALS['nologin_accounts'] ?? []
+ );
+ if ( ! $committers ) {
+ return;
+ }
+
+ $already_emailed[ $record['verdict_hash'] ] = time();
+ update_post_meta( $plugin->ID, self::EMAILED_META_KEY, $already_emailed );
+
+ // The stored record's findings are stripped for the meta row; the email gets them intact.
+ $record['findings'] = self::top_findings( $findings, 10 );
+
+ $email = new Security_Scan_Findings(
+ $plugin,
+ $committers,
+ [
+ 'record' => $record,
+ 'who' => 'WordPress.org',
+ ]
+ );
+ $email->send();
+ }
+
/**
* Return the attachment bar color for a risk score.
*
@@ -532,8 +754,6 @@ protected static function file_link( $plugin, $release_ref, $file_path, $line )
/**
* Return the highest-risk findings first, bounded for display.
*
- * The callback orders findings by ID, not by severity.
- *
* @param array $findings The scan findings.
* @param int $limit Maximum number of findings to return.
* @return array The highest-risk findings.
@@ -542,7 +762,7 @@ protected static function top_findings( $findings, $limit ) {
usort(
$findings,
static function ( $a, $b ) {
- return ( $b['risk_score'] ?? 0 ) <=> ( $a['risk_score'] ?? 0 );
+ return $b['risk_score'] <=> $a['risk_score'];
}
);
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Scan_Endpoint_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Scan_Endpoint_Test.php
index a941a8af48..e107e3d59f 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Scan_Endpoint_Test.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Scan_Endpoint_Test.php
@@ -9,6 +9,7 @@
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
+use WordPressdotorg\Plugin_Directory\Jobs\API_Update_Updater;
use WordPressdotorg\Plugin_Directory\Jobs\Plugin_Scan_Gandalf;
use WordPressdotorg\Plugin_Directory\Plugin_Directory;
@@ -208,9 +209,49 @@ public function test_callback_is_accepted(): void {
$this->assertSame( 200, $response->get_status() );
$this->assertSame( array( 'success' => true ), $response->get_data() );
+ $snapshot = get_post_meta( $this->plugin->ID, Plugin_Scan_Gandalf::LAST_RESULT_META_KEY, true );
+ $this->assertSame( 'advisory', $snapshot['action'] );
+ $this->assertSame( 5.5, $snapshot['max_risk_score'] );
+ $this->assertCount( 3, $snapshot['findings'] );
+
$this->assertEmpty( get_post_meta( $this->plugin->ID, Plugin_Scan_Gandalf::PENDING_META_KEY, true ) );
}
+ /**
+ * A high-risk callback blocks the release end to end.
+ */
+ public function test_high_risk_callback_blocks_release(): void {
+ update_post_meta(
+ $this->plugin->ID,
+ 'releases',
+ array(
+ array(
+ 'date' => time(),
+ 'tag' => self::VERSION,
+ 'version' => self::VERSION,
+ 'zips_built' => true,
+ 'zips_built_from_revision' => 0,
+ 'confirmations' => array(),
+ 'confirmed' => true,
+ 'confirmations_required' => 0,
+ 'committer' => array(),
+ 'revision' => array(),
+ 'release_delay' => DAY_IN_SECONDS,
+ ),
+ )
+ );
+
+ $response = $this->dispatch( $this->payload( array( 'max_risk_score' => 9.8 ) ) );
+
+ $this->assertSame( 200, $response->get_status() );
+
+ $release = Plugin_Directory::get_release( get_post( $this->plugin->ID ), self::VERSION );
+ $this->assertTrue( API_Update_Updater::is_release_blocked( $release ) );
+
+ $snapshot = get_post_meta( $this->plugin->ID, Plugin_Scan_Gandalf::LAST_RESULT_META_KEY, true );
+ $this->assertSame( 'blocked', $snapshot['action'] );
+ }
+
/**
* A production-shaped failure report is accepted and recorded.
*/
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Security_Scan_Block_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Security_Scan_Block_Test.php
new file mode 100644
index 0000000000..df2d1fd285
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Security_Scan_Block_Test.php
@@ -0,0 +1,636 @@
+ 'block-test-' . ( ++self::$plugin_count ),
+ 'post_title' => 'Scan Block Test Plugin',
+ 'post_status' => 'publish',
+ )
+ );
+
+ $this->assertInstanceOf( \WP_Post::class, $plugin );
+ $this->plugin = $plugin;
+
+ /*
+ * The stub update_source table survives across runs — the WP test
+ * installer only drops core tables — so clear leftovers that would
+ * collide with this run's plugin ID or read as a served version.
+ */
+ global $wpdb;
+ $wpdb->delete( $wpdb->prefix . 'update_source', array( 'plugin_id' => $this->plugin->ID ) );
+ $wpdb->delete( $wpdb->prefix . 'update_source', array( 'plugin_slug' => $this->plugin->post_name ) );
+
+ update_post_meta( $this->plugin->ID, 'version', self::VERSION );
+ update_post_meta( $this->plugin->ID, 'stable_tag', self::VERSION );
+ $this->add_pending_scan( self::SCAN_ID );
+ }
+
+ /**
+ * Register a pending scan on the plugin fixture.
+ *
+ * @param string $scan_id The scan ID to register.
+ */
+ private function add_pending_scan( string $scan_id ): void {
+ $pending = get_post_meta( $this->plugin->ID, Plugin_Scan_Gandalf::PENDING_META_KEY, true );
+ $pending = is_array( $pending ) ? $pending : array();
+
+ $pending[ $scan_id ] = array(
+ 'version' => self::VERSION,
+ 'release_ref' => self::VERSION,
+ 'requested_at' => time(),
+ );
+
+ update_post_meta( $this->plugin->ID, Plugin_Scan_Gandalf::PENDING_META_KEY, $pending );
+ }
+
+ /**
+ * Register a release for the scanned version, still inside its cooldown window.
+ */
+ private function stage_release(): void {
+ update_post_meta(
+ $this->plugin->ID,
+ 'releases',
+ array(
+ array(
+ 'date' => time(),
+ 'tag' => self::VERSION,
+ 'version' => self::VERSION,
+ 'zips_built' => true,
+ 'zips_built_from_revision' => 0,
+ 'confirmations' => array(),
+ 'confirmed' => true,
+ 'confirmations_required' => 0,
+ 'committer' => array(),
+ 'revision' => array(),
+ 'release_delay' => DAY_IN_SECONDS,
+ ),
+ )
+ );
+ }
+
+ /**
+ * Stage an update_source row for a served version alongside the cooldown release.
+ *
+ * @param string $served_version The version the row serves.
+ */
+ private function stage_cooldown_release( string $served_version = '1.0.0' ): void {
+ global $wpdb;
+
+ $wpdb->insert(
+ $wpdb->prefix . 'update_source',
+ array(
+ 'plugin_id' => $this->plugin->ID,
+ 'plugin_slug' => $this->plugin->post_name,
+ 'available' => 1,
+ 'version' => $served_version,
+ 'stable_tag' => $served_version,
+ 'plugin_name' => $this->plugin->post_title,
+ 'requires_plugins' => '',
+ 'last_updated' => $this->plugin->post_modified,
+ )
+ );
+
+ $this->stage_release();
+ }
+
+ /**
+ * Build a finding entry matching the callback contract.
+ *
+ * @param float $risk_score The finding risk score.
+ * @param array $overrides Fields to override.
+ * @return array The finding.
+ */
+ private function finding( float $risk_score, array $overrides = array() ): array {
+ return array_merge(
+ array(
+ 'id' => 'finding-' . md5( (string) $risk_score ),
+ 'ref' => 'prompt-security.supply_chain.remote_controlled_code',
+ 'title' => 'Remote response controls a PHP callable ',
+ 'severity' => 'error',
+ 'file_path' => 'includes/class-admin.php',
+ 'line' => 688,
+ 'code_snippet' => '$clean = $this->write;',
+ 'explanation' => 'The response body reaches a callable.',
+ 'risk_score' => $risk_score,
+ 'investigation' => array(
+ 'status' => 'completed',
+ 'result' => 'reproduced',
+ 'summary' => 'The unauthenticated probe reached the sink.',
+ ),
+ ),
+ $overrides
+ );
+ }
+
+ /**
+ * Build a completed callback matching the pending scan fixture.
+ *
+ * @param array $overrides Fields to override.
+ * @return array The callback data.
+ */
+ private function completed_callback( array $overrides = array() ): array {
+ $defaults = array(
+ 'status' => 'completed',
+ 'scan_id' => self::SCAN_ID,
+ 'subject_type' => 'plugin',
+ 'slug' => $this->plugin->post_name,
+ 'version' => self::VERSION,
+ 'release_ref' => self::VERSION,
+ 'completed_at' => time(),
+ 'verdict_hash' => 'f71c3d944050095a4e2e20f9ee8a7c9a',
+ 'findings_count' => 2,
+ 'findings' => array( $this->finding( 9.8 ), $this->finding( 5.2 ) ),
+ 'max_risk_score' => 9.8,
+ 'severity_counts' => array( 'error' => 2 ),
+ 'scanner_version' => '0.3.0',
+ 'report_url' => 'https://scanner.example/runs/' . self::SCAN_ID,
+ );
+
+ return array_merge( $defaults, $overrides );
+ }
+
+ /**
+ * Build a failed callback matching the pending scan fixture.
+ *
+ * @param array $overrides Fields to override.
+ * @return array The callback data.
+ */
+ private function failed_callback( array $overrides = array() ): array {
+ $defaults = array(
+ 'status' => 'failed',
+ 'scan_id' => self::SCAN_ID,
+ 'subject_type' => 'plugin',
+ 'slug' => $this->plugin->post_name,
+ 'version' => self::VERSION,
+ 'release_ref' => self::VERSION,
+ 'completed_at' => time(),
+ 'report_url' => 'https://scanner.example/runs/' . self::SCAN_ID,
+ 'error' => array(
+ 'kind' => 'timeout',
+ 'message' => 'Scan exceeded the runtime deadline.',
+ ),
+ );
+
+ return array_merge( $defaults, $overrides );
+ }
+
+ /**
+ * Fetch the internal notes recorded on the plugin fixture.
+ *
+ * @return array The internal note comments.
+ */
+ private function get_internal_notes(): array {
+ return get_comments(
+ array(
+ 'post_id' => $this->plugin->ID,
+ 'type' => 'internal-note',
+ )
+ );
+ }
+
+ /**
+ * Fetch the release record for the scanned version.
+ *
+ * @return array|false The release, or false when none exists.
+ */
+ private function get_release() {
+ return Plugin_Directory::get_release( get_post( $this->plugin->ID ), self::VERSION );
+ }
+
+ /**
+ * A completed scan at the block threshold holds the release, not the plugin.
+ */
+ public function test_high_risk_scan_blocks_release(): void {
+ $this->stage_release();
+
+ $result = Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->completed_callback() );
+
+ $this->assertTrue( $result );
+ $this->assertTrue( API_Update_Updater::is_release_blocked( $this->get_release() ) );
+ $this->assertSame( 'publish', get_post( $this->plugin->ID )->post_status );
+
+ $block = $this->get_release()['release_block'];
+ $this->assertSame( self::SCAN_ID, $block['scan_id'] );
+ $this->assertSame( 9.8, $block['risk_score'] );
+ $this->assertNotEmpty( $block['blocked_at'] );
+
+ $snapshot = get_post_meta( $this->plugin->ID, Plugin_Scan_Gandalf::LAST_RESULT_META_KEY, true );
+ $this->assertSame( 'blocked', $snapshot['action'] );
+ $this->assertSame( 9.8, $snapshot['max_risk_score'] );
+ $this->assertCount( 2, $snapshot['findings'] );
+
+ $consumed = get_post_meta( $this->plugin->ID, Plugin_Scan_Gandalf::CONSUMED_META_KEY, true );
+ $this->assertArrayHasKey( self::SCAN_ID, $consumed );
+
+ $this->assertEmpty( get_post_meta( $this->plugin->ID, Plugin_Scan_Gandalf::PENDING_META_KEY, true ) );
+ }
+
+ /**
+ * A block leaves an internal note with the escaped findings for reviewers.
+ */
+ public function test_block_leaves_findings_note(): void {
+ $this->stage_release();
+
+ Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->completed_callback() );
+
+ $notes = $this->get_internal_notes();
+ $this->assertCount( 1, $notes );
+
+ $note = $notes[0]->comment_content;
+ $this->assertStringContainsString( self::SCAN_ID, $note );
+ $this->assertStringContainsString( 'Automatically blocked version ' . self::VERSION, $note );
+ $this->assertStringContainsString( '<script>alert(1)</script>', $note );
+ $this->assertStringNotContainsString( '',
+ 'severity' => 'error',
+ 'file_path' => 'includes/class-admin.php',
+ 'line' => 688,
+ 'code_snippet' => '$clean = $this->write;',
+ 'explanation' => 'The response body reaches a callable.',
+ 'risk_score' => $risk_score,
+ 'investigation' => array(
+ 'status' => 'completed',
+ 'result' => 'reproduced',
+ 'summary' => 'The unauthenticated probe reached the sink.',
+ ),
+ ),
+ $overrides
+ );
+ }
+
+ /**
+ * Build a completed callback matching the pending scan fixture.
+ *
+ * @param array $overrides Fields to override.
+ * @return array The callback data.
+ */
+ private function completed_callback( array $overrides = array() ): array {
+ $defaults = array(
+ 'status' => 'completed',
+ 'scan_id' => self::SCAN_ID,
+ 'subject_type' => 'plugin',
+ 'slug' => $this->plugin->post_name,
+ 'version' => self::VERSION,
+ 'release_ref' => self::VERSION,
+ 'completed_at' => time(),
+ 'verdict_hash' => 'f71c3d944050095a4e2e20f9ee8a7c9a',
+ 'findings_count' => 1,
+ 'findings' => array( $this->finding( 9.8 ) ),
+ 'max_risk_score' => 9.8,
+ 'severity_counts' => array( 'error' => 1 ),
+ 'scanner_version' => '0.3.0',
+ 'report_url' => 'https://scanner.example/runs/' . self::SCAN_ID,
+ );
+
+ return array_merge( $defaults, $overrides );
+ }
+
+ /**
+ * A blocked release emails the committers, reflecting the block.
+ */
+ public function test_blocked_scan_emails_committers(): void {
+ $this->stage_release();
+
+ $this->assertTrue( Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->completed_callback() ) );
+
+ $this->assertCount( 1, $this->emails );
+ $email = $this->emails[0];
+
+ $this->assertSame( $this->committer->user_email, $email['to'] );
+ $this->assertStringContainsString( 'has been blocked due to security findings', $email['subject'] );
+ $this->assertStringContainsString( self::VERSION, $email['subject'] );
+
+ $this->assertStringContainsString( 'block it from being offered as an update', $email['message'] );
+ $this->assertStringContainsString( '9.8', $email['message'] );
+ $this->assertStringContainsString( 'Remote response controls a PHP callable', $email['message'] );
+ // The relative path links to the file in the Trac browser.
+ $this->assertStringContainsString(
+ sprintf( 'https://plugins.trac.wordpress.org/browser/%s/tags/%s/includes/class-admin.php#L688', $this->plugin->post_name, self::VERSION ),
+ $email['message']
+ );
+ $this->assertStringContainsString( '>includes/class-admin.php:688', $email['message'] );
+
+ // The code snippet renders escaped in a code block; the explanation as prose.
+ $this->assertStringContainsString( '$clean = $this->write;', $email['message'] );
+ $this->assertStringContainsString( '', $email['message'] );
+ $this->assertStringContainsString( 'The response body reaches a callable.', $email['message'] );
+ }
+
+ /**
+ * Hostile finding strings do not reach the email as markup.
+ *
+ * The HTML variant renders Markdown, so both HTML tags and Markdown link
+ * syntax must be neutralized.
+ */
+ public function test_email_neutralizes_hostile_findings(): void {
+ $this->stage_release();
+
+ $callback = $this->completed_callback(
+ array(
+ 'findings_count' => 2,
+ 'findings' => array(
+ $this->finding( 9.8 ),
+ $this->finding(
+ 9.7,
+ array(
+ 'title' => '[Appeal this decision](https://evil.example/appeal)',
+ 'code_snippet' => '',
+ )
+ ),
+ ),
+ )
+ );
+
+ Plugin_Scan_Gandalf::handle_callback( $this->plugin, $callback );
+
+ $this->assertCount( 1, $this->emails );
+ $message = $this->emails[0]['message'];
+
+ // Markup in a title renders as inert, escaped text.
+ $this->assertStringNotContainsString( '