[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

/wp-admin/includes/ -> secrets-site-health.php (source)

   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' );


Generated : Wed Oct 14 08:20:33 2026 Cross-referenced by PHPXref