diff --git a/src/wp-admin/includes/class-wp-debug-data.php b/src/wp-admin/includes/class-wp-debug-data.php index ca85f0ebece3d..e21817915188e 100644 --- a/src/wp-admin/includes/class-wp-debug-data.php +++ b/src/wp-admin/includes/class-wp-debug-data.php @@ -57,6 +57,7 @@ public static function 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 @@ private static function get_wp_dropins(): array { ); } + /** + * 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 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/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index 17a4a190cdbbb..4c4ec2e96fe47 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/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 @@ public function get_test_opcode_cache(): array { 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( + '

%s

', + 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.' ), + 'secrets.php', + esc_html( $provider->get_label() ), + 'WP_SECRETS_KEY' + ) + ) + ); + } + + $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( + '

%s

', + 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.' ), + 'WP_SECRETS_KEY' + ) + : sprintf( + /* translators: 1: secrets.php, 2: Error message from the keyring. */ + __( 'The keyring of the %1$s drop-in reported: %2$s' ), + 'secrets.php', + 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( + '

%s

%s

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

%s

', + $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.' ), + 'WP_SECRETS_KEY' + ) + : 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.' ), + 'WP_SECRETS_KEY' + ) + ) + ); + } + + 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( + '

%s

%s

%s

%s

', + $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.' ), + 'WP_SECRETS_KEY', + 'wp-config.php', + 'wp-config-sample.php' + ) + : 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.' ), + 'WP_SECRETS_KEY', + 'wp-config.php' + ), + $is_defined + ? sprintf( + /* translators: 1: WP_SECRETS_KEY, 2: wp-config.php */ + __( 'Replace the %1$s line in %2$s with this one:' ), + 'WP_SECRETS_KEY', + 'wp-config.php' + ) + : 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:' ), + 'wp-config.php', + '/* ' . __( 'That’s all, stop editing! Happy publishing.' ) . ' */' + ), + 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( '

%s

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

%s

%s

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

%s

', esc_html__( 'Every stored secret decrypted successfully.' ) ) + ); + } + + $items = ''; + + foreach ( $broken as $entry ) { + $items .= sprintf( + '
  • %s (%s)
  • ', + 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( + '

    %s

    %s

    ', + 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.' ), + 'WP_SECRETS_KEY' + ) + ) + ); + } + + /** + * 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( '

    %s

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

    %s

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

    %s

    ', + 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

    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|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 @@ public static function get_tests() { '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/src/wp-admin/includes/secrets-site-health.php b/src/wp-admin/includes/secrets-site-health.php deleted file mode 100644 index bbf97da012a1f..0000000000000 --- a/src/wp-admin/includes/secrets-site-health.php +++ /dev/null @@ -1,520 +0,0 @@ ->> $tests Existing Site Health tests. - * @return array>> - */ -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

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

    %s

    ', - 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.' ), - 'secrets.php', - esc_html( $provider->get_label() ), - 'WP_SECRETS_KEY' - ) - ) - ); - } - - $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( - '

    %s

    ', - 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.' ), - 'WP_SECRETS_KEY' - ) - : sprintf( - /* translators: 1: secrets.php, 2: Error message from the keyring. */ - __( 'The keyring of the %1$s drop-in reported: %2$s' ), - 'secrets.php', - 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( - '

    %s

    %s

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

    %s

    ', - $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.' ), - 'WP_SECRETS_KEY' - ) - : 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.' ), - 'WP_SECRETS_KEY' - ) - ) - ); - } - - 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( - '

    %s

    %s

    %s

    %s

    ', - $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.' ), - 'WP_SECRETS_KEY', - 'wp-config.php', - 'wp-config-sample.php' - ) - : 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.' ), - 'WP_SECRETS_KEY', - 'wp-config.php' - ), - $is_defined - ? sprintf( - /* translators: 1: WP_SECRETS_KEY, 2: wp-config.php */ - __( 'Replace the %1$s line in %2$s with this one:' ), - 'WP_SECRETS_KEY', - 'wp-config.php' - ) - : 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:' ), - 'wp-config.php', - '/* ' . __( 'That’s all, stop editing! Happy publishing.' ) . ' */' - ), - 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( '

    %s

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

    %s

    %s

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

    %s

    ', esc_html__( 'Every stored secret decrypted successfully.' ) ) - ); - } - - $items = ''; - - foreach ( $broken as $entry ) { - $items .= sprintf( - '
  • %s (%s)
  • ', - 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( - '

    %s

    %s

    ', - 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.' ), - 'WP_SECRETS_KEY' - ) - ) - ); -} - -/** - * 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|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( '

    %s

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

    %s

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

    %s

    ', - 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> $info Existing debug information sections. - * @return array> - */ -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/tests/phpunit/tests/secrets/siteHealth.php b/tests/phpunit/tests/secrets/siteHealth.php index bf55ca408d6a3..1b5ae7a9be5cf 100644 --- a/tests/phpunit/tests/secrets/siteHealth.php +++ b/tests/phpunit/tests/secrets/siteHealth.php @@ -1,28 +1,51 @@ The section. + */ + private function debug_section(): array { + $method = new ReflectionMethod( 'WP_Debug_Data', 'get_wp_secrets_api' ); + + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + + return $method->invoke( null ); + } + /** * Returns the Secrets API section's fields from Site Health debug information, * having checked that the section and each of its fields has the expected shape. * - * @param array> $info Debug information sections. + * @param array $section The Secrets API section of the debug information. * @return array> The section's fields, keyed by field name. */ - private function debug_fields( array $info ): array { - $this->assertArrayHasKey( 'secrets-api', $info, 'The debug information should include a secrets-api section.' ); - $this->assertArrayHasKey( 'fields', $info['secrets-api'], 'The secrets-api section should include a fields entry.' ); - $this->assertIsArray( $info['secrets-api']['fields'], 'The secrets-api fields entry should be an array.' ); + private function debug_fields( array $section ): array { + $this->assertArrayHasKey( 'fields', $section, 'The Secrets API section should include a fields entry.' ); + $this->assertIsArray( $section['fields'], 'The Secrets API fields entry should be an array.' ); $fields = array(); - foreach ( $info['secrets-api']['fields'] as $key => $field ) { + foreach ( $section['fields'] as $key => $field ) { $this->assertIsString( $key, 'Each debug field key should be a string.' ); $this->assertIsArray( $field, 'Each debug field should be an array.' ); @@ -33,34 +56,18 @@ private function debug_fields( array $info ): array { } /** - * Ensure that the three Secrets API checks are registered as direct tests with existing callbacks. + * Ensure that the three Secrets API checks are registered as direct tests with existing methods. * - * @covers ::wp_secrets_site_health_tests + * @covers WP_Site_Health::get_tests */ - public function test_registers_three_tests_under_direct(): void { - $tests = wp_secrets_site_health_tests( array() ); - - $this->assertArrayHasKey( 'direct', $tests, 'The registered tests should include a direct group.' ); - $this->assertCount( 3, $tests['direct'], 'Exactly three Secrets API tests should be registered as direct tests.' ); - $this->assertArrayHasKey( 'secrets_api_key_source', $tests['direct'], 'The key source test should be registered as a direct test.' ); - $this->assertArrayHasKey( 'secrets_api_undecryptable', $tests['direct'], 'The undecryptable test should be registered as a direct test.' ); - $this->assertArrayHasKey( 'secrets_api_needs_rotation', $tests['direct'], 'The needs rotation test should be registered as a direct test.' ); - - foreach ( $tests['direct'] as $key => $test ) { - $this->assertIsString( $test['test'], 'Each registered test callback should be a function name string.' ); - $this->assertTrue( function_exists( $test['test'] ), "Registered test callback \"{$test['test']}\" for \"{$key}\" does not exist." ); - } - } + public function test_registers_three_direct_tests(): void { + $tests = WP_Site_Health::get_tests(); - /** - * Ensure that wp_secrets_site_health_tests() keeps tests that were already registered. - * - * @covers ::wp_secrets_site_health_tests - */ - public function test_preserves_existing_tests(): void { - $tests = wp_secrets_site_health_tests( array( 'direct' => array( 'existing_test' => array( 'label' => 'x' ) ) ) ); - - $this->assertArrayHasKey( 'existing_test', $tests['direct'], 'A previously registered test should be preserved.' ); + foreach ( array( 'secrets_api_key_source', 'secrets_api_undecryptable', 'secrets_api_needs_rotation' ) as $key ) { + $this->assertArrayHasKey( $key, $tests['direct'], "The {$key} test should be registered as a direct test." ); + $this->assertSame( $key, $tests['direct'][ $key ]['test'], "The {$key} test should name its own method." ); + $this->assertTrue( method_exists( 'WP_Site_Health', "get_test_{$key}" ), "WP_Site_Health::get_test_{$key}() should exist." ); + } } /** @@ -68,13 +75,13 @@ public function test_preserves_existing_tests(): void { * * Runs in a separate process, where the test bootstrap does not define WP_SECRETS_KEY. * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled */ public function test_key_source_test_offers_a_generated_key_when_none_is_defined(): void { - $result = wp_secrets_site_health_test_key_source(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_key_source(); $this->assertSame( 'recommended', $result['status'], 'The key source status should be recommended without WP_SECRETS_KEY.' ); $this->assertStringContainsString( 'Add this line', $result['description'], 'The description should ask for the line to be added.' ); @@ -87,7 +94,7 @@ public function test_key_source_test_offers_a_generated_key_when_none_is_defined * Runs in a separate process because WP_SECRETS_KEY is a constant and cannot be * undefined for later tests. * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -95,7 +102,7 @@ public function test_key_source_test_offers_a_generated_key_when_none_is_defined public function test_key_source_test_offers_a_replacement_for_the_placeholder(): void { define( 'WP_SECRETS_KEY', 'put your unique phrase here' ); - $result = wp_secrets_site_health_test_key_source(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_key_source(); $this->assertSame( 'recommended', $result['status'], 'The key source status should be recommended for the placeholder.' ); $this->assertStringContainsString( 'Replace the WP_SECRETS_KEY line', $result['description'], 'The description should ask for the line to be replaced.' ); @@ -106,7 +113,7 @@ public function test_key_source_test_offers_a_replacement_for_the_placeholder(): * * The key source text is translated, so the check must not depend on it. * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -121,7 +128,7 @@ static function ( $translation, $text ) { 2 ); - $result = wp_secrets_site_health_test_key_source(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_key_source(); $this->assertSame( 'recommended', $result['status'], 'The key source status should be recommended without WP_SECRETS_KEY on a translated site.' ); $this->assertStringContainsString( 'Add this line', $result['description'], 'The description should ask for the line to be added on a translated site.' ); @@ -133,7 +140,7 @@ static function ( $translation, $text ) { * Runs in a separate process because WP_SECRETS_KEY is a constant and cannot be * undefined for later tests. * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -141,7 +148,7 @@ static function ( $translation, $text ) { public function test_key_source_test_recommends_regeneration_for_a_legacy_key(): void { define( 'WP_SECRETS_KEY', 'not-base64-32!!' ); - $result = wp_secrets_site_health_test_key_source(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_key_source(); $this->assertSame( 'recommended', $result['status'], 'A legacy WP_SECRETS_KEY should produce a recommended status.' ); $this->assertStringContainsString( 'can still be replaced', $result['description'], 'The description should say that the key can still be replaced.' ); @@ -155,7 +162,7 @@ public function test_key_source_test_recommends_regeneration_for_a_legacy_key(): * * @ticket 66380 * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -165,7 +172,7 @@ public function test_key_source_test_warns_to_keep_a_legacy_key_once_secrets_are $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); - $result = wp_secrets_site_health_test_key_source(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_key_source(); $this->assertSame( 'recommended', $result['status'], 'A legacy WP_SECRETS_KEY with stored secrets should produce a recommended status.' ); $this->assertStringContainsString( 'Keep its current value', $result['description'], 'The description should say to keep the current value.' ); @@ -176,7 +183,7 @@ public function test_key_source_test_warns_to_keep_a_legacy_key_once_secrets_are * * @ticket 66380 * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source */ public function test_key_source_test_is_critical_when_the_key_does_not_match_the_stored_secrets(): void { $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); @@ -184,7 +191,7 @@ public function test_key_source_test_is_critical_when_the_key_does_not_match_the // Stands in for a root key that was wrapped with a different site key. update_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION, base64_encode( random_bytes( 72 ) ) ); - $result = wp_secrets_site_health_test_key_source(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_key_source(); $this->assertSame( 'critical', $result['status'], 'A key that cannot unwrap the root key should produce a critical status.' ); $this->assertStringContainsString( 'Restoring the previous value', $result['description'], 'The description should say that restoring the previous value fixes it.' ); @@ -198,7 +205,7 @@ public function test_key_source_test_is_critical_when_the_key_does_not_match_the * * @ticket 66380 * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -209,7 +216,7 @@ public function test_key_source_test_is_critical_when_the_key_was_removed_after_ $previous = new WP_Secrets_Config_Key_Provider( true ); update_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION, $previous->wrap( random_bytes( 32 ) ) ); - $result = wp_secrets_site_health_test_key_source(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_key_source(); $this->assertSame( 'critical', $result['status'], 'A removed WP_SECRETS_KEY with stored secrets should produce a critical status.' ); $this->assertStringContainsString( 'WP_SECRETS_KEY was changed or removed', $result['description'], 'The description should say that WP_SECRETS_KEY was changed or removed.' ); @@ -224,7 +231,7 @@ public function test_key_source_test_is_critical_when_the_key_was_removed_after_ * * @ticket 66380 * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -236,7 +243,7 @@ public function test_key_source_test_reports_the_error_of_a_dropin_keyring_that_ $GLOBALS['wp_secrets_keyring'] = $keyring; - $result = wp_secrets_site_health_test_key_source(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_key_source(); $this->assertSame( 'critical', $result['status'], 'A drop-in keyring that cannot unwrap the root key should produce a critical status.' ); $this->assertStringContainsString( 'unwrap() configured to fail', $result['description'], 'The description should contain the keyring\'s own error.' ); @@ -249,7 +256,7 @@ public function test_key_source_test_reports_the_error_of_a_dropin_keyring_that_ * Runs in a separate process because WP_SECRETS_KEY is a constant and cannot be * undefined for later tests. * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -257,7 +264,7 @@ public function test_key_source_test_reports_the_error_of_a_dropin_keyring_that_ public function test_key_source_test_is_good_for_a_dedicated_base64_key(): void { define( 'WP_SECRETS_KEY', base64_encode( str_repeat( 'A', 32 ) ) ); - $result = wp_secrets_site_health_test_key_source(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_key_source(); $this->assertSame( 'good', $result['status'], 'A base64-encoded 32-byte WP_SECRETS_KEY should produce a good status.' ); } @@ -268,7 +275,7 @@ public function test_key_source_test_is_good_for_a_dedicated_base64_key(): void * Runs in a separate process because the key manager is cached in a static variable * the first time it is requested, so the broken flag must be set before then. * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -276,7 +283,7 @@ public function test_key_source_test_is_good_for_a_dedicated_base64_key(): void public function test_key_source_test_is_critical_when_the_keyring_is_broken(): void { $GLOBALS['wp_secrets_dropin_broken'] = true; - $result = wp_secrets_site_health_test_key_source(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_key_source(); $this->assertSame( 'critical', $result['status'], 'A broken secrets drop-in should produce a critical status.' ); } @@ -288,7 +295,7 @@ public function test_key_source_test_is_critical_when_the_keyring_is_broken(): v * * @ticket 66380 * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -305,7 +312,7 @@ static function ( $translation, $text ) { 2 ); - $result = wp_secrets_site_health_test_key_source(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_key_source(); $this->assertSame( 'critical', $result['status'], 'A broken secrets drop-in should produce a critical status on a translated site.' ); } @@ -317,7 +324,7 @@ static function ( $translation, $text ) { * * @ticket 66380 * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -328,7 +335,7 @@ public function test_key_source_test_is_good_when_a_dropin_replaces_the_provider $GLOBALS['wp_secrets_provider'] = $provider; - $result = wp_secrets_site_health_test_key_source(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_key_source(); $this->assertSame( 'good', $result['status'], 'A provider drop-in should produce a good status.' ); $this->assertStringContainsString( 'Example Vault', $result['description'], 'The description should name the provider.' ); @@ -339,7 +346,7 @@ public function test_key_source_test_is_good_when_a_dropin_replaces_the_provider * * @ticket 66380 * - * @covers ::wp_secrets_site_health_test_undecryptable + * @covers WP_Site_Health::get_test_secrets_api_undecryptable * * @runInSeparateProcess * @preserveGlobalState disabled @@ -347,7 +354,7 @@ public function test_key_source_test_is_good_when_a_dropin_replaces_the_provider public function test_undecryptable_test_is_critical_when_the_secrets_cannot_be_listed(): void { $GLOBALS['wp_secrets_dropin_broken'] = true; - $result = wp_secrets_site_health_test_undecryptable(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_undecryptable(); $this->assertSame( 'critical', $result['status'], 'A provider that cannot list secrets should produce a critical status.' ); } @@ -357,7 +364,7 @@ public function test_undecryptable_test_is_critical_when_the_secrets_cannot_be_l * * @ticket 66380 * - * @covers ::wp_secrets_site_health_test_needs_rotation + * @covers WP_Site_Health::get_test_secrets_api_needs_rotation * * @runInSeparateProcess * @preserveGlobalState disabled @@ -365,7 +372,7 @@ public function test_undecryptable_test_is_critical_when_the_secrets_cannot_be_l public function test_needs_rotation_test_is_recommended_when_the_secrets_cannot_be_listed(): void { $GLOBALS['wp_secrets_dropin_broken'] = true; - $result = wp_secrets_site_health_test_needs_rotation(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_needs_rotation(); $this->assertSame( 'recommended', $result['status'], 'A provider that cannot list secrets should produce a recommended status.' ); } @@ -373,12 +380,12 @@ public function test_needs_rotation_test_is_recommended_when_the_secrets_cannot_ /** * Ensure that the undecryptable check passes when every stored secret decrypts. * - * @covers ::wp_secrets_site_health_test_undecryptable + * @covers WP_Site_Health::get_test_secrets_api_undecryptable */ public function test_undecryptable_test_is_good_when_nothing_is_broken(): void { $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); - $result = wp_secrets_site_health_test_undecryptable(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_undecryptable(); $this->assertSame( 'good', $result['status'], 'The status should be good when every secret decrypts.' ); } @@ -386,10 +393,10 @@ public function test_undecryptable_test_is_good_when_nothing_is_broken(): void { /** * Ensure that the undecryptable check passes when no secrets are stored. * - * @covers ::wp_secrets_site_health_test_undecryptable + * @covers WP_Site_Health::get_test_secrets_api_undecryptable */ public function test_undecryptable_test_is_good_with_no_secrets_at_all(): void { - $result = wp_secrets_site_health_test_undecryptable(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_undecryptable(); $this->assertSame( 'good', $result['status'], 'The status should be good when no secrets are stored.' ); } @@ -399,7 +406,7 @@ public function test_undecryptable_test_is_good_with_no_secrets_at_all(): void { * * The stored ciphertext is overwritten to simulate a record that no longer decrypts. * - * @covers ::wp_secrets_site_health_test_undecryptable + * @covers WP_Site_Health::get_test_secrets_api_undecryptable */ public function test_undecryptable_test_is_critical_and_lists_the_broken_secret(): void { $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); @@ -417,7 +424,7 @@ public function test_undecryptable_test_is_critical_and_lists_the_broken_secret( $record['current']['ct'] = base64_encode( 'not decryptable' ); update_option( '_wp_secret_myplugin/api-key', $record, false ); - $result = wp_secrets_site_health_test_undecryptable(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_undecryptable(); $this->assertSame( 'critical', $result['status'], 'An undecryptable secret should produce a critical status.' ); $this->assertStringContainsString( 'myplugin/api-key', $result['description'], 'The description should name the undecryptable secret.' ); @@ -427,7 +434,7 @@ public function test_undecryptable_test_is_critical_and_lists_the_broken_secret( /** * Ensure that the undecryptable check's description never includes a secret's plaintext value. * - * @covers ::wp_secrets_site_health_test_undecryptable + * @covers WP_Site_Health::get_test_secrets_api_undecryptable */ public function test_undecryptable_test_never_contains_a_plaintext(): void { $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'UNIQUE-PLAINTEXT-CANARY-9f3a' ), 'Setting myplugin/api-key to UNIQUE-PLAINTEXT-CANARY-9f3a should succeed.' ); @@ -441,7 +448,7 @@ public function test_undecryptable_test_never_contains_a_plaintext(): void { $record['current']['ct'] = base64_encode( 'not decryptable' ); update_option( '_wp_secret_myplugin/api-key', $record, false ); - $result = wp_secrets_site_health_test_undecryptable(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_undecryptable(); $this->assertStringNotContainsString( 'UNIQUE-PLAINTEXT-CANARY-9f3a', $result['description'], 'The description should never contain the plaintext value.' ); } @@ -449,12 +456,12 @@ public function test_undecryptable_test_never_contains_a_plaintext(): void { /** * Ensure that the rotation check passes for a secret stored directly with wp_set_secret(). * - * @covers ::wp_secrets_site_health_test_needs_rotation + * @covers WP_Site_Health::get_test_secrets_api_needs_rotation */ public function test_needs_rotation_test_is_good_by_default(): void { $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); - $result = wp_secrets_site_health_test_needs_rotation(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_needs_rotation(); $this->assertSame( 'good', $result['status'], 'A directly stored secret should not need rotation.' ); } @@ -462,13 +469,13 @@ public function test_needs_rotation_test_is_good_by_default(): void { /** * Ensure that the rotation check recommends rotating a secret imported from a plain option. * - * @covers ::wp_secrets_site_health_test_needs_rotation + * @covers WP_Site_Health::get_test_secrets_api_needs_rotation */ public function test_needs_rotation_test_is_recommended_after_an_import(): void { update_option( 'my_plugin_api_key', 'value' ); $this->assertTrue( wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ), 'Importing my_plugin_api_key as myplugin/api-key should succeed.' ); - $result = wp_secrets_site_health_test_needs_rotation(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_needs_rotation(); $this->assertSame( 'recommended', $result['status'], 'An imported secret should produce a recommended status.' ); $this->assertStringContainsString( '1 secret was imported', $result['description'], 'The description should report the number of secrets flagged for rotation.' ); @@ -477,12 +484,12 @@ public function test_needs_rotation_test_is_recommended_after_an_import(): void /** * Ensure that the debug information reports the provider, keyring, record version and secret count. * - * @covers ::wp_secrets_site_health_debug_info + * @covers WP_Debug_Data::get_wp_secrets_api */ public function test_debug_info_reports_expected_fields(): void { $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); - $info = wp_secrets_site_health_debug_info( array() ); + $info = $this->debug_section(); $fields = $this->debug_fields( $info ); $this->assertArrayHasKey( 'dropin_active', $fields, 'The debug fields should include dropin_active.' ); @@ -507,7 +514,7 @@ public function test_debug_info_reports_expected_fields(): void { * * @ticket 66380 * - * @covers ::wp_secrets_site_health_debug_info + * @covers WP_Debug_Data::get_wp_secrets_api * * @runInSeparateProcess * @preserveGlobalState disabled @@ -519,7 +526,7 @@ public function test_debug_info_does_not_describe_the_keyring_when_a_dropin_repl $GLOBALS['wp_secrets_provider'] = $provider; - $fields = $this->debug_fields( wp_secrets_site_health_debug_info( array() ) ); + $fields = $this->debug_fields( $this->debug_section() ); $this->assertSame( 'Not used by the provider', $fields['keyring_class']['value'], 'The keyring class should be marked as not used.' ); $this->assertSame( 'Not used by the provider', $fields['key_source']['value'], 'The key source should be marked as not used.' ); @@ -528,10 +535,10 @@ public function test_debug_info_does_not_describe_the_keyring_when_a_dropin_repl /** * Ensure that the debug information reports no active drop-in when none is installed. * - * @covers ::wp_secrets_site_health_debug_info + * @covers WP_Debug_Data::get_wp_secrets_api */ public function test_debug_info_reports_no_dropin_by_default(): void { - $info = wp_secrets_site_health_debug_info( array() ); + $info = $this->debug_section(); $fields = $this->debug_fields( $info ); @@ -542,26 +549,26 @@ public function test_debug_info_reports_no_dropin_by_default(): void { /** * Ensure that the debug information never includes a secret's plaintext value. * - * @covers ::wp_secrets_site_health_debug_info + * @covers WP_Debug_Data::get_wp_secrets_api */ public function test_debug_info_never_contains_a_plaintext(): void { $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'UNIQUE-PLAINTEXT-CANARY-9f3a' ), 'Setting myplugin/api-key to UNIQUE-PLAINTEXT-CANARY-9f3a should succeed.' ); - $info = wp_secrets_site_health_debug_info( array() ); + $info = $this->debug_section(); - $dump = wp_json_encode( $info['secrets-api'] ); + $dump = wp_json_encode( $info ); - $this->assertIsString( $dump, 'The secrets-api section should encode to a JSON string.' ); + $this->assertIsString( $dump, 'The Secrets API section should encode to a JSON string.' ); $this->assertStringNotContainsString( 'UNIQUE-PLAINTEXT-CANARY-9f3a', $dump, 'The debug information should never contain the plaintext value.' ); } /** * Ensure that the debug information omits network secret counts on a single site install. * - * @covers ::wp_secrets_site_health_debug_info + * @covers WP_Debug_Data::get_wp_secrets_api */ public function test_debug_info_omits_network_fields_on_single_site(): void { - $info = wp_secrets_site_health_debug_info( array() ); + $info = $this->debug_section(); $this->assertArrayNotHasKey( 'network_secret_count', $this->debug_fields( $info ), 'Network secret counts should be omitted on a single site install.' ); } @@ -573,7 +580,7 @@ public function test_debug_info_omits_network_fields_on_single_site(): void { * @group multisite * @group ms-required * - * @covers ::wp_secrets_site_health_debug_info + * @covers WP_Debug_Data::get_wp_secrets_api */ public function test_debug_info_includes_network_fields_only_for_a_super_admin(): void { $this->skipWithoutMultisite(); @@ -581,12 +588,12 @@ public function test_debug_info_includes_network_fields_only_for_a_super_admin() $user_id = self::factory()->user->create(); wp_set_current_user( $user_id ); - $info = wp_secrets_site_health_debug_info( array() ); + $info = $this->debug_section(); $this->assertArrayNotHasKey( 'network_secret_count', $this->debug_fields( $info ), 'A non-super-admin should not see network secret counts.' ); grant_super_admin( $user_id ); - $info = wp_secrets_site_health_debug_info( array() ); + $info = $this->debug_section(); $this->assertArrayHasKey( 'network_secret_count', $this->debug_fields( $info ), 'A super admin should see network secret counts.' ); } }