*/ private $autoload_source; private string $hash_key; /** * @param callable():array|null $autoload_source Testable read-only options adapter. */ public function __construct( FindingFactory $findings, ?callable $autoload_source = null, ?string $hash_key = null ) { $this->findings = $findings; $this->autoload_source = $autoload_source ?? array( $this, 'wordpress_autoload' ); $this->hash_key = $hash_key ?? ( new LocalHashKey() )->derive(); } public function id(): string { return 'database.autoload'; } /** * @return array */ public function execute( string $timestamp ): array { try { $inventory = call_user_func( $this->autoload_source ); } catch ( Throwable $exception ) { return $this->error_finding( $timestamp ); } if ( ! is_array( $inventory ) || ! isset( $inventory['groups'], $inventory['largest'] ) || ! is_array( $inventory['groups'] ) || ! is_array( $inventory['largest'] ) ) { return $this->error_finding( $timestamp ); } $autoloaded_count = 0; $autoloaded_bytes = 0; $all_count = 0; $invalid = 0; $states = array( 'autoloaded' => array( 'options' => 0, 'bytes' => 0, ), 'not_autoloaded' => array( 'options' => 0, 'bytes' => 0, ), 'other' => array( 'options' => 0, 'bytes' => 0, ), ); $seen_states = array(); $group_truncated = count( $inventory['groups'] ) > self::GROUP_LIMIT; foreach ( array_slice( $inventory['groups'], 0, self::GROUP_LIMIT ) as $group ) { if ( ! is_array( $group ) || ! isset( $group['state'], $group['count'], $group['bytes'] ) || ! is_string( $group['state'] ) || isset( $seen_states[ $group['state'] ] ) ) { ++$invalid; continue; } $count = $this->non_negative_integer( $group['count'] ); $bytes = $this->non_negative_integer( $group['bytes'] ); if ( null === $count || null === $bytes || 1 !== preg_match( '/^[a-z0-9_-]{1,32}$/', $group['state'] ) ) { ++$invalid; continue; } $seen_states[ $group['state'] ] = true; $bucket = in_array( $group['state'], self::AUTOLOAD_VALUES, true ) ? 'autoloaded' : ( in_array( $group['state'], array( 'no', 'off', 'auto-off' ), true ) ? 'not_autoloaded' : 'other' ); $next_bucket_options = $this->checked_sum( $states[ $bucket ]['options'], $count ); $next_bucket_bytes = $this->checked_sum( $states[ $bucket ]['bytes'], $bytes ); $next_all_count = $this->checked_sum( $all_count, $count ); $next_autoload_count = 'autoloaded' === $bucket ? $this->checked_sum( $autoloaded_count, $count ) : $autoloaded_count; $next_autoload_bytes = 'autoloaded' === $bucket ? $this->checked_sum( $autoloaded_bytes, $bytes ) : $autoloaded_bytes; if ( null === $next_bucket_options || null === $next_bucket_bytes || null === $next_all_count || null === $next_autoload_count || null === $next_autoload_bytes ) { ++$invalid; continue; } $states[ $bucket ]['options'] = $next_bucket_options; $states[ $bucket ]['bytes'] = $next_bucket_bytes; $all_count = $next_all_count; if ( 'autoloaded' === $bucket ) { $autoloaded_count = $next_autoload_count; $autoloaded_bytes = $next_autoload_bytes; } } $largest = array(); $affected = array(); $truncated = $group_truncated || count( $inventory['largest'] ) > self::EVIDENCE_LIMIT; foreach ( array_slice( $inventory['largest'], 0, self::EVIDENCE_LIMIT ) as $option ) { if ( ! is_array( $option ) || ! isset( $option['name'], $option['bytes'] ) || ! is_string( $option['name'] ) || 1 !== preg_match( '/^[^\x00-\x1F\x7F]{1,191}$/', $option['name'] ) ) { $truncated = true; continue; } $bytes = $this->non_negative_integer( $option['bytes'] ); if ( null === $bytes ) { $truncated = true; continue; } $reference = hash_hmac( 'sha256', $option['name'], $this->hash_key ); $largest[] = array( 'reference' => $reference, 'bytes' => $bytes, ); $affected[] = array( 'type' => 'wordpress_option', 'reference' => $reference, ); } if ( 0 === count( $inventory['groups'] ) || count( $inventory['groups'] ) === $invalid ) { $status = 'not_tested'; $coverage = 'none'; } elseif ( $invalid > 0 || $group_truncated ) { $status = $autoloaded_bytes >= self::DEFAULT_LIMIT_BYTES ? 'warning' : 'not_tested'; $coverage = 'partial'; } else { $status = $autoloaded_bytes >= self::DEFAULT_LIMIT_BYTES ? 'warning' : 'pass'; $coverage = 'complete'; } return $this->findings->build( $this->id(), $status, array( 'objects' => array( 'summary' => array( 'autoloaded_options' => $autoloaded_count, 'autoloaded_bytes' => $autoloaded_bytes, 'all_options' => $all_count, 'core_default_limit_bytes' => self::DEFAULT_LIMIT_BYTES, 'states' => $states, ), 'largest' => $largest, ), 'observed_count' => $autoloaded_count, 'total_count' => $autoloaded_count, 'truncated' => $truncated, 'limitations' => array( 'Option names are site-keyed references and option values are never selected or stored.', 'The documented WordPress core default warning limit is used without invoking site filters; persistent-object-cache effects and actual request performance are not measured.', ), ), 'none' === $coverage ? 0 : $autoloaded_count, 'none' === $coverage ? max( 1, $autoloaded_count ) : $autoloaded_count, $coverage, $timestamp, $affected ); } /** * @return array{groups:list,largest:list} */ private function wordpress_autoload(): array { global $wpdb; $options_table = isset( $wpdb->options ) && is_string( $wpdb->options ) ? $wpdb->options : ''; if ( '' === $options_table || 1 !== preg_match( '/^[A-Za-z0-9_]+$/', $options_table ) ) { throw new RuntimeException( 'options_table_unavailable' ); } // The table is the validated current-site core options table; values are aggregated in SQL. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $groups = $wpdb->get_results( $wpdb->prepare( 'SELECT autoload AS autoload_state, COUNT(*) AS option_count, COALESCE(SUM(OCTET_LENGTH(option_value)), 0) AS total_bytes FROM %i GROUP BY autoload ORDER BY autoload ASC LIMIT 101', $options_table ), ARRAY_A ); $largest = $wpdb->get_results( $wpdb->prepare( "SELECT option_name, OCTET_LENGTH(option_value) AS option_bytes FROM %i WHERE autoload IN ('yes','on','auto-on','auto') ORDER BY option_bytes DESC, option_id ASC LIMIT 51", $options_table ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching if ( ! is_array( $groups ) || ! is_array( $largest ) ) { throw new RuntimeException( 'autoload_inventory_unavailable' ); } $normalized_groups = array(); foreach ( $groups as $group ) { $normalized_groups[] = array( 'state' => is_array( $group ) && isset( $group['autoload_state'] ) && is_string( $group['autoload_state'] ) ? strtolower( $group['autoload_state'] ) : '', 'count' => $this->non_negative_integer( is_array( $group ) ? ( $group['option_count'] ?? null ) : null ) ?? -1, 'bytes' => $this->non_negative_integer( is_array( $group ) ? ( $group['total_bytes'] ?? null ) : null ) ?? -1, ); } $normalized_largest = array(); foreach ( $largest as $option ) { $normalized_largest[] = array( 'name' => is_array( $option ) && isset( $option['option_name'] ) && is_string( $option['option_name'] ) ? $option['option_name'] : '', 'bytes' => $this->non_negative_integer( is_array( $option ) ? ( $option['option_bytes'] ?? null ) : null ) ?? -1, ); } return array( 'groups' => $normalized_groups, 'largest' => $normalized_largest, ); } /** * @param mixed $value Integer-like database value. */ private function non_negative_integer( $value ): ?int { if ( ! is_int( $value ) && ! is_string( $value ) ) { return null; } $value = (string) $value; if ( 1 !== preg_match( '/^\d{1,18}$/', $value ) ) { return null; } $integer = (int) $value; return $integer >= 0 ? $integer : null; } private function checked_sum( int $left, int $right ): ?int { if ( $right > PHP_INT_MAX - $left ) { return null; } return $left + $right; } /** * @return array */ private function error_finding( string $timestamp ): array { return $this->findings->build( $this->id(), 'error', array( 'objects' => array(), 'observed_count' => 0, 'total_count' => 0, 'truncated' => false, 'limitations' => array( 'The bounded aggregate query failed; no SQL error, option name/value, table, account, host or credential value was stored.' ), ), 0, 1, 'error', $timestamp ); } }