| [ Index ] |
PHP Cross Reference of WordPress Trunk (Updated Daily) |
[Summary view] [Print] [Text view]
1 <?php 2 /** 3 * Secrets API: Site Health integration 4 * 5 * @package WordPress 6 * @subpackage Secrets 7 * @since 7.2.0 8 */ 9 10 /** 11 * Registers the Secrets API's Site Health tests. 12 * 13 * No settings screen – the proposal defers that to 7.3 – but the health signal an 14 * operator needs to notice a broken key, a weak key source, or a pending rotation is 15 * in scope now. 16 * 17 * @since 7.2.0 18 * 19 * @param array<string, array<string, array<string, mixed>>> $tests Existing Site Health tests. 20 * @return array<string, array<string, array<string, mixed>>> 21 */ 22 function wp_secrets_site_health_tests( $tests ) { 23 $tests['direct']['secrets_api_key_source'] = array( 24 'label' => __( 'Secrets API key source' ), 25 'test' => 'wp_secrets_site_health_test_key_source', 26 ); 27 28 $tests['direct']['secrets_api_undecryptable'] = array( 29 'label' => __( 'Secrets API: undecryptable secrets' ), 30 'test' => 'wp_secrets_site_health_test_undecryptable', 31 ); 32 33 $tests['direct']['secrets_api_needs_rotation'] = array( 34 'label' => __( 'Secrets API: credentials pending rotation' ), 35 'test' => 'wp_secrets_site_health_test_needs_rotation', 36 ); 37 38 return $tests; 39 } 40 add_filter( 'site_status_tests', 'wp_secrets_site_health_tests' ); 41 42 /** 43 * Builds the standard shape a Site Health test callback returns. 44 * 45 * @since 7.2.0 46 * 47 * @param string $test The test's own identifier, matching the key registered 48 * in wp_secrets_site_health_tests(). 49 * @param string $label Short label for the test result. 50 * @param string $status One of 'good', 'recommended', 'critical'. 51 * @param string $description HTML description, normally one or more <p> elements. 52 * @return array 53 * 54 * @phpstan-return array{test: string, label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string} 55 * @phpstan-param 'good'|'recommended'|'critical' $status 56 */ 57 function wp_secrets_site_health_result( string $test, string $label, string $status, string $description ): array { 58 $colors = array( 59 'good' => 'green', 60 'recommended' => 'orange', 61 'critical' => 'red', 62 ); 63 64 return array( 65 'test' => $test, 66 'label' => $label, 67 'status' => $status, 68 'badge' => array( 69 'label' => __( 'Security' ), 70 'color' => isset( $colors[ $status ] ) ? $colors[ $status ] : 'blue', 71 ), 72 'description' => $description, 73 ); 74 } 75 76 /** 77 * Site Health test: is the site key a dedicated constant, or a weaker fallback? 78 * 79 * @since 7.2.0 80 * 81 * @return array 82 * 83 * @phpstan-return array{test: string, label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string} 84 */ 85 function wp_secrets_site_health_test_key_source(): array { 86 $key_source = _wp_secrets_get_key_manager()->get_keyring()->get_key_source(); 87 88 if ( false !== strpos( $key_source, 'broken' ) ) { 89 return wp_secrets_site_health_result( 90 'secrets_api_key_source', 91 __( 'Secrets API key source is broken' ), 92 'critical', 93 sprintf( 94 '<p>%s</p>', 95 esc_html__( 'The active keyring did not load correctly. No secret can be encrypted or decrypted until this is fixed.' ) 96 ) 97 ); 98 } 99 100 if ( false !== strpos( $key_source, 'legacy interpretation' ) ) { 101 return wp_secrets_site_health_result( 102 'secrets_api_key_source', 103 __( 'Secrets API key should be regenerated' ), 104 'recommended', 105 sprintf( 106 '<p>%s</p>', 107 esc_html__( 'WP_SECRETS_KEY is defined, but not in the recommended base64-encoded 32-byte form. Regenerate it with `wp secret generate-key`.' ) 108 ) 109 ); 110 } 111 112 if ( false !== strpos( $key_source, 'LOGGED_IN_KEY' ) ) { 113 return wp_secrets_site_health_result( 114 'secrets_api_key_source', 115 __( 'Secrets API is using a fallback key' ), 116 'recommended', 117 sprintf( 118 '<p>%s</p>', 119 esc_html__( 'No dedicated WP_SECRETS_KEY constant is defined. Secrets are encrypted using a key derived from existing salts, which works but is not the recommended configuration. Define WP_SECRETS_KEY with a value from `wp secret generate-key`.' ) 120 ) 121 ); 122 } 123 124 return wp_secrets_site_health_result( 125 'secrets_api_key_source', 126 __( 'Secrets API is using a dedicated key' ), 127 'good', 128 sprintf( '<p>%s</p>', esc_html( $key_source ) ) 129 ); 130 } 131 132 /** 133 * Site Health test: does every stored secret still decrypt? 134 * 135 * Network secrets are included only for a super admin on a multisite install – 136 * never shown to a site administrator who is not one. 137 * 138 * @since 7.2.0 139 * 140 * @return array 141 * 142 * @phpstan-return array{test: string, label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string} 143 */ 144 function wp_secrets_site_health_test_undecryptable(): array { 145 $broken = wp_secrets_site_health_find_undecryptable( false ); 146 147 if ( is_multisite() && is_super_admin() ) { 148 $broken = array_merge( $broken, wp_secrets_site_health_find_undecryptable( true ) ); 149 } 150 151 if ( empty( $broken ) ) { 152 return wp_secrets_site_health_result( 153 'secrets_api_undecryptable', 154 __( 'All secrets can be decrypted' ), 155 'good', 156 sprintf( '<p>%s</p>', esc_html__( 'Every stored secret decrypted successfully.' ) ) 157 ); 158 } 159 160 $items = ''; 161 162 foreach ( $broken as $entry ) { 163 $items .= sprintf( 164 '<li>%s (%s)</li>', 165 esc_html( $entry['name'] ), 166 sprintf( 167 /* translators: %s: A secret's last known fingerprint. */ 168 esc_html__( 'fingerprint: %s' ), 169 esc_html( $entry['fingerprint'] ) 170 ) 171 ); 172 } 173 174 return wp_secrets_site_health_result( 175 'secrets_api_undecryptable', 176 __( 'Some secrets cannot be decrypted' ), 177 'critical', 178 sprintf( 179 '<p>%s</p><ul>%s</ul><p>%s</p>', 180 esc_html__( 'The following secrets exist but could not be decrypted with the current key. There is no way to recover the original value. The credential must be re-entered.' ), 181 $items, 182 esc_html__( 'This can happen after losing WP_SECRETS_KEY, restoring a database backup without its matching key, or a failed key rotation.' ) 183 ) 184 ); 185 } 186 187 /** 188 * Finds secrets that fail to decrypt, for one scope. 189 * 190 * @since 7.2.0 191 * 192 * @param bool $network Whether to check network-scope secrets. 193 * @return array List of array( 'name' => ..., 'fingerprint' => ... ). 194 * 195 * @phpstan-return list<array{name: string, fingerprint: string}> 196 */ 197 function wp_secrets_site_health_find_undecryptable( bool $network ): array { 198 $entries = $network ? wp_list_network_secrets() : wp_list_secrets(); 199 200 if ( is_wp_error( $entries ) ) { 201 return array(); 202 } 203 204 $broken = array(); 205 206 foreach ( $entries as $entry ) { 207 $secret = $network ? wp_get_network_secret( $entry['name'] ) : wp_get_secret( $entry['name'] ); 208 209 if ( is_wp_error( $secret ) ) { 210 $broken[] = array( 211 'name' => $entry['name'], 212 'fingerprint' => $entry['fingerprint'], 213 ); 214 } 215 } 216 217 return $broken; 218 } 219 220 /** 221 * Site Health test: are any secrets flagged as needing rotation? 222 * 223 * @since 7.2.0 224 * 225 * @return array 226 * 227 * @phpstan-return array{test: string, label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string} 228 */ 229 function wp_secrets_site_health_test_needs_rotation(): array { 230 $count = wp_secrets_site_health_count_needing_rotation( false ); 231 232 if ( is_multisite() && is_super_admin() ) { 233 $count += wp_secrets_site_health_count_needing_rotation( true ); 234 } 235 236 if ( 0 === $count ) { 237 return wp_secrets_site_health_result( 238 'secrets_api_needs_rotation', 239 __( 'No secrets are pending rotation' ), 240 'good', 241 sprintf( '<p>%s</p>', esc_html__( 'No secret is flagged as needing rotation.' ) ) 242 ); 243 } 244 245 return wp_secrets_site_health_result( 246 'secrets_api_needs_rotation', 247 __( 'Some secrets are pending rotation' ), 248 'recommended', 249 sprintf( 250 '<p>%s</p>', 251 esc_html( 252 sprintf( 253 /* translators: %d: Number of secrets flagged for rotation. */ 254 _n( 255 '%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.', 256 '%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.', 257 $count 258 ), 259 $count 260 ) 261 ) 262 ) 263 ); 264 } 265 266 /** 267 * Counts secrets flagged needs_rotation, for one scope. 268 * 269 * @since 7.2.0 270 * 271 * @param bool $network Whether to check network-scope secrets. 272 * @return int 273 * 274 * @phpstan-return int<0, max> 275 */ 276 function wp_secrets_site_health_count_needing_rotation( bool $network ): int { 277 $entries = $network ? wp_list_network_secrets() : wp_list_secrets(); 278 279 if ( is_wp_error( $entries ) ) { 280 return 0; 281 } 282 283 $count = 0; 284 285 foreach ( $entries as $entry ) { 286 if ( ! empty( $entry['needs_rotation'] ) ) { 287 ++$count; 288 } 289 } 290 291 return $count; 292 } 293 294 /** 295 * Adds a Secrets API section to Site Health's debug information. 296 * 297 * Counts and class names only – no secret values, and no fingerprints. Network 298 * scope figures are included only for a super admin on a multisite install. 299 * 300 * @since 7.2.0 301 * 302 * @param array<string, array<string, mixed>> $info Existing debug information sections. 303 * @return array<string, array<string, mixed>> 304 */ 305 function wp_secrets_site_health_debug_info( $info ) { 306 $key_manager = _wp_secrets_get_key_manager(); 307 $provider = _wp_secrets_get_provider(); 308 309 $fields = array( 310 'dropin_active' => array( 311 'label' => __( 'Drop-in active' ), 312 'value' => wp_using_secrets_dropin() ? __( 'Yes' ) : __( 'No' ), 313 ), 314 'provider_class' => array( 315 'label' => __( 'Provider class' ), 316 'value' => get_class( $provider ), 317 ), 318 'protected_by' => array( 319 'label' => __( 'Secrets protected by' ), 320 'value' => $provider->get_label(), 321 ), 322 'protection_at' => array( 323 'label' => __( 'Encryption boundary' ), 324 'value' => WP_Secrets_Provider::BOUNDARY_WORDPRESS === $provider->get_protection_boundary() 325 ? __( 'WordPress' ) 326 : __( 'The provider (outside WordPress)' ), 327 ), 328 'writable' => array( 329 'label' => __( 'Accepts writes' ), 330 'value' => $provider->is_writable() ? __( 'Yes' ) : __( 'No' ), 331 ), 332 'keyring_class' => array( 333 'label' => __( 'Keyring class' ), 334 'value' => get_class( $key_manager->get_keyring() ), 335 ), 336 'key_source' => array( 337 'label' => __( 'Key source' ), 338 'value' => $key_manager->get_keyring()->get_key_source(), 339 ), 340 'record_version' => array( 341 'label' => __( 'Record format version' ), 342 'value' => (string) WP_SECRETS_RECORD_VERSION, 343 ), 344 ); 345 346 $site_secrets = wp_list_secrets(); 347 $fields['site_secret_count'] = array( 348 'label' => __( 'Site secrets' ), 349 'value' => is_wp_error( $site_secrets ) ? __( 'Unavailable' ) : (string) count( $site_secrets ), 350 ); 351 352 if ( is_multisite() && is_super_admin() ) { 353 $network_secrets = wp_list_network_secrets(); 354 $fields['network_secret_count'] = array( 355 'label' => __( 'Network secrets' ), 356 'value' => is_wp_error( $network_secrets ) ? __( 'Unavailable' ) : (string) count( $network_secrets ), 357 ); 358 } 359 360 $info['secrets-api'] = array( 361 'label' => __( 'Secrets API' ), 362 'fields' => $fields, 363 ); 364 365 return $info; 366 } 367 add_filter( 'debug_information', 'wp_secrets_site_health_debug_info' );
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
| Generated : Wed Oct 14 08:20:33 2026 | Cross-referenced by PHPXref |