Commit 296015833f for wordpress.org

commit 296015833f7e852de762cefdf2cfbda810ef79ac
Author: whyisjake <whyisjake@git.wordpress.org>
Date:   Tue Nov 10 18:40:58 2026 +0000

    Security: Move Secrets API Site Health checks into `WP_Site_Health`.

    The Secrets API's Site Health checks and debug information lived in `wp-admin/includes/secrets-site-health.php`, as functions hooked onto `site_status_tests` and `debug_information`. That is how the feature plugin added them, and the file was carried over unchanged. Core's own checks are methods of `WP_Site_Health` and `WP_Debug_Data`, and these now are too.

    The three checks are `WP_Site_Health::get_test_secrets_api_key_source()`, `::get_test_secrets_api_undecryptable()` and `::get_test_secrets_api_needs_rotation()`, registered as direct tests in `WP_Site_Health::get_tests()` under the same IDs and labels. The debug information is `WP_Debug_Data::get_wp_secrets_api()`.

    The statuses, labels and descriptions of the checks are unchanged. Three things do change, to match the rest of core:

    * The debug information section key is `wp-secrets-api`, not `secrets-api`.
    * The results use the blue Security badge whatever the status.
    * The results include an `actions` key.

    `secrets-site-health.php` and the `wp_secrets_site_health_*()` functions are removed. None of them has been in a release.

    Follow-up to r64337, r65385, r65387.

    Props ericmann, peterwilsoncc, mindctrl.
    See #66187.

    Built from https://develop.svn.wordpress.org/trunk@65394


    git-svn-id: http://core.svn.wordpress.org/trunk@64533 1a063a9b-81f0-0310-95a4-ce76da25c4cd

diff --git a/wp-admin/includes/class-wp-debug-data.php b/wp-admin/includes/class-wp-debug-data.php
index ca85f0ebec..e218179151 100644
--- a/wp-admin/includes/class-wp-debug-data.php
+++ b/wp-admin/includes/class-wp-debug-data.php
@@ -57,6 +57,7 @@ class WP_Debug_Data {
 			'wp-server'           => self::get_wp_server(),
 			'wp-database'         => self::get_wp_database(),
 			'wp-constants'        => self::get_wp_constants(),
+			'wp-secrets-api'      => self::get_wp_secrets_api(),
 			'wp-filesystem'       => self::get_wp_filesystem(),
 		);

@@ -335,6 +336,80 @@ class WP_Debug_Data {
 		);
 	}

+	/**
+	 * Gets the Secrets API section of the debug data.
+	 *
+	 * Counts and class names only – no secret values, and no fingerprints. Network
+	 * scope figures are included only for a super admin on a multisite install.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return array<string, string|array> The Secrets API debug data.
+	 */
+	private static function get_wp_secrets_api(): array {
+		$key_manager = _wp_secrets_get_key_manager();
+		$provider    = _wp_secrets_get_provider();
+
+		// A provider from a secrets.php drop-in protects secrets with its own keys, not the keyring.
+		$uses_keyring = $provider instanceof WP_Secrets_Libsodium_Provider || $provider instanceof WP_Secrets_Broken_Provider;
+
+		$fields = array(
+			'dropin_active'  => array(
+				'label' => __( 'Drop-in active' ),
+				'value' => wp_using_secrets_dropin() ? __( 'Yes' ) : __( 'No' ),
+			),
+			'provider_class' => array(
+				'label' => __( 'Provider class' ),
+				'value' => get_class( $provider ),
+			),
+			'protected_by'   => array(
+				'label' => __( 'Secrets protected by' ),
+				'value' => $provider->get_label(),
+			),
+			'protection_at'  => array(
+				'label' => __( 'Encryption boundary' ),
+				'value' => WP_Secrets_Provider::BOUNDARY_WORDPRESS === $provider->get_protection_boundary()
+					? __( 'WordPress' )
+					: __( 'The provider (outside WordPress)' ),
+			),
+			'writable'       => array(
+				'label' => __( 'Accepts writes' ),
+				'value' => $provider->is_writable() ? __( 'Yes' ) : __( 'No' ),
+			),
+			'keyring_class'  => array(
+				'label' => __( 'Keyring class' ),
+				'value' => $uses_keyring ? get_class( $key_manager->get_keyring() ) : __( 'Not used by the provider' ),
+			),
+			'key_source'     => array(
+				'label' => __( 'Key source' ),
+				'value' => $uses_keyring ? $key_manager->get_keyring()->get_key_source() : __( 'Not used by the provider' ),
+			),
+			'record_version' => array(
+				'label' => __( 'Record format version' ),
+				'value' => (string) WP_SECRETS_RECORD_VERSION,
+			),
+		);
+
+		$site_secrets                = wp_list_secrets();
+		$fields['site_secret_count'] = array(
+			'label' => __( 'Site secrets' ),
+			'value' => is_wp_error( $site_secrets ) ? __( 'Unavailable' ) : (string) count( $site_secrets ),
+		);
+
+		if ( is_multisite() && is_super_admin() ) {
+			$network_secrets                = wp_list_network_secrets();
+			$fields['network_secret_count'] = array(
+				'label' => __( 'Network secrets' ),
+				'value' => is_wp_error( $network_secrets ) ? __( 'Unavailable' ) : (string) count( $network_secrets ),
+			);
+		}
+
+		return array(
+			'label'  => __( 'Secrets API' ),
+			'fields' => $fields,
+		);
+	}
+
 	/**
 	 * Gets the WordPress server section of the debug data.
 	 *
diff --git a/wp-admin/includes/class-wp-site-health.php b/wp-admin/includes/class-wp-site-health.php
index 17a4a190cd..4c4ec2e96f 100644
--- a/wp-admin/includes/class-wp-site-health.php
+++ b/wp-admin/includes/class-wp-site-health.php
@@ -7,8 +7,6 @@
  * @since 5.2.0
  */

-require_once __DIR__ . '/secrets-site-health.php';
-
 #[AllowDynamicProperties]
 class WP_Site_Health {
 	private static $instance = null;
@@ -2837,6 +2835,408 @@ class WP_Site_Health {
 		return $result;
 	}

+	/**
+	 * Tests whether the Secrets API has a usable key.
+	 *
+	 * There is no settings screen for secrets, so this is how an administrator notices
+	 * a missing or broken key, or a key that no longer unlocks the stored secrets.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return array The test result.
+	 *
+	 * @phpstan-return array{label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string, actions: string, test: string}
+	 */
+	public function get_test_secrets_api_key_source(): array {
+		$provider = _wp_secrets_get_provider();
+
+		if ( ! $this->secrets_api_uses_keyring( $provider ) ) {
+			return $this->get_secrets_api_test_result(
+				'secrets_api_key_source',
+				__( 'Secrets API keys are managed by the provider' ),
+				'good',
+				sprintf(
+					'<p>%s</p>',
+					sprintf(
+						/* translators: 1: secrets.php, 2: Name of the active secrets provider, 3: WP_SECRETS_KEY */
+						__( 'Secrets are protected by the provider of the %1$s drop-in: %2$s. %3$s is not used.' ),
+						'<code>secrets.php</code>',
+						esc_html( $provider->get_label() ),
+						'<code>WP_SECRETS_KEY</code>'
+					)
+				)
+			);
+		}
+
+		$keyring    = _wp_secrets_get_key_manager()->get_keyring();
+		$key_source = $keyring->get_key_source();
+		$key_status = $keyring instanceof WP_Secrets_Config_Key_Provider ? $keyring->get_key_status() : null;
+
+		// The broken keyring's description is translated, so check its class rather than its text.
+		if ( $keyring instanceof WP_Secrets_Broken_Keyring ) {
+			return $this->get_secrets_api_test_result(
+				'secrets_api_key_source',
+				__( 'Secrets API key source is broken' ),
+				'critical',
+				sprintf(
+					'<p>%s</p>',
+					esc_html__( 'The active keyring did not load correctly. No secret can be encrypted or decrypted until this is fixed.' )
+				)
+			);
+		}
+
+		/*
+		 * Once a root key exists, it is wrapped with the current site key, and core has no way
+		 * to re-wrap it under another one. Changing the key source then locks every stored
+		 * secret, and blocks saving new ones, until the change is reverted.
+		 */
+		$has_root_key = false !== get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION );
+
+		if ( $has_root_key ) {
+			$root_key = _wp_secrets_get_key_manager()->get_root_key();
+
+			if ( is_wp_error( $root_key ) ) {
+				/*
+				 * Only the default keyring reads wp-config.php. A keyring from a secrets.php
+				 * drop-in fails for its own reasons, such as an unreachable key service, and
+				 * its error is the useful part.
+				 */
+				$cause = $keyring instanceof WP_Secrets_Config_Key_Provider
+					? sprintf(
+						/* translators: %s: WP_SECRETS_KEY */
+						__( 'This happens when %s was changed or removed after secrets were stored. Restoring the previous value fixes it.' ),
+						'<code>WP_SECRETS_KEY</code>'
+					)
+					: sprintf(
+						/* translators: 1: secrets.php, 2: Error message from the keyring. */
+						__( 'The keyring of the %1$s drop-in reported: %2$s' ),
+						'<code>secrets.php</code>',
+						esc_html( $root_key->get_error_message() )
+					);
+
+				return $this->get_secrets_api_test_result(
+					'secrets_api_key_source',
+					__( 'Secrets API cannot unlock the stored secrets' ),
+					'critical',
+					sprintf(
+						'<p>%s</p><p>%s</p>',
+						esc_html__( 'The configured key cannot decrypt the root key that protects the stored secrets, so no secret can be read or saved.' ),
+						$cause
+					)
+				);
+			}
+
+			wp_secrets_memzero( $root_key );
+		}
+
+		if ( WP_Secrets_Config_Key_Provider::KEY_STATUS_LEGACY === $key_status ) {
+			return $this->get_secrets_api_test_result(
+				'secrets_api_key_source',
+				__( 'Secrets API key is not in the recommended form' ),
+				'recommended',
+				sprintf(
+					'<p>%s</p>',
+					$has_root_key
+						? sprintf(
+							/* translators: %s: WP_SECRETS_KEY */
+							__( '%s is defined, but not as a base64-encoded random 32-byte value. Keep its current value: secrets are already encrypted with it, and changing it makes them unreadable.' ),
+							'<code>WP_SECRETS_KEY</code>'
+						)
+						: sprintf(
+							/* translators: %s: WP_SECRETS_KEY */
+							__( '%s is defined, but not as a base64-encoded random 32-byte value. No secrets are stored yet, so it can still be replaced with such a value.' ),
+							'<code>WP_SECRETS_KEY</code>'
+						)
+				)
+			);
+		}
+
+		if ( WP_Secrets_Config_Key_Provider::KEY_STATUS_UNDEFINED === $key_status || WP_Secrets_Config_Key_Provider::KEY_STATUS_UNUSABLE === $key_status ) {
+			$is_defined = WP_Secrets_Config_Key_Provider::KEY_STATUS_UNUSABLE === $key_status;
+			$example    = sprintf( "define( 'WP_SECRETS_KEY', '%s' );", base64_encode( random_bytes( 32 ) ) );
+
+			return $this->get_secrets_api_test_result(
+				'secrets_api_key_source',
+				__( 'Secrets API has no encryption key' ),
+				'recommended',
+				sprintf(
+					'<p>%s</p><p>%s</p><p><code>%s</code></p><p>%s</p>',
+					$is_defined
+						? sprintf(
+							/* translators: 1: WP_SECRETS_KEY, 2: wp-config.php, 3: wp-config-sample.php */
+							__( '%1$s is defined in %2$s, but its value cannot be used: it is empty, still the placeholder from %3$s, or the same as one of the authentication keys and salts. Plugins cannot store API keys and other credentials with the Secrets API until it is fixed.' ),
+							'<code>WP_SECRETS_KEY</code>',
+							'<code>wp-config.php</code>',
+							'<code>wp-config-sample.php</code>'
+						)
+						: sprintf(
+							/* translators: 1: WP_SECRETS_KEY, 2: wp-config.php */
+							__( '%1$s is not defined in %2$s. Plugins cannot store API keys and other credentials with the Secrets API until it is.' ),
+							'<code>WP_SECRETS_KEY</code>',
+							'<code>wp-config.php</code>'
+						),
+					$is_defined
+						? sprintf(
+							/* translators: 1: WP_SECRETS_KEY, 2: wp-config.php */
+							__( 'Replace the %1$s line in %2$s with this one:' ),
+							'<code>WP_SECRETS_KEY</code>',
+							'<code>wp-config.php</code>'
+						)
+						: sprintf(
+							/* translators: 1: wp-config.php, 2: Translated version of "That's all, stop editing! Happy publishing." */
+							__( 'Add this line to %1$s, above the line reading %2$s:' ),
+							'<code>wp-config.php</code>',
+							'<code>/* ' . __( 'That&#8217;s all, stop editing! Happy publishing.' ) . ' */</code>'
+						),
+					esc_html( $example ),
+					esc_html__( 'A new value is generated each time this page loads. Keep a copy of the one you use with your backups: secrets stored with it cannot be read without it.' )
+				)
+			);
+		}
+
+		return $this->get_secrets_api_test_result(
+			'secrets_api_key_source',
+			__( 'Secrets API is using a dedicated key' ),
+			'good',
+			sprintf( '<p>%s</p>', esc_html( $key_source ) )
+		);
+	}
+
+	/**
+	 * Tests whether every stored secret still decrypts.
+	 *
+	 * Network secrets are included only for a super admin on a multisite install –
+	 * never shown to a site administrator who is not one.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return array The test result.
+	 *
+	 * @phpstan-return array{label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string, actions: string, test: string}
+	 */
+	public function get_test_secrets_api_undecryptable(): array {
+		$broken = $this->get_undecryptable_secrets( false );
+
+		if ( ! is_wp_error( $broken ) && is_multisite() && is_super_admin() ) {
+			$network_broken = $this->get_undecryptable_secrets( true );
+			$broken         = is_wp_error( $network_broken ) ? $network_broken : array_merge( $broken, $network_broken );
+		}
+
+		// Secrets that cannot be listed cannot be checked, which must not read as all of them decrypting.
+		if ( is_wp_error( $broken ) ) {
+			return $this->get_secrets_api_test_result(
+				'secrets_api_undecryptable',
+				__( 'Secrets could not be checked' ),
+				'critical',
+				sprintf(
+					'<p>%s</p><p>%s</p>',
+					esc_html__( 'The stored secrets could not be listed, so none of them could be checked. Secrets probably cannot be read or written either until this is fixed.' ),
+					esc_html( $broken->get_error_message() )
+				)
+			);
+		}
+
+		if ( empty( $broken ) ) {
+			return $this->get_secrets_api_test_result(
+				'secrets_api_undecryptable',
+				__( 'All secrets can be decrypted' ),
+				'good',
+				sprintf( '<p>%s</p>', esc_html__( 'Every stored secret decrypted successfully.' ) )
+			);
+		}
+
+		$items = '';
+
+		foreach ( $broken as $entry ) {
+			$items .= sprintf(
+				'<li>%s (%s)</li>',
+				esc_html( $entry['name'] ),
+				sprintf(
+					/* translators: %s: A secret's last known fingerprint. */
+					esc_html__( 'fingerprint: %s' ),
+					esc_html( $entry['fingerprint'] )
+				)
+			);
+		}
+
+		return $this->get_secrets_api_test_result(
+			'secrets_api_undecryptable',
+			__( 'Some secrets cannot be decrypted' ),
+			'critical',
+			sprintf(
+				'<p>%s</p><ul>%s</ul><p>%s</p>',
+				esc_html__( 'The following secrets exist but could not be decrypted with the current key.' ),
+				$items,
+				sprintf(
+					/* translators: %s: WP_SECRETS_KEY */
+					__( 'This can happen after %s was changed or removed, after restoring a database backup without its matching key, or after a failed key rotation. Restoring the previous key makes the secrets readable again. Otherwise, each credential must be entered again.' ),
+					'<code>WP_SECRETS_KEY</code>'
+				)
+			)
+		);
+	}
+
+	/**
+	 * Tests whether any secrets are flagged as needing rotation.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return array The test result.
+	 *
+	 * @phpstan-return array{label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string, actions: string, test: string}
+	 */
+	public function get_test_secrets_api_needs_rotation(): array {
+		$count = $this->count_secrets_needing_rotation( false );
+
+		if ( ! is_wp_error( $count ) && is_multisite() && is_super_admin() ) {
+			$network_count = $this->count_secrets_needing_rotation( true );
+			$count         = is_wp_error( $network_count ) ? $network_count : $count + $network_count;
+		}
+
+		if ( is_wp_error( $count ) ) {
+			return $this->get_secrets_api_test_result(
+				'secrets_api_needs_rotation',
+				__( 'Secrets could not be checked for pending rotation' ),
+				'recommended',
+				sprintf( '<p>%s</p>', esc_html( $count->get_error_message() ) )
+			);
+		}
+
+		if ( 0 === $count ) {
+			return $this->get_secrets_api_test_result(
+				'secrets_api_needs_rotation',
+				__( 'No secrets are pending rotation' ),
+				'good',
+				sprintf( '<p>%s</p>', esc_html__( 'No secret is flagged as needing rotation.' ) )
+			);
+		}
+
+		return $this->get_secrets_api_test_result(
+			'secrets_api_needs_rotation',
+			__( 'Some secrets are pending rotation' ),
+			'recommended',
+			sprintf(
+				'<p>%s</p>',
+				esc_html(
+					sprintf(
+						/* translators: %d: Number of secrets flagged for rotation. */
+						_n(
+							'%d secret was imported from an existing option and is flagged for rotation. A credential that sat in a plain option has already been through whatever backups and replication paths that option went through; re-entering it with a new value is recommended.',
+							'%d secrets were imported from existing options and are flagged for rotation. A credential that sat in a plain option has already been through whatever backups and replication paths that option went through; re-entering each with a new value is recommended.',
+							$count
+						),
+						$count
+					)
+				)
+			)
+		);
+	}
+
+	/**
+	 * Builds the result of a Secrets API test.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $test        The test's identifier, as registered in get_tests().
+	 * @param string $label       Short label for the test result.
+	 * @param string $status      One of 'good', 'recommended', 'critical'.
+	 * @param string $description HTML description, normally one or more <p> elements.
+	 * @return array The test result.
+	 *
+	 * @phpstan-param 'good'|'recommended'|'critical' $status
+	 * @phpstan-return array{label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string, actions: string, test: string}
+	 */
+	private function get_secrets_api_test_result( string $test, string $label, string $status, string $description ): array {
+		return array(
+			'label'       => $label,
+			'status'      => $status,
+			'badge'       => array(
+				'label' => __( 'Security' ),
+				'color' => 'blue',
+			),
+			'description' => $description,
+			'actions'     => '',
+			'test'        => $test,
+		);
+	}
+
+	/**
+	 * Determines whether the active secrets provider protects secrets with the keyring.
+	 *
+	 * A secrets.php drop-in can replace the whole provider, which then protects secrets
+	 * with its own keys. The default keyring, and WP_SECRETS_KEY, are not used at all.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param WP_Secrets_Provider $provider The active provider.
+	 * @return bool
+	 */
+	private function secrets_api_uses_keyring( WP_Secrets_Provider $provider ): bool {
+		return $provider instanceof WP_Secrets_Libsodium_Provider || $provider instanceof WP_Secrets_Broken_Provider;
+	}
+
+	/**
+	 * Finds the stored secrets that fail to decrypt, for one scope.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param bool $network Whether to check network-scope secrets.
+	 * @return array|WP_Error List of array( 'name' => ..., 'fingerprint' => ... ),
+	 *                        or WP_Error when the secrets cannot be listed.
+	 *
+	 * @phpstan-return list<array{name: string, fingerprint: string}>|WP_Error
+	 */
+	private function get_undecryptable_secrets( bool $network ) {
+		$entries = $network ? wp_list_network_secrets() : wp_list_secrets();
+
+		if ( is_wp_error( $entries ) ) {
+			return $entries;
+		}
+
+		$broken = array();
+
+		foreach ( $entries as $entry ) {
+			$secret = $network ? wp_get_network_secret( $entry['name'] ) : wp_get_secret( $entry['name'] );
+
+			if ( is_wp_error( $secret ) ) {
+				$broken[] = array(
+					'name'        => $entry['name'],
+					'fingerprint' => $entry['fingerprint'],
+				);
+			}
+		}
+
+		return $broken;
+	}
+
+	/**
+	 * Counts the stored secrets flagged as needing rotation, for one scope.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param bool $network Whether to check network-scope secrets.
+	 * @return int|WP_Error The number of secrets, or WP_Error when the secrets cannot be listed.
+	 *
+	 * @phpstan-return int<0, max>|WP_Error
+	 */
+	private function count_secrets_needing_rotation( bool $network ) {
+		$entries = $network ? wp_list_network_secrets() : wp_list_secrets();
+
+		if ( is_wp_error( $entries ) ) {
+			return $entries;
+		}
+
+		$count = 0;
+
+		foreach ( $entries as $entry ) {
+			if ( ! empty( $entry['needs_rotation'] ) ) {
+				++$count;
+			}
+		}
+
+		return $count;
+	}
+
 	/**
 	 * Returns a set of tests that belong to the site status page.
 	 *
@@ -2937,6 +3337,18 @@ class WP_Site_Health {
 					'label' => __( 'Opcode cache' ),
 					'test'  => 'opcode_cache',
 				),
+				'secrets_api_key_source'       => array(
+					'label' => __( 'Secrets API key source' ),
+					'test'  => 'secrets_api_key_source',
+				),
+				'secrets_api_undecryptable'    => array(
+					'label' => __( 'Secrets API: undecryptable secrets' ),
+					'test'  => 'secrets_api_undecryptable',
+				),
+				'secrets_api_needs_rotation'   => array(
+					'label' => __( 'Secrets API: credentials pending rotation' ),
+					'test'  => 'secrets_api_needs_rotation',
+				),
 			),
 			'async'  => array(
 				'dotorg_communication' => array(
diff --git a/wp-admin/includes/secrets-site-health.php b/wp-admin/includes/secrets-site-health.php
deleted file mode 100644
index bbf97da012..0000000000
--- a/wp-admin/includes/secrets-site-health.php
+++ /dev/null
@@ -1,520 +0,0 @@
-<?php
-/**
- * Secrets API: Site Health integration
- *
- * @package WordPress
- * @subpackage Secrets
- * @since 7.2.0
- */
-
-/**
- * Registers the Secrets API's Site Health tests.
- *
- * There is no settings screen for secrets, so these tests are how an operator
- * notices a broken key, a weak key source, or a pending rotation.
- *
- * @since 7.2.0
- *
- * @param array<string, array<string, array<string, mixed>>> $tests Existing Site Health tests.
- * @return array<string, array<string, array<string, mixed>>>
- */
-function wp_secrets_site_health_tests( $tests ) {
-	$tests['direct']['secrets_api_key_source'] = array(
-		'label' => __( 'Secrets API key source' ),
-		'test'  => 'wp_secrets_site_health_test_key_source',
-	);
-
-	$tests['direct']['secrets_api_undecryptable'] = array(
-		'label' => __( 'Secrets API: undecryptable secrets' ),
-		'test'  => 'wp_secrets_site_health_test_undecryptable',
-	);
-
-	$tests['direct']['secrets_api_needs_rotation'] = array(
-		'label' => __( 'Secrets API: credentials pending rotation' ),
-		'test'  => 'wp_secrets_site_health_test_needs_rotation',
-	);
-
-	return $tests;
-}
-add_filter( 'site_status_tests', 'wp_secrets_site_health_tests' );
-
-/**
- * Builds the standard shape a Site Health test callback returns.
- *
- * @since 7.2.0
- *
- * @param string $test        The test's own identifier, matching the key registered
- *                            in wp_secrets_site_health_tests().
- * @param string $label       Short label for the test result.
- * @param string $status      One of 'good', 'recommended', 'critical'.
- * @param string $description HTML description, normally one or more <p> elements.
- * @return array
- *
- * @phpstan-return array{test: string, label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string}
- * @phpstan-param 'good'|'recommended'|'critical' $status
- */
-function wp_secrets_site_health_result( string $test, string $label, string $status, string $description ): array {
-	$colors = array(
-		'good'        => 'green',
-		'recommended' => 'orange',
-		'critical'    => 'red',
-	);
-
-	return array(
-		'test'        => $test,
-		'label'       => $label,
-		'status'      => $status,
-		'badge'       => array(
-			'label' => __( 'Security' ),
-			'color' => $colors[ $status ] ?? 'blue',
-		),
-		'description' => $description,
-	);
-}
-
-/**
- * Whether the active provider protects secrets with the keyring.
- *
- * A secrets.php drop-in can replace the whole provider, which then protects secrets
- * with its own keys. The default keyring, and WP_SECRETS_KEY, are not used at all.
- *
- * @since 7.2.0
- *
- * @param WP_Secrets_Provider $provider The active provider.
- * @return bool
- */
-function wp_secrets_site_health_uses_keyring( WP_Secrets_Provider $provider ): bool {
-	return $provider instanceof WP_Secrets_Libsodium_Provider || $provider instanceof WP_Secrets_Broken_Provider;
-}
-
-/**
- * Tests whether the site key is defined, and in the recommended form.
- *
- * @since 7.2.0
- *
- * @return array
- *
- * @phpstan-return array{test: string, label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string}
- */
-function wp_secrets_site_health_test_key_source(): array {
-	$provider = _wp_secrets_get_provider();
-
-	if ( ! wp_secrets_site_health_uses_keyring( $provider ) ) {
-		return wp_secrets_site_health_result(
-			'secrets_api_key_source',
-			__( 'Secrets API keys are managed by the provider' ),
-			'good',
-			sprintf(
-				'<p>%s</p>',
-				sprintf(
-					/* translators: 1: secrets.php, 2: Name of the active secrets provider, 3: WP_SECRETS_KEY */
-					__( 'Secrets are protected by the provider of the %1$s drop-in: %2$s. %3$s is not used.' ),
-					'<code>secrets.php</code>',
-					esc_html( $provider->get_label() ),
-					'<code>WP_SECRETS_KEY</code>'
-				)
-			)
-		);
-	}
-
-	$keyring    = _wp_secrets_get_key_manager()->get_keyring();
-	$key_source = $keyring->get_key_source();
-	$key_status = $keyring instanceof WP_Secrets_Config_Key_Provider ? $keyring->get_key_status() : null;
-
-	// The broken keyring's description is translated, so check its class rather than its text.
-	if ( $keyring instanceof WP_Secrets_Broken_Keyring ) {
-		return wp_secrets_site_health_result(
-			'secrets_api_key_source',
-			__( 'Secrets API key source is broken' ),
-			'critical',
-			sprintf(
-				'<p>%s</p>',
-				esc_html__( 'The active keyring did not load correctly. No secret can be encrypted or decrypted until this is fixed.' )
-			)
-		);
-	}
-
-	/*
-	 * Once a root key exists, it is wrapped with the current site key, and core has no way
-	 * to re-wrap it under another one. Changing the key source then locks every stored
-	 * secret, and blocks saving new ones, until the change is reverted.
-	 */
-	$has_root_key = false !== get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION );
-
-	if ( $has_root_key ) {
-		$root_key = _wp_secrets_get_key_manager()->get_root_key();
-
-		if ( is_wp_error( $root_key ) ) {
-			/*
-			 * Only the default keyring reads wp-config.php. A keyring from a secrets.php
-			 * drop-in fails for its own reasons, such as an unreachable key service, and
-			 * its error is the useful part.
-			 */
-			$cause = $keyring instanceof WP_Secrets_Config_Key_Provider
-				? sprintf(
-					/* translators: %s: WP_SECRETS_KEY */
-					__( 'This happens when %s was changed or removed after secrets were stored. Restoring the previous value fixes it.' ),
-					'<code>WP_SECRETS_KEY</code>'
-				)
-				: sprintf(
-					/* translators: 1: secrets.php, 2: Error message from the keyring. */
-					__( 'The keyring of the %1$s drop-in reported: %2$s' ),
-					'<code>secrets.php</code>',
-					esc_html( $root_key->get_error_message() )
-				);
-
-			return wp_secrets_site_health_result(
-				'secrets_api_key_source',
-				__( 'Secrets API cannot unlock the stored secrets' ),
-				'critical',
-				sprintf(
-					'<p>%s</p><p>%s</p>',
-					esc_html__( 'The configured key cannot decrypt the root key that protects the stored secrets, so no secret can be read or saved.' ),
-					$cause
-				)
-			);
-		}
-
-		wp_secrets_memzero( $root_key );
-	}
-
-	if ( WP_Secrets_Config_Key_Provider::KEY_STATUS_LEGACY === $key_status ) {
-		return wp_secrets_site_health_result(
-			'secrets_api_key_source',
-			__( 'Secrets API key is not in the recommended form' ),
-			'recommended',
-			sprintf(
-				'<p>%s</p>',
-				$has_root_key
-					? sprintf(
-						/* translators: %s: WP_SECRETS_KEY */
-						__( '%s is defined, but not as a base64-encoded random 32-byte value. Keep its current value: secrets are already encrypted with it, and changing it makes them unreadable.' ),
-						'<code>WP_SECRETS_KEY</code>'
-					)
-					: sprintf(
-						/* translators: %s: WP_SECRETS_KEY */
-						__( '%s is defined, but not as a base64-encoded random 32-byte value. No secrets are stored yet, so it can still be replaced with such a value.' ),
-						'<code>WP_SECRETS_KEY</code>'
-					)
-			)
-		);
-	}
-
-	if ( WP_Secrets_Config_Key_Provider::KEY_STATUS_UNDEFINED === $key_status || WP_Secrets_Config_Key_Provider::KEY_STATUS_UNUSABLE === $key_status ) {
-		$is_defined = WP_Secrets_Config_Key_Provider::KEY_STATUS_UNUSABLE === $key_status;
-		$example    = sprintf( "define( 'WP_SECRETS_KEY', '%s' );", base64_encode( random_bytes( 32 ) ) );
-
-		return wp_secrets_site_health_result(
-			'secrets_api_key_source',
-			__( 'Secrets API has no encryption key' ),
-			'recommended',
-			sprintf(
-				'<p>%s</p><p>%s</p><p><code>%s</code></p><p>%s</p>',
-				$is_defined
-					? sprintf(
-						/* translators: 1: WP_SECRETS_KEY, 2: wp-config.php, 3: wp-config-sample.php */
-						__( '%1$s is defined in %2$s, but its value cannot be used: it is empty, still the placeholder from %3$s, or the same as one of the authentication keys and salts. Plugins cannot store API keys and other credentials with the Secrets API until it is fixed.' ),
-						'<code>WP_SECRETS_KEY</code>',
-						'<code>wp-config.php</code>',
-						'<code>wp-config-sample.php</code>'
-					)
-					: sprintf(
-						/* translators: 1: WP_SECRETS_KEY, 2: wp-config.php */
-						__( '%1$s is not defined in %2$s. Plugins cannot store API keys and other credentials with the Secrets API until it is.' ),
-						'<code>WP_SECRETS_KEY</code>',
-						'<code>wp-config.php</code>'
-					),
-				$is_defined
-					? sprintf(
-						/* translators: 1: WP_SECRETS_KEY, 2: wp-config.php */
-						__( 'Replace the %1$s line in %2$s with this one:' ),
-						'<code>WP_SECRETS_KEY</code>',
-						'<code>wp-config.php</code>'
-					)
-					: sprintf(
-						/* translators: 1: wp-config.php, 2: Translated version of "That's all, stop editing! Happy publishing." */
-						__( 'Add this line to %1$s, above the line reading %2$s:' ),
-						'<code>wp-config.php</code>',
-						'<code>/* ' . __( 'That&#8217;s all, stop editing! Happy publishing.' ) . ' */</code>'
-					),
-				esc_html( $example ),
-				esc_html__( 'A new value is generated each time this page loads. Keep a copy of the one you use with your backups: secrets stored with it cannot be read without it.' )
-			)
-		);
-	}
-
-	return wp_secrets_site_health_result(
-		'secrets_api_key_source',
-		__( 'Secrets API is using a dedicated key' ),
-		'good',
-		sprintf( '<p>%s</p>', esc_html( $key_source ) )
-	);
-}
-
-/**
- * Tests whether every stored secret still decrypts.
- *
- * Network secrets are included only for a super admin on a multisite install –
- * never shown to a site administrator who is not one.
- *
- * @since 7.2.0
- *
- * @return array
- *
- * @phpstan-return array{test: string, label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string}
- */
-function wp_secrets_site_health_test_undecryptable(): array {
-	$broken = wp_secrets_site_health_find_undecryptable( false );
-
-	if ( ! is_wp_error( $broken ) && is_multisite() && is_super_admin() ) {
-		$network_broken = wp_secrets_site_health_find_undecryptable( true );
-		$broken         = is_wp_error( $network_broken ) ? $network_broken : array_merge( $broken, $network_broken );
-	}
-
-	// Secrets that cannot be listed cannot be checked, which must not read as all of them decrypting.
-	if ( is_wp_error( $broken ) ) {
-		return wp_secrets_site_health_result(
-			'secrets_api_undecryptable',
-			__( 'Secrets could not be checked' ),
-			'critical',
-			sprintf(
-				'<p>%s</p><p>%s</p>',
-				esc_html__( 'The stored secrets could not be listed, so none of them could be checked. Secrets probably cannot be read or written either until this is fixed.' ),
-				esc_html( $broken->get_error_message() )
-			)
-		);
-	}
-
-	if ( empty( $broken ) ) {
-		return wp_secrets_site_health_result(
-			'secrets_api_undecryptable',
-			__( 'All secrets can be decrypted' ),
-			'good',
-			sprintf( '<p>%s</p>', esc_html__( 'Every stored secret decrypted successfully.' ) )
-		);
-	}
-
-	$items = '';
-
-	foreach ( $broken as $entry ) {
-		$items .= sprintf(
-			'<li>%s (%s)</li>',
-			esc_html( $entry['name'] ),
-			sprintf(
-				/* translators: %s: A secret's last known fingerprint. */
-				esc_html__( 'fingerprint: %s' ),
-				esc_html( $entry['fingerprint'] )
-			)
-		);
-	}
-
-	return wp_secrets_site_health_result(
-		'secrets_api_undecryptable',
-		__( 'Some secrets cannot be decrypted' ),
-		'critical',
-		sprintf(
-			'<p>%s</p><ul>%s</ul><p>%s</p>',
-			esc_html__( 'The following secrets exist but could not be decrypted with the current key.' ),
-			$items,
-			sprintf(
-				/* translators: %s: WP_SECRETS_KEY */
-				__( 'This can happen after %s was changed or removed, after restoring a database backup without its matching key, or after a failed key rotation. Restoring the previous key makes the secrets readable again. Otherwise, each credential must be entered again.' ),
-				'<code>WP_SECRETS_KEY</code>'
-			)
-		)
-	);
-}
-
-/**
- * Finds secrets that fail to decrypt, for one scope.
- *
- * @since 7.2.0
- *
- * @param bool $network Whether to check network-scope secrets.
- * @return array|WP_Error List of array( 'name' => ..., 'fingerprint' => ... ),
- *                        or WP_Error when the secrets cannot be listed.
- *
- * @phpstan-return list<array{name: string, fingerprint: string}>|WP_Error
- */
-function wp_secrets_site_health_find_undecryptable( bool $network ) {
-	$entries = $network ? wp_list_network_secrets() : wp_list_secrets();
-
-	if ( is_wp_error( $entries ) ) {
-		return $entries;
-	}
-
-	$broken = array();
-
-	foreach ( $entries as $entry ) {
-		$secret = $network ? wp_get_network_secret( $entry['name'] ) : wp_get_secret( $entry['name'] );
-
-		if ( is_wp_error( $secret ) ) {
-			$broken[] = array(
-				'name'        => $entry['name'],
-				'fingerprint' => $entry['fingerprint'],
-			);
-		}
-	}
-
-	return $broken;
-}
-
-/**
- * Tests whether any secrets are flagged as needing rotation.
- *
- * @since 7.2.0
- *
- * @return array
- *
- * @phpstan-return array{test: string, label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string}
- */
-function wp_secrets_site_health_test_needs_rotation(): array {
-	$count = wp_secrets_site_health_count_needing_rotation( false );
-
-	if ( ! is_wp_error( $count ) && is_multisite() && is_super_admin() ) {
-		$network_count = wp_secrets_site_health_count_needing_rotation( true );
-		$count         = is_wp_error( $network_count ) ? $network_count : $count + $network_count;
-	}
-
-	if ( is_wp_error( $count ) ) {
-		return wp_secrets_site_health_result(
-			'secrets_api_needs_rotation',
-			__( 'Secrets could not be checked for pending rotation' ),
-			'recommended',
-			sprintf( '<p>%s</p>', esc_html( $count->get_error_message() ) )
-		);
-	}
-
-	if ( 0 === $count ) {
-		return wp_secrets_site_health_result(
-			'secrets_api_needs_rotation',
-			__( 'No secrets are pending rotation' ),
-			'good',
-			sprintf( '<p>%s</p>', esc_html__( 'No secret is flagged as needing rotation.' ) )
-		);
-	}
-
-	return wp_secrets_site_health_result(
-		'secrets_api_needs_rotation',
-		__( 'Some secrets are pending rotation' ),
-		'recommended',
-		sprintf(
-			'<p>%s</p>',
-			esc_html(
-				sprintf(
-					/* translators: %d: Number of secrets flagged for rotation. */
-					_n(
-						'%d secret was imported from an existing option and is flagged for rotation. A credential that sat in a plain option has already been through whatever backups and replication paths that option went through; re-entering it with a new value is recommended.',
-						'%d secrets were imported from existing options and are flagged for rotation. A credential that sat in a plain option has already been through whatever backups and replication paths that option went through; re-entering each with a new value is recommended.',
-						$count
-					),
-					$count
-				)
-			)
-		)
-	);
-}
-
-/**
- * Counts secrets flagged needs_rotation, for one scope.
- *
- * @since 7.2.0
- *
- * @param bool $network Whether to check network-scope secrets.
- * @return int|WP_Error The number of secrets, or WP_Error when the secrets cannot be listed.
- *
- * @phpstan-return int<0, max>|WP_Error
- */
-function wp_secrets_site_health_count_needing_rotation( bool $network ) {
-	$entries = $network ? wp_list_network_secrets() : wp_list_secrets();
-
-	if ( is_wp_error( $entries ) ) {
-		return $entries;
-	}
-
-	$count = 0;
-
-	foreach ( $entries as $entry ) {
-		if ( ! empty( $entry['needs_rotation'] ) ) {
-			++$count;
-		}
-	}
-
-	return $count;
-}
-
-/**
- * Adds a Secrets API section to Site Health's debug information.
- *
- * Counts and class names only – no secret values, and no fingerprints. Network
- * scope figures are included only for a super admin on a multisite install.
- *
- * @since 7.2.0
- *
- * @param array<string, array<string, mixed>> $info Existing debug information sections.
- * @return array<string, array<string, mixed>>
- */
-function wp_secrets_site_health_debug_info( $info ) {
-	$key_manager  = _wp_secrets_get_key_manager();
-	$provider     = _wp_secrets_get_provider();
-	$uses_keyring = wp_secrets_site_health_uses_keyring( $provider );
-
-	$fields = array(
-		'dropin_active'  => array(
-			'label' => __( 'Drop-in active' ),
-			'value' => wp_using_secrets_dropin() ? __( 'Yes' ) : __( 'No' ),
-		),
-		'provider_class' => array(
-			'label' => __( 'Provider class' ),
-			'value' => get_class( $provider ),
-		),
-		'protected_by'   => array(
-			'label' => __( 'Secrets protected by' ),
-			'value' => $provider->get_label(),
-		),
-		'protection_at'  => array(
-			'label' => __( 'Encryption boundary' ),
-			'value' => WP_Secrets_Provider::BOUNDARY_WORDPRESS === $provider->get_protection_boundary()
-				? __( 'WordPress' )
-				: __( 'The provider (outside WordPress)' ),
-		),
-		'writable'       => array(
-			'label' => __( 'Accepts writes' ),
-			'value' => $provider->is_writable() ? __( 'Yes' ) : __( 'No' ),
-		),
-		'keyring_class'  => array(
-			'label' => __( 'Keyring class' ),
-			'value' => $uses_keyring ? get_class( $key_manager->get_keyring() ) : __( 'Not used by the provider' ),
-		),
-		'key_source'     => array(
-			'label' => __( 'Key source' ),
-			'value' => $uses_keyring ? $key_manager->get_keyring()->get_key_source() : __( 'Not used by the provider' ),
-		),
-		'record_version' => array(
-			'label' => __( 'Record format version' ),
-			'value' => (string) WP_SECRETS_RECORD_VERSION,
-		),
-	);
-
-	$site_secrets                = wp_list_secrets();
-	$fields['site_secret_count'] = array(
-		'label' => __( 'Site secrets' ),
-		'value' => is_wp_error( $site_secrets ) ? __( 'Unavailable' ) : (string) count( $site_secrets ),
-	);
-
-	if ( is_multisite() && is_super_admin() ) {
-		$network_secrets                = wp_list_network_secrets();
-		$fields['network_secret_count'] = array(
-			'label' => __( 'Network secrets' ),
-			'value' => is_wp_error( $network_secrets ) ? __( 'Unavailable' ) : (string) count( $network_secrets ),
-		);
-	}
-
-	$info['secrets-api'] = array(
-		'label'  => __( 'Secrets API' ),
-		'fields' => $fields,
-	);
-
-	return $info;
-}
-add_filter( 'debug_information', 'wp_secrets_site_health_debug_info' );
diff --git a/wp-includes/version.php b/wp-includes/version.php
index fe6761676f..23c5e3bc98 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,7 +16,7 @@
  *
  * @global string $wp_version
  */
-$wp_version = '7.2-beta5-65393';
+$wp_version = '7.2-beta5-65394';

 /**
  * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.