*/ private $column_source; private string $hash_key; /** * @param callable():array|null $column_source Testable information-schema adapter. */ public function __construct( FindingFactory $findings, ?callable $column_source = null, ?string $hash_key = null ) { $this->findings = $findings; $this->column_source = $column_source ?? array( $this, 'database_columns' ); $this->hash_key = $hash_key ?? ( new LocalHashKey() )->derive(); } public function id(): string { return 'database.component_schema'; } /** * @return array */ public function execute( string $timestamp ): array { try { $rows = call_user_func( $this->column_source ); } catch ( Throwable $exception ) { return $this->error_finding( $timestamp ); } if ( ! is_array( $rows ) ) { return $this->error_finding( $timestamp ); } $tables = array(); $invalid = 0; $truncated = count( $rows ) > self::PROCESS_LIMIT; foreach ( array_slice( $rows, 0, self::PROCESS_LIMIT ) as $row ) { if ( ! is_array( $row ) || ! isset( $row['name'], $row['primary'] ) || ! is_string( $row['name'] ) || ! is_bool( $row['primary'] ) || 1 !== preg_match( '/^[^\x00-\x1F\x7F]{1,64}$/', $row['name'] ) ) { ++$invalid; continue; } if ( ! isset( $tables[ $row['name'] ] ) ) { $tables[ $row['name'] ] = array( 'columns' => 0, 'primary_key' => false, ); } ++$tables[ $row['name'] ]['columns']; $tables[ $row['name'] ]['primary_key'] = $tables[ $row['name'] ]['primary_key'] || $row['primary']; } ksort( $tables ); $objects = array(); $affected = array(); $without_primary = 0; foreach ( $tables as $name => $signals ) { if ( ! $signals['primary_key'] ) { ++$without_primary; } if ( count( $objects ) >= self::EVIDENCE_LIMIT ) { $truncated = true; continue; } $reference = hash_hmac( 'sha256', $name, $this->hash_key ); $objects[] = array( 'reference' => $reference, 'column_count' => $signals['columns'], 'primary_key' => $signals['primary_key'], ); $affected[] = array( 'type' => 'custom_database_table', 'reference' => $reference, ); } $observed = count( $tables ); if ( 0 === count( $rows ) ) { $status = 'pass'; $coverage = 'complete'; } elseif ( 0 === $observed ) { $status = 'not_tested'; $coverage = 'none'; } elseif ( $without_primary > 0 ) { $status = 'warning'; $coverage = $truncated || $invalid > 0 ? 'partial' : 'complete'; } elseif ( $truncated || $invalid > 0 ) { $status = 'not_tested'; $coverage = 'partial'; } else { $status = 'pass'; $coverage = 'complete'; } return $this->findings->build( $this->id(), $status, array( 'objects' => $objects, 'observed_count' => $observed, 'total_count' => $observed, 'truncated' => $truncated, 'limitations' => array( 'Only column counts and primary-key presence were observed for current-site custom tables; every table name is site-keyed HMAC evidence.', 'A missing primary key is a review signal, not proof that a component schema is invalid. Table ownership, foreign keys, query patterns and migration code were not inferred.', ), ), $observed, $observed, $coverage, $timestamp, $affected ); } /** * Return one safe signal per custom-table column, without column names. * * @return list */ private function database_columns(): array { global $wpdb; $database = isset( $wpdb->dbname ) && is_string( $wpdb->dbname ) ? $wpdb->dbname : ''; $prefix = isset( $wpdb->prefix ) && is_string( $wpdb->prefix ) ? $wpdb->prefix : ''; if ( '' === $database || '' === $prefix ) { throw new RuntimeException( 'database_schema_unavailable' ); } // The schema and prefix are parameters; no table content or column name is selected. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT TABLE_NAME AS table_name, COLUMN_KEY AS column_key FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME LIKE %s ORDER BY TABLE_NAME ASC, ORDINAL_POSITION ASC LIMIT 10001', $database, $wpdb->esc_like( $prefix ) . '%' ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching if ( ! is_array( $rows ) ) { throw new RuntimeException( 'database_schema_unavailable' ); } $core_tables = array_fill_keys( $wpdb->tables( 'all', true ), true ); $global_tables = is_multisite() ? array_fill_keys( $wpdb->tables( 'global', true ), true ) : array(); $owned_prefix = $prefix . 'px320_audit_'; $columns = array(); foreach ( $rows as $row ) { $name = is_array( $row ) && isset( $row['table_name'] ) && is_string( $row['table_name'] ) ? $row['table_name'] : ''; if ( ! $this->belongs_to_current_site( $name, $prefix, $global_tables ) || isset( $core_tables[ $name ] ) || 0 === strpos( $name, $owned_prefix ) ) { continue; } $columns[] = array( 'name' => $name, 'primary' => is_array( $row ) && isset( $row['column_key'] ) && 'PRI' === $row['column_key'], ); } if ( count( $rows ) > self::PROCESS_LIMIT ) { $columns[] = array( 'name' => '', 'primary' => false, ); } return $columns; } /** * @param array $global_tables Multisite global core-table allowlist. */ private function belongs_to_current_site( string $name, string $prefix, array $global_tables ): bool { global $wpdb; if ( 0 !== strpos( $name, $prefix ) || isset( $global_tables[ $name ] ) ) { return false; } if ( is_multisite() && isset( $wpdb->base_prefix ) && is_string( $wpdb->base_prefix ) && $prefix === $wpdb->base_prefix ) { $suffix = substr( $name, strlen( $prefix ) ); return 1 !== preg_match( '/^\d+_/', $suffix ); } return true; } /** * @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 information-schema query failed; no SQL error, schema, table, column, account, host or credential value was stored.' ), ), 0, 1, 'error', $timestamp ); } }