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( '