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..df8c389f8a --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/email/class-security-scan-findings.php @@ -0,0 +1,298 @@ +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: URL to the automated security review documentation. */ + __( 'An automated security review of %1$s %2$s reported the following findings. Learn more about these reviews in the [plugin developer handbook](%3$s).', 'wporg-plugins' ), + $this->excerpt( $this->plugin_title(), 200 ), + $this->excerpt( (string) $record['version'], 32 ), + 'https://developer.wordpress.org/plugins/wordpress-org/automated-security-review/' + ); + + if ( 'blocked' === $record['action'] ) { + $action = __( 'The issues found were severe enough to block this version from being offered as an update. 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 a finding does not apply, please reply to this email with the details.', '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 { + // Normalize every newline the Markdown processor recognizes (it maps \r\n and lone \r to \n): an unindented line it splits out later escapes the code block. + $snippet = str_replace( [ "\r\n", "\r" ], "\n", trim( $snippet, "\n\r" ) ); + $lines = array_slice( explode( "\n", $snippet ), 0, 10 ); + $snippet = mb_strimwidth( implode( "\n", $this->outdent( $lines ) ), 0, 1000, '…' ); + + if ( '' === trim( $snippet ) ) { + return ''; + } + + return ' ' . str_replace( "\n", "\n ", $snippet ); + } + + /** + * Strip the whitespace prefix shared by all non-blank lines, keeping + * the block's relative indentation. + * + * @param array $lines The lines to outdent. + * @return array The outdented lines. + */ + private function outdent( array $lines ): array { + $prefix = null; + + foreach ( $lines as $line ) { + if ( '' === trim( $line ) ) { + continue; + } + + $indent = substr( $line, 0, strspn( $line, " \t" ) ); + + if ( null === $prefix ) { + $prefix = $indent; + continue; + } + + $length = 0; + $max_length = min( strlen( $prefix ), strlen( $indent ) ); + while ( $length < $max_length && $prefix[ $length ] === $indent[ $length ] ) { + ++$length; + } + $prefix = substr( $prefix, 0, $length ); + + if ( '' === $prefix ) { + break; + } + } + + if ( ! $prefix ) { + return $lines; + } + + return array_map( + static function ( string $line ) use ( $prefix ): string { + return str_starts_with( $line, $prefix ) ? substr( $line, strlen( $prefix ) ) : $line; + }, + $lines + ); + } + + /** + * 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 d250a245e0..74e0629190 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,6 +7,7 @@ namespace WordPressdotorg\Plugin_Directory\Jobs; +use WordPressdotorg\Plugin_Directory\Email\Security_Scan_Findings; use WordPressdotorg\Plugin_Directory\Plugin_Directory; use WordPressdotorg\Plugin_Directory\Template; use WordPressdotorg\Plugin_Directory\Tools; @@ -36,9 +37,15 @@ class Plugin_Scan_Gandalf { /** Consumed callbacks keyed by scan_id, to acknowledge retries without repeating effects. */ const CONSUMED_META_KEY = '_gandalf_scan_consumed'; + /** Verdict hashes already emailed to the plugin committers, to avoid duplicate emails. */ + const EMAILED_META_KEY = '_gandalf_scan_emailed'; + /** Completed scans with a max risk score at or above this have their release blocked. */ const BLOCK_RISK_SCORE = PHP_FLOAT_MAX; + /** Completed scans with a max risk score at or above this have their committers emailed. */ + const NOTIFY_RISK_SCORE = self::BLOCK_RISK_SCORE; + /** Gandalf scan endpoint. */ const ENDPOINT = 'https://gandalf.wordpress.org/scan'; @@ -278,6 +285,8 @@ protected static function consume_callback( $plugin, $data ) { if ( $record['findings_count'] > 0 || 'advisory' !== $record['action'] ) { self::notify_slack( $plugin, $record ); } + + self::notify_committers( $plugin, $record ); } else { self::record_last_error( $plugin, $data['error']['kind'], $data['error']['message'], $scan_id ); } @@ -620,6 +629,67 @@ 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. + */ + protected static function notify_committers( $plugin, $record ) { + 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 ); + + $record['findings'] = self::top_findings( $record['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. * diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Security_Scan_Notification_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Security_Scan_Notification_Test.php new file mode 100644 index 0000000000..67dd850a84 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Security_Scan_Notification_Test.php @@ -0,0 +1,789 @@ +threshold_pin = static function (): float { + return 8.0; + }; + add_filter( 'wporg_plugins_security_scan_block_risk_score', $this->threshold_pin ); + add_filter( 'wporg_plugins_security_scan_notify_risk_score', $this->threshold_pin ); + + // Tools::audit_log() reads it unguarded. + $this->remote_addr = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) $_SERVER['REMOTE_ADDR'] : null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Captured verbatim to restore in tearDown, not used as input. + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + + // Capture the exclusion globals the tests overwrite, to restore them without clobbering a pre-existing value. + foreach ( array( 'bot_accounts', 'nologin_accounts' ) as $global ) { + $this->account_globals[ $global ] = array_key_exists( $global, $GLOBALS ) ? $GLOBALS[ $global ] : null; + } + + $plugin = Plugin_Directory::create_plugin_post( + array( + 'post_name' => 'notify-test-' . ( ++self::$plugin_count ), + 'post_title' => 'Scan Notification 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 ); + + $login = 'scan-committer-' . self::$plugin_count; + $user_id = wp_create_user( $login, wp_generate_password(), $login . '@example.com' ); + $this->assertIsInt( $user_id ); + $this->committer = new \WP_User( $user_id ); + + $this->prime_committers( array( $login ) ); + + $this->emails = array(); + $this->mail_filter = function ( $short_circuit, $atts ) { + $this->emails[] = $atts; + return true; + }; + add_filter( 'pre_wp_mail', $this->mail_filter, 10, 2 ); + + /* + * The HTML email resets the mailer after wp_mail(); with wp_mail() + * short-circuited nothing initializes the global, so stub it. + */ + $GLOBALS['phpmailer'] = new class() { + /** + * The plain-text alternative body. + * + * @var string + */ + public $AltBody = ''; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase + + /** + * Toggle HTML mode. + * + * @param bool $is_html Whether to send HTML. + */ + public function IsHTML( bool $is_html = true ): void {} // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + }; + } + + /** + * Remove the filters and globals the tests installed. + */ + protected function tearDown(): void { + remove_filter( 'pre_wp_mail', $this->mail_filter, 10 ); + remove_filter( 'wporg_plugins_security_scan_block_risk_score', $this->threshold_pin ); + remove_filter( 'wporg_plugins_security_scan_notify_risk_score', $this->threshold_pin ); + + if ( $this->threshold_filter ) { + remove_filter( 'wporg_plugins_security_scan_notify_risk_score', $this->threshold_filter, 10 ); + $this->threshold_filter = null; + } + + unset( $GLOBALS['phpmailer'] ); + + foreach ( $this->account_globals as $global => $value ) { + if ( null === $value ) { + unset( $GLOBALS[ $global ] ); + } else { + $GLOBALS[ $global ] = $value; + } + } + + if ( null === $this->remote_addr ) { + unset( $_SERVER['REMOTE_ADDR'] ); + } else { + $_SERVER['REMOTE_ADDR'] = $this->remote_addr; + } + + parent::tearDown(); + } + + /** + * 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 ); + } + + /** + * Prime the committer cache Tools::get_plugin_committers() reads. + * + * @param array $logins The committer logins. + */ + private function prime_committers( array $logins ): void { + wp_cache_set( $this->plugin->post_name, $logins, 'plugin-committers', HOUR_IN_SECONDS ); + } + + /** + * Filter the notification threshold for the current test. + * + * @param float $threshold The notification threshold to use. + */ + private function set_notify_threshold( float $threshold ): void { + $this->threshold_filter = static function () use ( $threshold ): float { + return $threshold; + }; + add_filter( 'wporg_plugins_security_scan_notify_risk_score', $this->threshold_filter ); + } + + /** + * 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 serving the scanned version, so a verdict + * that can't un-ship anything stays advisory. + */ + private function stage_served_release(): void { + global $wpdb; + + $wpdb->insert( + $wpdb->prefix . 'update_source', + array( + 'plugin_id' => $this->plugin->ID, + 'plugin_slug' => $this->plugin->post_name, + 'available' => 1, + 'version' => self::VERSION, + 'stable_tag' => self::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' => 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 this version 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( ''; + + update_post_meta( $this->plugin->ID, 'version', $version ); + update_post_meta( $this->plugin->ID, 'stable_tag', $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(), + ), + ) + ); + + $callback = $this->completed_callback( + array( + 'version' => $version, + 'release_ref' => $version, + ) + ); + + Plugin_Scan_Gandalf::handle_callback( $this->plugin, $callback ); + + $this->assertCount( 1, $this->emails ); + $message = $this->emails[0]['message']; + + $this->assertStringNotContainsString( '" ) ), + ), + ) + ); + + Plugin_Scan_Gandalf::handle_callback( $this->plugin, $callback ); + + $message = $this->emails[0]['message']; + + $this->assertStringContainsString( '<script>alert(4)</script>', $message ); + $this->assertStringNotContainsString( '', $body ); + $this->assertStringContainsString( '# heading text', $body ); + + // The HTML variant keeps them escaped and neutralized. + $html = $email->html(); + $this->assertStringContainsString( '<script>alert(1)</script>', $html ); + $this->assertStringContainsString( '# heading text', $html ); + } + + /** + * A high-risk verdict that could not block still emails, as advisory. + */ + public function test_advisory_high_risk_scan_emails_committers(): void { + $this->stage_served_release(); + + $this->assertTrue( Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->completed_callback() ) ); + + $this->assertCount( 1, $this->emails ); + $this->assertStringContainsString( 'Security scan findings in', $this->emails[0]['subject'] ); + $this->assertStringContainsString( 'Please review the findings and address them in an upcoming release.', $this->emails[0]['message'] ); + $this->assertStringNotContainsString( 'blocked', $this->emails[0]['message'] ); + } + + /** + * Scans below the notification threshold do not email the committers. + */ + public function test_below_threshold_sends_no_email(): void { + $this->stage_release(); + + $callback = $this->completed_callback( + array( + 'findings' => array( $this->finding( 7.9 ) ), + 'max_risk_score' => 7.9, + ) + ); + + $this->assertTrue( Plugin_Scan_Gandalf::handle_callback( $this->plugin, $callback ) ); + $this->assertCount( 0, $this->emails ); + } + + /** + * Lowering the threshold emails advisory results below the block threshold. + */ + public function test_threshold_filter_lowers_notification_bar(): void { + $this->set_notify_threshold( 5.0 ); + + $callback = $this->completed_callback( + array( + 'findings' => array( $this->finding( 5.2 ) ), + 'max_risk_score' => 5.2, + ) + ); + + $this->assertTrue( Plugin_Scan_Gandalf::handle_callback( $this->plugin, $callback ) ); + + $this->assertCount( 1, $this->emails ); + $this->assertStringContainsString( 'Security scan findings in', $this->emails[0]['subject'] ); + } + + /** + * A threshold above 10 disables the emails, even for a blocked release. + */ + public function test_threshold_filter_disables_notifications(): void { + $this->set_notify_threshold( 11.0 ); + $this->stage_release(); + + $this->assertTrue( Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->completed_callback() ) ); + + $this->assertTrue( API_Update_Updater::is_release_blocked( Plugin_Directory::get_release( $this->plugin, self::VERSION ) ) ); + $this->assertCount( 0, $this->emails ); + } + + /** + * The same advisory verdict is emailed once, even across scans. + */ + public function test_same_advisory_verdict_is_emailed_once(): void { + $this->stage_served_release(); + + $this->assertTrue( Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->completed_callback() ) ); + + $this->add_pending_scan( self::SECOND_SCAN_ID ); + $retry = $this->completed_callback( array( 'scan_id' => self::SECOND_SCAN_ID ) ); + + $this->assertTrue( Plugin_Scan_Gandalf::handle_callback( get_post( $this->plugin->ID ), $retry ) ); + + $this->assertCount( 1, $this->emails ); + } + + /** + * A blocked release always emails, even for an already-emailed verdict. + */ + public function test_blocked_verdict_always_emails(): void { + $this->stage_release(); + + $this->assertTrue( Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->completed_callback() ) ); + + $this->add_pending_scan( self::SECOND_SCAN_ID ); + $retry = $this->completed_callback( array( 'scan_id' => self::SECOND_SCAN_ID ) ); + + $this->assertTrue( Plugin_Scan_Gandalf::handle_callback( get_post( $this->plugin->ID ), $retry ) ); + + $this->assertCount( 2, $this->emails ); + $this->assertStringContainsString( 'has been blocked due to security findings', $this->emails[1]['subject'] ); + } + + /** + * Bot committers are not emailed. + */ + public function test_bot_committers_are_not_emailed(): void { + $this->stage_release(); + + // A real account, so only the bot filter keeps it from being emailed. + $bot_login = 'gandalf-bot-' . self::$plugin_count; + $this->assertIsInt( wp_create_user( $bot_login, wp_generate_password(), $bot_login . '@example.com' ) ); + + $this->prime_committers( array( $this->committer->user_login, $bot_login ) ); + $GLOBALS['bot_accounts'] = array( $bot_login ); + + $this->assertTrue( Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->completed_callback() ) ); + + $this->assertCount( 1, $this->emails ); + $this->assertSame( $this->committer->user_email, $this->emails[0]['to'] ); + } + + /** + * No-login committers are not emailed. + */ + public function test_nologin_committers_are_not_emailed(): void { + $this->stage_release(); + + // A real account, so only the no-login filter keeps it from being emailed. + $nologin_login = 'gandalf-nologin-' . self::$plugin_count; + $this->assertIsInt( wp_create_user( $nologin_login, wp_generate_password(), $nologin_login . '@example.com' ) ); + + $this->prime_committers( array( $this->committer->user_login, $nologin_login ) ); + $GLOBALS['nologin_accounts'] = array( $nologin_login ); + + $this->assertTrue( Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->completed_callback() ) ); + + $this->assertCount( 1, $this->emails ); + $this->assertSame( $this->committer->user_email, $this->emails[0]['to'] ); + } + + /** + * A finding carrying only the required risk_score still renders. + */ + public function test_minimal_finding_renders(): void { + $this->stage_release(); + + $callback = $this->completed_callback( + array( + 'findings' => array( array( 'risk_score' => 9.9 ) ), + 'max_risk_score' => 9.9, + ) + ); + + $this->assertTrue( Plugin_Scan_Gandalf::handle_callback( $this->plugin, $callback ) ); + + $this->assertCount( 1, $this->emails ); + $message = $this->emails[0]['message']; + + // The absent title falls back, and the finding renders without a crash. + $this->assertStringContainsString( '9.9', $message ); + $this->assertStringContainsString( '(no summary provided)', $message ); + } +}