diff --git a/cli/class-wp-cli-secret-command.php b/cli/class-wp-cli-secret-command.php index 5a467a9..09c3373 100644 --- a/cli/class-wp-cli-secret-command.php +++ b/cli/class-wp-cli-secret-command.php @@ -649,17 +649,34 @@ public function generate_key(): void { * @param array $assoc_args Associative arguments. */ public function health( array $args, array $assoc_args ): void { - // Core loads its Site Health tests only for the Site Health screen, so on a - // WordPress that ships the API they are not loaded under WP-CLI. - if ( ! function_exists( 'wp_secrets_site_health_test_key_source' ) ) { - require_once ABSPATH . 'wp-admin/includes/secrets-site-health.php'; + if ( class_exists( 'Secrets_API_Site_Health', false ) ) { + // Before 7.2, the plugin's copies of core's tests. + $site_health = new Secrets_API_Site_Health(); + } else { + // Core loads WP_Site_Health only for the Site Health screen, so it is not + // loaded under WP-CLI. + if ( ! class_exists( 'WP_Site_Health', false ) ) { + require_once ABSPATH . 'wp-admin/includes/class-wp-site-health.php'; + } + + $site_health = WP_Site_Health::get_instance(); } - $results = array( - wp_secrets_site_health_test_key_source(), - wp_secrets_site_health_test_undecryptable(), - wp_secrets_site_health_test_needs_rotation(), - ); + if ( method_exists( $site_health, 'get_test_secrets_api_key_source' ) ) { + $results = array( + $site_health->get_test_secrets_api_key_source(), + $site_health->get_test_secrets_api_undecryptable(), + $site_health->get_test_secrets_api_needs_rotation(), + ); + } else { + // The 7.2 betas before r65394 have the tests as functions, which + // class-wp-site-health.php loads. Remove once 7.2 is released. + $results = array( + wp_secrets_site_health_test_key_source(), + wp_secrets_site_health_test_undecryptable(), + wp_secrets_site_health_test_needs_rotation(), + ); + } $items = array(); diff --git a/docs/decisions/0014-the-plugin-keeps-wp-cli-on-a-core-that-ships-the-api.md b/docs/decisions/0014-the-plugin-keeps-wp-cli-on-a-core-that-ships-the-api.md index b51d01a..e44432b 100644 --- a/docs/decisions/0014-the-plugin-keeps-wp-cli-on-a-core-that-ships-the-api.md +++ b/docs/decisions/0014-the-plugin-keeps-wp-cli-on-a-core-that-ships-the-api.md @@ -27,9 +27,10 @@ exactly the release the commit message promised them for. nothing else.** It loads none of `src/` or `plugin/`. The commands call the public API and the internals core has as well (`_wp_secrets_get_key_manager()`, `_wp_secrets_get_provider()`, `_wp_secrets_get_store()`, `WP_Secrets_Config_Key_Provider`), so the same `cli/` runs on core's -implementation unchanged. `wp secret health` loads core's -`wp-admin/includes/secrets-site-health.php` itself, since core loads it only for the Site Health -screen. +implementation unchanged. `wp secret health` loads core's `WP_Site_Health` itself, since core +loads it only for the Site Health screen. (When this was decided, the tests were functions in +`wp-admin/includes/secrets-site-health.php`. [r65394](https://core.trac.wordpress.org/changeset/65394) +made them methods of `WP_Site_Health`.) **`migrate-legacy` does not come along.** The prototype was deprecated and its users told to migrate more than a month before 7.2. The migrator and legacy reader load only with the plugin's diff --git a/docs/reference/ci.md b/docs/reference/ci.md index 5f011cb..bee6e4f 100644 --- a/docs/reference/ci.md +++ b/docs/reference/ci.md @@ -23,7 +23,10 @@ make ci # all of the above, except test-stand-down `make test-stand-down` needs a WordPress that ships the Secrets API, such as trunk, so it is not part of `make ci`. It runs the `stand-down` group with `SECRETS_API_TESTS_EXPECT_CORE=1`, which -makes the stand-down tests fail instead of skip on a WordPress without the API. +makes the stand-down tests fail instead of skip on a WordPress without the API. The group also +holds the Site Health parity tests: `plugin/class-secrets-api-site-health.php` copies methods +from core's `WP_Site_Health` and `WP_Debug_Data` for WordPress before 7.2, and on trunk each copy +is compared with core's. When one fails, copy the method again from core. ## The always-works path @@ -132,7 +135,7 @@ person pasted. |---|---|---|---| | `static` | 8.3 | — | lint + compat + analyse. Gates everything else. | | `test` | 7.4, 8.0, 8.3, 8.5; 7.4 | 7.1.x; 6.8 | Single site | -| `stand-down` | 7.4, 8.5 | trunk | `make test-stand-down`: core serves the API, and the plugin loads none of its own layer. Core is wordpress-develop trunk's `src/`, from the same tarball as the test suite, not the nightly zip, which can lag a day behind it. | +| `stand-down` | 7.4, 8.5 | trunk | `make test-stand-down`: core serves the API, the plugin loads none of its own layer, and the plugin's copies of core's Site Health methods match core's. Core is wordpress-develop trunk's `src/`, from the same tarball as the test suite, not the nightly zip, which can lag a day behind it. | | `test-multisite` | 8.3 | 7.1.x | Multisite suite | | `smoke` | 7.4, 8.3, 8.5; 8.3 | 7.1.x; nightly | WP-CLI end to end, single site then multisite. The nightly leg runs the commands on core's implementation. | | `examples` | 8.3 | 7.1.x | `make test-examples` against Moto (an AWS emulator) and a Vault dev server, both pinned by digest, single site then multisite. Not part of `make ci`. | diff --git a/docs/reference/classes.md b/docs/reference/classes.md index 1f807df..1c9d2e1 100644 --- a/docs/reference/classes.md +++ b/docs/reference/classes.md @@ -286,6 +286,103 @@ public function set( string $name, array $record, bool $network = false ) **Returns:** `bool|WP_Error` +## `Secrets_API_Site_Health` + +*final class* + +Adds the Secrets API's Site Health tests and debug information on WordPress before 7.2. + +In core they are methods of WP_Site_Health and WP_Debug_Data (r65394). A plugin cannot +add methods to those classes, so this class carries copies of them and registers them +through the 'site_status_tests' and 'debug_information' filters, under the same test +IDs, labels and debug section key that core uses. + +Everything after add_debug_information() is copied from core trunk unchanged, so it +passes no text domain, as core does. Porting a later core change means copying the +methods again. On trunk, the stand-down suite compares each copy with core's. + +**Source:** [`plugin/class-secrets-api-site-health.php`](../../plugin/class-secrets-api-site-health.php) + +### Methods + +#### `Secrets_API_Site_Health::add_debug_information()` + +Adds the Secrets API section, as WP_Debug_Data::debug_data() does in core. + +```php +public function add_debug_information( $info ) +``` + +| Parameter | Type | Description | +|---|---|---| +| `$info` | `array>` | Existing debug information sections. | + +**Returns:** `array>` + +#### `Secrets_API_Site_Health::add_tests()` + +Registers the three tests as direct tests, as WP_Site_Health::get_tests() does in core. + +```php +public function add_tests( $tests ) +``` + +| Parameter | Type | Description | +|---|---|---| +| `$tests` | `array>>` | Existing Site Health tests. | + +**Returns:** `array>>` + +#### `Secrets_API_Site_Health::get_test_secrets_api_key_source()` + +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. + +```php +public function get_test_secrets_api_key_source(): array +``` + +**Returns:** `array` The test result. + +**Since:** 7.2.0 + +#### `Secrets_API_Site_Health::get_test_secrets_api_needs_rotation()` + +Tests whether any secrets are flagged as needing rotation. + +```php +public function get_test_secrets_api_needs_rotation(): array +``` + +**Returns:** `array` The test result. + +**Since:** 7.2.0 + +#### `Secrets_API_Site_Health::get_test_secrets_api_undecryptable()` + +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. + +```php +public function get_test_secrets_api_undecryptable(): array +``` + +**Returns:** `array` The test result. + +**Since:** 7.2.0 + +#### `Secrets_API_Site_Health::register()` + +Hooks the tests and the debug information into Site Health. + +```php +public static function register(): void +``` + ## `WP_Secret` *final class* · implements `JsonSerializable` diff --git a/docs/reference/functions.md b/docs/reference/functions.md index 1cd3d87..3f0b0d6 100644 --- a/docs/reference/functions.md +++ b/docs/reference/functions.md @@ -447,171 +447,6 @@ function wp_secrets_provider_label(): string **Source:** [`src/wp-includes/secrets.php`](../../src/wp-includes/secrets.php) -## `wp_secrets_site_health_count_needing_rotation()` - -Counts secrets flagged needs_rotation, for one scope. - -```php -function wp_secrets_site_health_count_needing_rotation( bool $network ) -``` - -| Parameter | Type | Description | -|---|---|---| -| `$network` | `bool` | Whether to check network-scope secrets. | - -**Returns:** `int|WP_Error` The number of secrets, or WP_Error when the secrets cannot be listed. - -**Since:** 7.2.0 - -**Source:** [`src/wp-admin/includes/secrets-site-health.php`](../../src/wp-admin/includes/secrets-site-health.php) - -## `wp_secrets_site_health_debug_info()` - -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. - -```php -function wp_secrets_site_health_debug_info( $info ) -``` - -| Parameter | Type | Description | -|---|---|---| -| `$info` | `array>` | Existing debug information sections. | - -**Returns:** `array>` - -**Since:** 7.2.0 - -**Source:** [`src/wp-admin/includes/secrets-site-health.php`](../../src/wp-admin/includes/secrets-site-health.php) - -## `wp_secrets_site_health_find_undecryptable()` - -Finds secrets that fail to decrypt, for one scope. - -```php -function wp_secrets_site_health_find_undecryptable( bool $network ) -``` - -| Parameter | Type | Description | -|---|---|---| -| `$network` | `bool` | Whether to check network-scope secrets. | - -**Returns:** `array|WP_Error` List of array( 'name' => ..., 'fingerprint' => ... ), or WP_Error when the secrets cannot be listed. - -**Since:** 7.2.0 - -**Source:** [`src/wp-admin/includes/secrets-site-health.php`](../../src/wp-admin/includes/secrets-site-health.php) - -## `wp_secrets_site_health_result()` - -Builds the standard shape a Site Health test callback returns. - -```php -function wp_secrets_site_health_result( string $test, string $label, string $status, string $description ): array -``` - -| Parameter | Type | Description | -|---|---|---| -| `$test` | `string` | The test's own identifier, matching the key registered in wp_secrets_site_health_tests(). | -| `$label` | `string` | Short label for the test result. | -| `$status` | `string` | One of 'good', 'recommended', 'critical'. | -| `$description` | `string` | HTML description, normally one or more \ elements. | - -**Returns:** `array` - -**Since:** 7.2.0 - -**Source:** [`src/wp-admin/includes/secrets-site-health.php`](../../src/wp-admin/includes/secrets-site-health.php) - -## `wp_secrets_site_health_test_key_source()` - -Tests whether the site key is defined, and in the recommended form. - -```php -function wp_secrets_site_health_test_key_source(): array -``` - -**Returns:** `array` - -**Since:** 7.2.0 - -**Source:** [`src/wp-admin/includes/secrets-site-health.php`](../../src/wp-admin/includes/secrets-site-health.php) - -## `wp_secrets_site_health_test_needs_rotation()` - -Tests whether any secrets are flagged as needing rotation. - -```php -function wp_secrets_site_health_test_needs_rotation(): array -``` - -**Returns:** `array` - -**Since:** 7.2.0 - -**Source:** [`src/wp-admin/includes/secrets-site-health.php`](../../src/wp-admin/includes/secrets-site-health.php) - -## `wp_secrets_site_health_test_undecryptable()` - -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. - -```php -function wp_secrets_site_health_test_undecryptable(): array -``` - -**Returns:** `array` - -**Since:** 7.2.0 - -**Source:** [`src/wp-admin/includes/secrets-site-health.php`](../../src/wp-admin/includes/secrets-site-health.php) - -## `wp_secrets_site_health_tests()` - -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. - -```php -function wp_secrets_site_health_tests( $tests ) -``` - -| Parameter | Type | Description | -|---|---|---| -| `$tests` | `array>>` | Existing Site Health tests. | - -**Returns:** `array>>` - -**Since:** 7.2.0 - -**Source:** [`src/wp-admin/includes/secrets-site-health.php`](../../src/wp-admin/includes/secrets-site-health.php) - -## `wp_secrets_site_health_uses_keyring()` - -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. - -```php -function wp_secrets_site_health_uses_keyring( WP_Secrets_Provider $provider ): bool -``` - -| Parameter | Type | Description | -|---|---|---| -| `$provider` | `WP_Secrets_Provider` | The active provider. | - -**Returns:** `bool` - -**Since:** 7.2.0 - -**Source:** [`src/wp-admin/includes/secrets-site-health.php`](../../src/wp-admin/includes/secrets-site-health.php) - ## `wp_secrets_validate_name()` Validates a secret's name. diff --git a/docs/spec/rotation.md b/docs/spec/rotation.md index 228b550..cd2764c 100644 --- a/docs/spec/rotation.md +++ b/docs/spec/rotation.md @@ -61,8 +61,7 @@ reached through the CLI. **Flagging for rotation.** Every slot carries `needs_rotation`. `wp_import_option_as_secret()` sets it to `true`; ordinary writes set `false`. It surfaces in `wp_list_secrets()`, in the Site -Health test `secrets_api_needs_rotation` in `src/wp-admin/includes/secrets-site-health.php`, and -in `wp secret health`. +Health test `secrets_api_needs_rotation`, and in `wp secret health`. ## Why diff --git a/docs/spec/scope.md b/docs/spec/scope.md index b912e00..4882ef4 100644 --- a/docs/spec/scope.md +++ b/docs/spec/scope.md @@ -19,13 +19,11 @@ scope", and "Timeline" in the [proposal][proposal]. `Requires PHP: 7.4`. **Core-bound versus plugin-only.** Everything under `src/` is written to be copied into -`wordpress-develop` unchanged: `src/wp-includes/secrets.php` holds the public functions, -`src/wp-includes/secrets/` holds the classes, and -`src/wp-admin/includes/secrets-site-health.php` holds Site Health. `plugin/` and `cli/` are never -copied. `tests/phpunit/test-architecture.php` enforces the boundary: no reference to `WP_CLI`, +`wordpress-develop` unchanged: `src/wp-includes/secrets.php` holds the public functions, and +`src/wp-includes/secrets/` holds the classes. `plugin/` and `cli/` are never copied. `tests/phpunit/test-architecture.php` enforces the boundary: no reference to `WP_CLI`, `plugin/`, or `cli/` under `src/`; no self-guarding `function_exists()` or `class_exists()`; every file carries `@since 7.2.0`; no text domain passed to a translation function; no subdirectory other than the -three core paths; no prototype-compatibility symbol. +two core paths; no prototype-compatibility symbol. **The core patch.** Trac ticket [#66187](https://core.trac.wordpress.org/ticket/66187), under review in [WordPress/wordpress-develop#13759](https://github.com/WordPress/wordpress-develop/pull/13759). @@ -67,9 +65,13 @@ defined and true. On a WordPress that ships the API they run on core's implement `migrate-legacy` refuses, since its migrator loads only with the plugin's copy. Both are exercised end to end against a real `wp` binary by `tests/smoke/smoke.sh`, on single site and multisite. -**Site Health.** Three tests (`secrets_api_key_source`, undecryptable secrets, -`secrets_api_needs_rotation`) and a debug-information section, in -`src/wp-admin/includes/secrets-site-health.php`. +**Site Health.** Three tests (`secrets_api_key_source`, `secrets_api_undecryptable`, +`secrets_api_needs_rotation`) and a debug-information section under the key `wp-secrets-api`. In +core they are methods of `WP_Site_Health` and `WP_Debug_Data` +([r65394](https://core.trac.wordpress.org/changeset/65394)). A plugin can't add methods to those +classes, so before 7.2 `plugin/class-secrets-api-site-health.php` registers copies of them through +the `site_status_tests` and `debug_information` filters. On trunk, a stand-down test compares each +copy with core's, so a core change that needs porting fails CI. **No UI.** There is no settings screen, no options page, and no admin menu entry. diff --git a/phpcs.xml.dist b/phpcs.xml.dist index ef8e276..9092544 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -73,9 +73,20 @@ With text domains configured, the sniff warns about any call that omits one and suggests naming "default". In src/ omitting it is the point: core does not pass a domain for its own strings, and review on the core patch asked for the same. + plugin/class-secrets-api-site-health.php carries copies of core's Site Health + methods, kept identical to core's so that a later change can be copied again. --> /src/* + /plugin/class-secrets-api-site-health\.php$ + + + + + /plugin/class-secrets-api-site-health\.php$ diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 16bf24d..0251afe 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -25,6 +25,9 @@ parameters: - vendor/php-stubs/wp-cli-stubs/wp-cli-stubs.php - vendor/php-stubs/wp-cli-stubs/wp-cli-commands-stubs.php - tests/phpstan/compat-stubs.php + - tests/phpstan/beta-site-health-stubs.php + stubFiles: + - tests/phpstan/site-health-7-2-stubs.php bootstrapFiles: - tests/phpstan-bootstrap.php treatPhpDocTypesAsCertain: false @@ -67,3 +70,13 @@ parameters: identifier: return.type path: src/wp-includes/secrets/class-wp-secrets-config-key-provider.php count: 1 + # r65394's WP_Debug_Data::get_wp_secrets_api() documents its return as + # array, as the other sections of WP_Debug_Data do, and + # level 10 wants the inner array typed too. The method is a copy of core's, + # kept identical so it can be compared with core on trunk; core's own PHPStan + # level doesn't report this. + - + message: '#^Method Secrets_API_Site_Health::get_wp_secrets_api\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + path: plugin/class-secrets-api-site-health.php + count: 1 diff --git a/plugin/class-secrets-api-site-health.php b/plugin/class-secrets-api-site-health.php new file mode 100644 index 0000000..b2e1bdc --- /dev/null +++ b/plugin/class-secrets-api-site-health.php @@ -0,0 +1,544 @@ +>> $tests Existing Site Health tests. + * @return array>> + */ + public function add_tests( $tests ) { + $tests['direct']['secrets_api_key_source'] = array( + 'label' => __( 'Secrets API key source', 'secrets-api' ), + 'test' => array( $this, 'get_test_secrets_api_key_source' ), + ); + + $tests['direct']['secrets_api_undecryptable'] = array( + 'label' => __( 'Secrets API: undecryptable secrets', 'secrets-api' ), + 'test' => array( $this, 'get_test_secrets_api_undecryptable' ), + ); + + $tests['direct']['secrets_api_needs_rotation'] = array( + 'label' => __( 'Secrets API: credentials pending rotation', 'secrets-api' ), + 'test' => array( $this, 'get_test_secrets_api_needs_rotation' ), + ); + + return $tests; + } + + /** + * Adds the Secrets API section, as WP_Debug_Data::debug_data() does in core. + * + * @param array> $info Existing debug information sections. + * @return array> + */ + public function add_debug_information( $info ) { + $info['wp-secrets-api'] = self::get_wp_secrets_api(); + + return $info; + } + + /** + * 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

    %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; + } + + /** + * 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, + ); + } +} diff --git a/secrets-api.php b/secrets-api.php index 5280758..ebb2288 100644 --- a/secrets-api.php +++ b/secrets-api.php @@ -129,7 +129,9 @@ function wp_secrets_api_bootstrap(): void { * is false during some of the same contexts Site Health's own async REST checks * run in, which would make the gate unreliable in exactly the cases it matters. */ - require_once WP_SECRETS_API_PLUGIN_DIR . 'src/wp-admin/includes/secrets-site-health.php'; + require_once WP_SECRETS_API_PLUGIN_DIR . 'plugin/class-secrets-api-site-health.php'; + + Secrets_API_Site_Health::register(); /* * Installed as the default store so that a site carrying prototype-format 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 bbf97da..0000000 --- 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

    %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/src/wp-includes/secrets.php b/src/wp-includes/secrets.php index 0a8163c..ce574be 100644 --- a/src/wp-includes/secrets.php +++ b/src/wp-includes/secrets.php @@ -317,6 +317,7 @@ function wp_secrets_validate_name( string $name ) { * be the exact silent downgrade this API refuses to make. * * @since 7.2.0 + * @access private * * @global bool $wp_secrets_dropin_broken Whether the secrets.php drop-in failed to load cleanly. * @global WP_Secrets_Store $wp_secrets_store Optional. Store override set by the drop-in. @@ -356,6 +357,7 @@ function _wp_secrets_get_store(): WP_Secrets_Store { * fails closed (WP_Secrets_Broken_Keyring) rather than falling back to the default. * * @since 7.2.0 + * @access private * * @global bool $wp_secrets_dropin_broken Whether the secrets.php drop-in failed to load cleanly. * @global WP_Secrets_Keyring $wp_secrets_keyring Optional. Keyring override set by the drop-in. @@ -632,6 +634,7 @@ function wp_list_network_secrets( string $namespace = '' ) { // phpcs:ignore Uni * to be empty. * * @since 7.2.0 + * @access private * * @global bool $wp_secrets_dropin_broken Whether the secrets.php drop-in failed to load cleanly. * @global WP_Secrets_Provider $wp_secrets_provider Optional. Provider override set by the drop-in. @@ -781,6 +784,7 @@ function wp_secrets_provider_label(): string { * Shared implementation behind wp_set_secret() and wp_set_network_secret(). * * @since 7.2.0 + * @access private * * @param string $name The secret's name. * @param string $value The plaintext value. @@ -805,6 +809,7 @@ function _wp_secrets_set( * Shared implementation behind wp_get_secret() and wp_get_network_secret(). * * @since 7.2.0 + * @access private * * @param string $name The secret's name. * @param string $version A WP_Secret_Version constant. @@ -840,6 +845,7 @@ function _wp_secrets_get( string $name, string $version, bool $network ) { * Shared implementation behind wp_delete_secret() and wp_delete_network_secret(). * * @since 7.2.0 + * @access private * * @param string $name The secret's name. * @param bool $network Whether this is a network-scope secret. @@ -853,6 +859,7 @@ function _wp_secrets_delete( string $name, bool $network ) { * Shared implementation behind wp_retire_secret_version() and its network twin. * * @since 7.2.0 + * @access private * * @param string $name The secret's name. * @param bool $network Whether this is a network-scope secret. @@ -866,6 +873,7 @@ function _wp_secrets_retire( string $name, bool $network ) { * Shared implementation behind wp_list_secrets() and wp_list_network_secrets(). * * @since 7.2.0 + * @access private * * @param string $name_prefix Restrict to names beginning with this prefix. * @param bool $network Whether to list network-scope secrets. diff --git a/tests/phpstan/beta-site-health-stubs.php b/tests/phpstan/beta-site-health-stubs.php new file mode 100644 index 0000000..28b15b9 --- /dev/null +++ b/tests/phpstan/beta-site-health-stubs.php @@ -0,0 +1,23 @@ +assertIsString( $source, "{$file} should be readable." ); + $this->assertSame( + 1, + preg_match( '/\n(\t\/\*\*\n(?:\t \*[^\n]*\n)*\t \*\/\n\t[a-z ]*function ' . preg_quote( $method, '/' ) . '\(.*?\n\t\}\n)/s', $source, $matches ), + "{$method}() should be declared in {$file}." + ); + + return $matches[1]; + } + + /** + * Data provider. + * + * @return array The core file, relative to ABSPATH, and the method name. + */ + public function data_copied_methods(): array { + $site_health = 'wp-admin/includes/class-wp-site-health.php'; + $debug_data = 'wp-admin/includes/class-wp-debug-data.php'; + + return array( + 'get_test_secrets_api_key_source' => array( $site_health, 'get_test_secrets_api_key_source' ), + 'get_test_secrets_api_undecryptable' => array( $site_health, 'get_test_secrets_api_undecryptable' ), + 'get_test_secrets_api_needs_rotation' => array( $site_health, 'get_test_secrets_api_needs_rotation' ), + 'get_secrets_api_test_result' => array( $site_health, 'get_secrets_api_test_result' ), + 'secrets_api_uses_keyring' => array( $site_health, 'secrets_api_uses_keyring' ), + 'get_undecryptable_secrets' => array( $site_health, 'get_undecryptable_secrets' ), + 'count_secrets_needing_rotation' => array( $site_health, 'count_secrets_needing_rotation' ), + 'get_wp_secrets_api' => array( $debug_data, 'get_wp_secrets_api' ), + ); + } + + /** + * Ensure that the plugin's copy of a method matches core's. + * + * @dataProvider data_copied_methods + * + * @param string $core_file Core file that declares the method, relative to ABSPATH. + * @param string $method Method name. + */ + public function test_copy_matches_core( string $core_file, string $method ): void { + $this->assertSame( + $this->method_source( ABSPATH . $core_file, $method ), + $this->method_source( WP_SECRETS_API_PLUGIN_DIR . 'plugin/class-secrets-api-site-health.php', $method ), + "Secrets_API_Site_Health::{$method}() should match core's. Copy it again from {$core_file}." + ); + } + + /** + * Ensure that the plugin copies every Secrets API method core has. + * + * A new method in core that the copies call would otherwise go unnoticed until the + * plugin failed on WordPress before 7.2. + */ + public function test_copies_every_core_method(): void { + $copied = array_column( $this->data_copied_methods(), 1 ); + + foreach ( array( 'WP_Site_Health', 'WP_Debug_Data' ) as $class ) { + if ( ! class_exists( $class, false ) ) { + require_once ABSPATH . 'wp-admin/includes/class-' . str_replace( '_', '-', strtolower( $class ) ) . '.php'; + } + + foreach ( ( new ReflectionClass( $class ) )->getMethods() as $method ) { + if ( preg_match( '/secrets/', $method->getName() ) ) { + $this->assertContains( $method->getName(), $copied, "{$class}::{$method->getName()}() is not copied in Secrets_API_Site_Health." ); + } + } + } + } +} diff --git a/tests/phpunit/test-secrets-site-health.php b/tests/phpunit/test-secrets-site-health.php index bf55ca4..b8f899a 100644 --- a/tests/phpunit/test-secrets-site-health.php +++ b/tests/phpunit/test-secrets-site-health.php @@ -1,28 +1,48 @@ The section. + */ + private function debug_section(): array { + $method = new ReflectionMethod( 'Secrets_API_Site_Health', '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,48 +53,69 @@ 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 Secrets_API_Site_Health::add_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 { + $site_health = new Secrets_API_Site_Health(); + $tests = $site_health->add_tests( array() ); + + 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( array( $site_health, "get_test_{$key}" ), $tests['direct'][ $key ]['test'], "The {$key} test should call its own method." ); + $this->assertTrue( method_exists( 'Secrets_API_Site_Health', "get_test_{$key}" ), "Secrets_API_Site_Health::get_test_{$key}() should exist." ); } } /** - * Ensure that wp_secrets_site_health_tests() keeps tests that were already registered. + * Ensure that add_tests() keeps tests that were already registered. * - * @covers ::wp_secrets_site_health_tests + * @covers Secrets_API_Site_Health::add_tests */ public function test_preserves_existing_tests(): void { - $tests = wp_secrets_site_health_tests( array( 'direct' => array( 'existing_test' => array( 'label' => 'x' ) ) ) ); + $tests = ( new Secrets_API_Site_Health() )->add_tests( array( 'direct' => array( 'existing_test' => array( 'label' => 'x' ) ) ) ); $this->assertArrayHasKey( 'existing_test', $tests['direct'], 'A previously registered test should be preserved.' ); } + /** + * Ensure that the debug information is added under the key core uses. + * + * @covers Secrets_API_Site_Health::add_debug_information + */ + public function test_adds_debug_information_under_core_key(): void { + $info = ( new Secrets_API_Site_Health() )->add_debug_information( array( 'wp-core' => array( 'label' => 'x' ) ) ); + + $this->assertArrayHasKey( 'wp-core', $info, 'A previously added section should be preserved.' ); + $this->assertSame( $this->debug_section(), $info['wp-secrets-api'], 'The Secrets API section should be added under the wp-secrets-api key.' ); + } + + /** + * Ensure that the plugin hooks the tests and the debug information into Site Health. + * + * @covers Secrets_API_Site_Health::register + */ + public function test_hooks_into_site_health(): void { + $tests = apply_filters( 'site_status_tests', array( 'direct' => array() ) ); + $info = apply_filters( 'debug_information', array() ); + + $this->assertArrayHasKey( 'secrets_api_key_source', $tests['direct'], 'The site_status_tests filter should add the key source test.' ); + $this->assertArrayHasKey( 'wp-secrets-api', $info, 'The debug_information filter should add the Secrets API section.' ); + } + /** * Ensure that the key source check offers a generated define line when WP_SECRETS_KEY is not defined. * * Runs in a separate process, where the test bootstrap does not define WP_SECRETS_KEY. * - * @covers ::wp_secrets_site_health_test_key_source + * @covers Secrets_API_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 Secrets_API_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 +128,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 Secrets_API_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -95,7 +136,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 Secrets_API_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 +147,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 Secrets_API_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -121,7 +162,7 @@ static function ( $translation, $text ) { 2 ); - $result = wp_secrets_site_health_test_key_source(); + $result = ( new Secrets_API_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 +174,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 Secrets_API_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -141,7 +182,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 Secrets_API_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 +196,7 @@ public function test_key_source_test_recommends_regeneration_for_a_legacy_key(): * * @ticket 66380 * - * @covers ::wp_secrets_site_health_test_key_source + * @covers Secrets_API_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -165,7 +206,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 Secrets_API_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 +217,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 Secrets_API_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 +225,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 Secrets_API_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 +239,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 Secrets_API_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -209,7 +250,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 Secrets_API_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 +265,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 Secrets_API_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -236,7 +277,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 Secrets_API_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 +290,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 Secrets_API_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -257,7 +298,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 Secrets_API_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 +309,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 Secrets_API_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -276,7 +317,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 Secrets_API_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 +329,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 Secrets_API_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -305,7 +346,7 @@ static function ( $translation, $text ) { 2 ); - $result = wp_secrets_site_health_test_key_source(); + $result = ( new Secrets_API_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 +358,7 @@ static function ( $translation, $text ) { * * @ticket 66380 * - * @covers ::wp_secrets_site_health_test_key_source + * @covers Secrets_API_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -328,7 +369,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 Secrets_API_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 +380,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 Secrets_API_Site_Health::get_test_secrets_api_undecryptable * * @runInSeparateProcess * @preserveGlobalState disabled @@ -347,7 +388,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 Secrets_API_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 +398,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 Secrets_API_Site_Health::get_test_secrets_api_needs_rotation * * @runInSeparateProcess * @preserveGlobalState disabled @@ -365,7 +406,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 Secrets_API_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 +414,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 Secrets_API_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 Secrets_API_Site_Health() )->get_test_secrets_api_undecryptable(); $this->assertSame( 'good', $result['status'], 'The status should be good when every secret decrypts.' ); } @@ -386,10 +427,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 Secrets_API_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 Secrets_API_Site_Health() )->get_test_secrets_api_undecryptable(); $this->assertSame( 'good', $result['status'], 'The status should be good when no secrets are stored.' ); } @@ -399,7 +440,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 Secrets_API_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 +458,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 Secrets_API_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 +468,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 Secrets_API_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 +482,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 Secrets_API_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 +490,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 Secrets_API_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 Secrets_API_Site_Health() )->get_test_secrets_api_needs_rotation(); $this->assertSame( 'good', $result['status'], 'A directly stored secret should not need rotation.' ); } @@ -462,13 +503,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 Secrets_API_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 Secrets_API_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 +518,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 Secrets_API_Site_Health::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 +548,7 @@ public function test_debug_info_reports_expected_fields(): void { * * @ticket 66380 * - * @covers ::wp_secrets_site_health_debug_info + * @covers Secrets_API_Site_Health::get_wp_secrets_api * * @runInSeparateProcess * @preserveGlobalState disabled @@ -519,7 +560,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 +569,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 Secrets_API_Site_Health::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 +583,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 Secrets_API_Site_Health::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 Secrets_API_Site_Health::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 +614,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 Secrets_API_Site_Health::get_wp_secrets_api */ public function test_debug_info_includes_network_fields_only_for_a_super_admin(): void { $this->skipWithoutMultisite(); @@ -581,12 +622,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.' ); } } diff --git a/tests/phpunit/test-secrets-stand-down.php b/tests/phpunit/test-secrets-stand-down.php index 3ff1c4b..ec83d86 100644 --- a/tests/phpunit/test-secrets-stand-down.php +++ b/tests/phpunit/test-secrets-stand-down.php @@ -57,11 +57,28 @@ public function test_core_declares_the_api(): void { * @covers ::wp_secrets_api_bootstrap */ public function test_the_plugin_only_layer_is_not_loaded(): void { - foreach ( array( 'Secrets_API_Migrator', 'Secrets_API_Legacy_Reader', 'Secrets_API_Prototype_Fallback_Store' ) as $name ) { + foreach ( array( 'Secrets_API_Migrator', 'Secrets_API_Legacy_Reader', 'Secrets_API_Prototype_Fallback_Store', 'Secrets_API_Site_Health' ) as $name ) { $this->assertFalse( class_exists( $name, false ), "{$name} should not be loaded when core ships the API." ); } } + /** + * Ensure that `wp secret health` runs core's Site Health checks. + * + * Core loads WP_Site_Health only for the Site Health screen, so the command has to load + * it itself. The plugin's own copies of the checks are not loaded on this WordPress. + * + * @covers WP_CLI_Secret_Command::health + */ + public function test_cli_health_runs_core_checks(): void { + WP_CLI::reset(); + + ( new WP_CLI_Secret_Command() )->health( array(), array() ); + + $this->assertTrue( class_exists( 'WP_Site_Health', false ), 'The command should load WP_Site_Health.' ); + $this->assertCount( 3, WP_CLI::$formatted_items[0]['items'], 'The command should report core\'s three Secrets API checks.' ); + } + /** * Ensure that the plugin installs no store of its own in front of core's. *