*/ private $group_source; /** * @param callable():array|null $group_source Testable fixed-query adapter. */ public function __construct( FindingFactory $findings, ?callable $group_source = null ) { $this->findings = $findings; $this->group_source = $group_source ?? array( $this, 'database_groups' ); } public function id(): string { return 'database.cleanup_candidates'; } /** * @return array */ public function execute( string $timestamp ): array { try { $groups = call_user_func( $this->group_source ); } catch ( Throwable $exception ) { return $this->error_finding( $timestamp ); } if ( ! is_array( $groups ) ) { return $this->error_finding( $timestamp ); } $objects = array(); $seen = array(); $invalid = 0; $affected = 0; $truncated = count( $groups ) > count( self::GROUPS ); foreach ( array_slice( $groups, 0, count( self::GROUPS ) ) as $group ) { if ( ! is_array( $group ) || ! isset( $group['type'], $group['count'] ) || ! is_string( $group['type'] ) || ! in_array( $group['type'], self::GROUPS, true ) || isset( $seen[ $group['type'] ] ) ) { ++$invalid; continue; } $count = $this->non_negative_integer( $group['count'] ); if ( null === $count ) { ++$invalid; continue; } $seen[ $group['type'] ] = true; $objects[] = array( 'type' => $group['type'], 'count' => $count, ); if ( $count > 0 ) { ++$affected; } } $observed = count( $objects ); $total = count( self::GROUPS ); if ( 0 === $observed ) { $status = 'not_tested'; $coverage = 'none'; } elseif ( $observed < $total || $invalid > 0 || $truncated ) { $status = 'not_tested'; $coverage = 'partial'; } elseif ( $affected > 0 ) { $status = 'info'; $coverage = 'complete'; } else { $status = 'pass'; $coverage = 'complete'; } return $this->findings->build( $this->id(), $status, array( 'objects' => $objects, 'observed_count' => $observed, 'total_count' => $total, 'truncated' => $truncated, 'limitations' => array( 'Only seven fixed aggregate counts were queried. Candidate presence is inventory, not permission to delete data.', 'No post, comment, metadata, option name, option value or other row content was selected or stored.', ), ), $observed, $total, $coverage, $timestamp ); } /** * Execute only hard-coded aggregate queries against validated WordPress tables. * * @return list */ private function database_groups(): array { global $wpdb; $tables = array( $wpdb->posts, $wpdb->postmeta, $wpdb->comments, $wpdb->options ); foreach ( $tables as $table ) { if ( ! is_string( $table ) || 1 !== preg_match( '/^[A-Za-z0-9_]+$/', $table ) ) { throw new RuntimeException( 'database_table_unavailable' ); } } $queries = array( 'post_revisions' => $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s", 'revision' ), 'auto_drafts' => $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = %s", 'auto-draft' ), 'trashed_posts' => $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = %s", 'trash' ), 'orphan_postmeta' => "SELECT COUNT(*) FROM {$wpdb->postmeta} pm LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.ID IS NULL", 'spam_comments' => $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->comments} WHERE comment_approved = %s", 'spam' ), 'trashed_comments' => $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->comments} WHERE comment_approved = %s", 'trash' ), 'expired_transient_timeouts' => $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE %s AND CAST(option_value AS UNSIGNED) < %d", $wpdb->esc_like( '_transient_timeout_' ) . '%', time() ), ); $groups = array(); foreach ( $queries as $type => $query ) { // Table names are validated WordPress properties; every value is prepared. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared $value = $wpdb->get_var( $query ); $count = $this->non_negative_integer( $value ); if ( null === $count ) { throw new RuntimeException( 'database_aggregate_unavailable' ); } $groups[] = array( 'type' => $type, 'count' => $count, ); } return $groups; } /** * @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; } /** * @return array */ private function error_finding( string $timestamp ): array { return $this->findings->build( $this->id(), 'error', array( 'objects' => array(), 'observed_count' => 0, 'total_count' => count( self::GROUPS ), 'truncated' => false, 'limitations' => array( 'A fixed aggregate query failed; no database error, table, row content or connection detail was stored.' ), ), 0, count( self::GROUPS ), 'error', $timestamp ); } }