From 76d0923440449901678d6aa2170fad05115c68c4 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 12 Aug 2026 15:33:21 -0500 Subject: [PATCH 01/15] Plugin Directory: Email committers the outcome of a security scan. A completed security scan whose maximum risk score reaches the notification threshold (default 8.0, filterable via wporg_plugins_security_scan_notify_risk_score) emails the plugin's committers the findings: risk score, title, and a Trac browser link per finding. A blocked release is reflected in the email, including that a new version escapes the block; below the block threshold the email is advisory. Release blocks always email, advisory results deduplicate per verdict hash, and finding strings are treated as untrusted throughout. Co-Authored-By: Claude Fable 5 --- .../email/class-security-scan-findings.php | 157 ++++++ .../jobs/class-plugin-scan-gandalf.php | 68 +++ .../tests/Security_Scan_Notification_Test.php | 518 ++++++++++++++++++ 3 files changed, 743 insertions(+) create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/email/class-security-scan-findings.php create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Security_Scan_Notification_Test.php 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..1108de8903 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/email/class-security-scan-findings.php @@ -0,0 +1,157 @@ +args['record']; + + if ( 'blocked' === $record['action'] ) { + /* translators: 1: Plugin name. 2: Plugin version. */ + $subject = __( '%1$s %2$s has been blocked pending a security review', '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 = __( 'To protect sites, this version has been blocked from being offered as an update while the WordPress Plugin Review Team takes a closer look. Sites running a previous version keep receiving that version in the meantime. The team will release this version if it finds no cause for concern; you can also address the findings in a new version, which is not affected by this block.', 'wporg-plugins' ); + } else { + $action = __( 'No action has been taken against this version. Please review the findings and address them in an upcoming release.', 'wporg-plugins' ); + } + + $outro = __( '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", array_filter( [ $greeting, $intro, $action, $this->findings_text( $record ), $outro ] ) ); + } + + /** + * Format the findings as a list, highest risk first. + * + * Finding strings are untrusted scanner output; only the risk score is + * contractually guaranteed. Lines end in two spaces to hard-break in Markdown. + * + * @param array $record The completed scan record. + * @return string The findings list, 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 ); + + $item .= " \n " . $file_path . ( $line ? ':' . $line : '' ); + $item .= " \n " . $this->file_url( (string) $record['release_ref'], (string) $finding['file_path'], $line ); + } + + $items[] = $item; + } + + if ( ! $items ) { + return ''; + } + + return __( 'Findings:', 'wporg-plugins' ) . "\n\n" . implode( "\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; + } + + /** + * Collapse untrusted text onto a single bounded line, without markup. + * + * @param string $text The text to excerpt. + * @param int $length Maximum length in characters. + * @return string The excerpted text. + */ + private function excerpt( string $text, int $length ): string { + $text = wp_strip_all_tags( $text ); + + // Neutralize Markdown link and image syntax; the HTML variant renders Markdown. + $text = str_replace( [ '[', ']' ], [ '(', ')' ], $text ); + + return mb_strimwidth( preg_replace( '/\s+/u', ' ', trim( $text ) ), 0, $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..1c67b3684a 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,65 @@ 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 ); + + $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..5b039bc09b --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Security_Scan_Notification_Test.php @@ -0,0 +1,518 @@ +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. + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + + $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['bot_accounts'], $GLOBALS['phpmailer'] ); + + 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 pending a security review', $email['subject'] ); + $this->assertStringContainsString( self::VERSION, $email['subject'] ); + + $this->assertStringContainsString( 'blocked from being offered as an update', $email['message'] ); + $this->assertStringContainsString( '9.8', $email['message'] ); + $this->assertStringContainsString( 'Remote response controls a PHP callable', $email['message'] ); + $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'] + ); + } + + /** + * 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)' ) ), + ), + ) + ); + + Plugin_Scan_Gandalf::handle_callback( $this->plugin, $callback ); + + $this->assertCount( 1, $this->emails ); + $message = $this->emails[0]['message']; + + $this->assertStringNotContainsString( '', + ) + ), ), ) ); @@ -373,13 +386,17 @@ public function test_email_neutralizes_hostile_findings(): void { $this->assertCount( 1, $this->emails ); $message = $this->emails[0]['message']; + // Markup in a title renders as inert, escaped text. $this->assertStringNotContainsString( '" ) ), + ), + ) + ); + + Plugin_Scan_Gandalf::handle_callback( $this->plugin, $callback ); + + $message = $this->emails[0]['message']; + + $this->assertStringContainsString( '<script>alert(4)</script>', $message ); + $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( '', $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. */ @@ -637,6 +725,25 @@ public function test_bot_committers_are_not_emailed(): void { $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. */ @@ -653,6 +760,10 @@ public function test_minimal_finding_renders(): void { $this->assertTrue( Plugin_Scan_Gandalf::handle_callback( $this->plugin, $callback ) ); $this->assertCount( 1, $this->emails ); - $this->assertStringContainsString( '9.9', $this->emails[0]['message'] ); + $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 ); } } From 0e74372f9eb0302c35f66356fcd563ef52aca05e Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Thu, 27 Aug 2026 10:45:07 -0500 Subject: [PATCH 15/15] Plugin Directory: Restore the account-exclusion globals after each scan email test. tearDown() unset bot_accounts and nologin_accounts unconditionally, which would clobber a value set before the test ran. Capture each global's prior state in setUp() and restore it, matching the REMOTE_ADDR handling. Co-Authored-By: Claude Fable 5 --- .../tests/Security_Scan_Notification_Test.php | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) 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 index f8fe99aa7e..67dd850a84 100644 --- 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 @@ -93,6 +93,13 @@ class Security_Scan_Notification_Test extends TestCase { */ private ?string $remote_addr = null; + /** + * The account-exclusion globals the tests set, restored on teardown. + * + * @var array + */ + private array $account_globals = array(); + /** * Create a published plugin with a pending security scan and one committer. */ @@ -112,6 +119,11 @@ protected function setUp(): void { $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 ), @@ -184,7 +196,15 @@ protected function tearDown(): void { $this->threshold_filter = null; } - unset( $GLOBALS['bot_accounts'], $GLOBALS['nologin_accounts'], $GLOBALS['phpmailer'] ); + 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'] );