*/ private $database_source; /** * @param callable():array|null $database_source Testable read-only database adapter. */ public function __construct( FindingFactory $findings, ?callable $database_source = null ) { $this->findings = $findings; $this->database_source = $database_source ?? array( $this, 'wordpress_database' ); } public function id(): string { // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- Stable registry machine ID. return 'wordpress.database_baseline'; } /** * @return array */ public function execute( string $timestamp ): array { try { $database = call_user_func( $this->database_source ); } catch ( Throwable $exception ) { return $this->error_finding( $timestamp ); } if ( ! is_array( $database ) ) { return $this->error_finding( $timestamp ); } $observations = array(); $version_state = 'unknown'; if ( isset( $database['family'], $database['version'] ) && is_string( $database['family'] ) && is_string( $database['version'] ) && in_array( $database['family'], array( 'mariadb', 'mysql' ), true ) && $this->valid_version( $database['version'] ) ) { $version_state = $this->version_state( $database['family'], $database['version'] ); $observations[] = array( 'setting' => 'server_version', 'family' => $database['family'], 'version' => $database['version'], 'recommendation_state' => $version_state, ); } if ( isset( $database['max_allowed_packet'] ) && is_int( $database['max_allowed_packet'] ) && $database['max_allowed_packet'] > 0 ) { $observations[] = array( 'setting' => 'max_allowed_packet', 'bytes' => $database['max_allowed_packet'], ); } foreach ( array( 'server_charset', 'server_collation', 'default_storage_engine' ) as $setting ) { if ( isset( $database[ $setting ] ) && is_string( $database[ $setting ] ) && 1 === preg_match( '/^[A-Za-z0-9_]{1,64}$/', $database[ $setting ] ) ) { $observations[] = array( 'setting' => $setting, 'value' => strtolower( $database[ $setting ] ), ); } } $observed = count( $observations ); if ( 0 === $observed ) { $status = 'not_tested'; $coverage = 'none'; } elseif ( 'below_legacy_floor' === $version_state ) { $status = 'fail'; $coverage = self::TOTAL_SETTINGS === $observed ? 'complete' : 'partial'; } elseif ( 'legacy' === $version_state ) { $status = 'warning'; $coverage = self::TOTAL_SETTINGS === $observed ? 'complete' : 'partial'; } elseif ( 'recommended' !== $version_state || $observed < self::TOTAL_SETTINGS ) { $status = 'not_tested'; $coverage = 'partial'; } else { $status = 'pass'; $coverage = 'complete'; } return $this->findings->build( $this->id(), $status, array( 'observations' => $observations, 'method' => 'allowlisted_database_metadata_query', 'limitations' => array( 'Only normalized server family/version and four allowlisted settings were read. Database, account, host, table, row, query-log and credential values were not collected.' ), ), $observed, self::TOTAL_SETTINGS, $coverage, $timestamp ); } /** * @return array{family:string,version:string,max_allowed_packet:int|null,server_charset:string|null,server_collation:string|null,default_storage_engine:string|null} */ private function wordpress_database(): array { global $wpdb; $server_info = $wpdb->db_server_info(); $family = $this->database_family( $server_info ); $version = $this->database_version( $family, $server_info, $wpdb->db_version() ); // Fixed read-only server variables; no identifier or value is user supplied. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared $variables = $wpdb->get_row( 'SELECT @@max_allowed_packet AS max_allowed_packet, @@character_set_server AS server_charset, @@collation_server AS server_collation, @@default_storage_engine AS default_storage_engine', ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared return array( 'family' => $family, 'version' => $version, 'max_allowed_packet' => is_array( $variables ) ? $this->positive_integer( $variables['max_allowed_packet'] ?? null ) : null, 'server_charset' => is_array( $variables ) && isset( $variables['server_charset'] ) && is_string( $variables['server_charset'] ) ? $variables['server_charset'] : null, 'server_collation' => is_array( $variables ) && isset( $variables['server_collation'] ) && is_string( $variables['server_collation'] ) ? $variables['server_collation'] : null, 'default_storage_engine' => is_array( $variables ) && isset( $variables['default_storage_engine'] ) && is_string( $variables['default_storage_engine'] ) ? $variables['default_storage_engine'] : null, ); } private function version_state( string $family, string $version ): string { $recommended = 'mariadb' === $family ? '10.11' : '8.0'; if ( version_compare( $version, $recommended, '>=' ) ) { return 'recommended'; } return version_compare( $version, '5.5.5', '>=' ) ? 'legacy' : 'below_legacy_floor'; } private function database_family( string $server_info ): string { $server_info = strtolower( trim( $server_info ) ); if ( false !== strpos( $server_info, 'mariadb' ) ) { return 'mariadb'; } if ( 1 === preg_match( '/\b(?:percona|tidb|aurora)\b/', $server_info ) ) { return 'unknown'; } return 1 === preg_match( '/^\d+\.\d+/', $server_info ) ? 'mysql' : 'unknown'; } private function normalized_version( string $value ): string { if ( 1 !== preg_match( '/^(\d+\.\d+(?:\.\d+)?)/', trim( $value ), $matches ) ) { return 'unknown'; } return $matches[1]; } private function database_version( string $family, string $server_info, string $fallback ): string { if ( 'mariadb' === $family && 1 === preg_match( '/(?:^|[^0-9])(\d+\.\d+(?:\.\d+)?)-mariadb\b/i', $server_info, $matches ) ) { return $matches[1]; } return $this->normalized_version( $fallback ); } private function valid_version( string $value ): bool { return 1 === preg_match( '/^\d+\.\d+(?:\.\d+)?$/', $value ); } /** * @param mixed $value Database variable. */ private function positive_integer( $value ): ?int { if ( ! is_int( $value ) && ! is_string( $value ) ) { return null; } $value = (string) $value; if ( 1 !== preg_match( '/^[1-9]\d{0,17}$/', $value ) ) { return null; } $integer = (int) $value; return $integer > 0 ? $integer : null; } /** * @return array */ private function error_finding( string $timestamp ): array { return $this->findings->build( $this->id(), 'error', array( 'observations' => array(), 'method' => 'allowlisted_database_metadata_query', 'limitations' => array( 'The read-only database adapter failed; no exception, SQL error, server banner, database, account or credential value was stored.' ), ), 0, self::TOTAL_SETTINGS, 'error', $timestamp ); } }