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 );
+ }
+}