database = $database; $this->contract = $contract; $this->table = Schema::tables( $database )['findings']; } /** * @param array $finding Validated and masked Finding. */ public function save( int $run_id, array $finding ): string { if ( $run_id < 1 ) { throw new InvalidArgumentException( 'invalid_run_id' ); } $this->contract->validate( $finding ); $json = wp_json_encode( $finding, JSON_UNESCAPED_SLASHES ); if ( false === $json || strlen( $json ) > self::MAX_CONTRACT_BYTES ) { throw new StorageException( 'finding_contract_too_large' ); } $uuid = wp_generate_uuid4(); $references = wp_json_encode( $finding['affected_objects'], JSON_UNESCAPED_SLASHES ); $references = false === $references ? '[]' : $references; $object_hash = hash_hmac( 'sha256', (string) $finding['check_id'] . '|' . $references, ( new LocalHashKey() )->derive() ); // Idempotent chunk retry updates the same run/check/object projection. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $existing = $this->database->get_row( $this->database->prepare( "SELECT id, finding_uuid, contract_json FROM {$this->table} WHERE run_id = %d AND check_id = %s AND object_hash = %s LIMIT 1", $run_id, (string) $finding['check_id'], $object_hash ), ARRAY_A ); if ( is_array( $existing ) ) { $previous = json_decode( (string) $existing['contract_json'], true, 64 ); if ( is_array( $previous ) && isset( $previous['first_seen'] ) && is_string( $previous['first_seen'] ) ) { $finding['first_seen'] = $previous['first_seen']; $this->contract->validate( $finding ); $json = wp_json_encode( $finding, JSON_UNESCAPED_SLASHES ); } if ( false === $json || strlen( $json ) > self::MAX_CONTRACT_BYTES ) { throw new StorageException( 'finding_contract_too_large' ); } $updated = $this->database->update( $this->table, array( 'category' => (string) $finding['category'], 'status' => (string) $finding['status'], 'severity' => (string) $finding['severity'], 'confidence' => (string) $finding['confidence'], 'contract_json' => $json, 'last_seen_at' => $this->mysql_timestamp( (string) $finding['last_seen'] ), ), array( 'id' => (int) $existing['id'] ), array( '%s', '%s', '%s', '%s', '%s', '%s' ), array( '%d' ) ); if ( false === $updated ) { throw new StorageException( 'finding_update_failed' ); } return (string) $existing['finding_uuid']; } $inserted = $this->database->insert( $this->table, array( 'run_id' => $run_id, 'finding_uuid' => $uuid, 'check_id' => (string) $finding['check_id'], 'category' => (string) $finding['category'], 'status' => (string) $finding['status'], 'severity' => (string) $finding['severity'], 'confidence' => (string) $finding['confidence'], 'object_hash' => $object_hash, 'contract_json' => $json, 'first_seen_at' => $this->mysql_timestamp( (string) $finding['first_seen'] ), 'last_seen_at' => $this->mysql_timestamp( (string) $finding['last_seen'] ), ), array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) ); if ( false === $inserted ) { throw new StorageException( 'finding_save_failed' ); } return $uuid; } public function count_for_run( int $run_id ): int { if ( $run_id < 1 ) { throw new InvalidArgumentException( 'invalid_run_id' ); } // Table name is selected from the compiled Schema allowlist. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared return (int) $this->database->get_var( $this->database->prepare( "SELECT COUNT(*) FROM {$this->table} WHERE run_id = %d", $run_id ) ); } /** * Return storage identities plus validated contracts for local report projection. * * @return list}> */ public function records_for_run( int $run_id, int $limit = 200 ): array { if ( $run_id < 1 || $limit < 1 || $limit > 200 ) { throw new InvalidArgumentException( 'invalid_finding_query' ); } // Table name is selected from the compiled Schema allowlist; LIMIT is validated. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $rows = $this->database->get_results( $this->database->prepare( "SELECT id, finding_uuid, object_hash, contract_json FROM {$this->table} WHERE run_id = %d ORDER BY id ASC LIMIT %d", $run_id, $limit ), ARRAY_A ); if ( ! is_array( $rows ) ) { throw new StorageException( 'invalid_finding_contract' ); } $records = array(); foreach ( $rows as $row ) { $records[] = $this->record_from_row( $row ); } return $records; } /** * Resolve a report-local opaque Finding identity inside one run. * * @return array{id:int,finding_uuid:string,object_hash:string,finding:array}|null */ public function record_for_run( int $run_id, string $finding_uuid ): ?array { if ( $run_id < 1 || ! wp_is_uuid( $finding_uuid, 4 ) ) { return null; } // Table name is selected from the compiled Schema allowlist and values are parameterized. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $row = $this->database->get_row( $this->database->prepare( "SELECT id, finding_uuid, object_hash, contract_json FROM {$this->table} WHERE run_id = %d AND finding_uuid = %s LIMIT 1", $run_id, $finding_uuid ), ARRAY_A ); return is_array( $row ) ? $this->record_from_row( $row ) : null; } /** * Return validated Finding contracts for an authorized report view. * * @return list> */ public function list_for_run( int $run_id, int $limit = 200 ): array { $findings = array(); foreach ( $this->records_for_run( $run_id, $limit ) as $record ) { $findings[] = $record['finding']; } return $findings; } /** * @param mixed $row Database row. * @return array{id:int,finding_uuid:string,object_hash:string,finding:array} */ private function record_from_row( $row ): array { if ( ! is_array( $row ) || ! isset( $row['id'], $row['finding_uuid'], $row['object_hash'], $row['contract_json'] ) || (int) $row['id'] < 1 || ! is_string( $row['finding_uuid'] ) || ! wp_is_uuid( $row['finding_uuid'], 4 ) || ! is_string( $row['object_hash'] ) || 1 !== preg_match( '/^[a-f0-9]{64}$/D', $row['object_hash'] ) || ! is_string( $row['contract_json'] ) ) { throw new StorageException( 'invalid_finding_contract' ); } $finding = json_decode( $row['contract_json'], true, 64 ); if ( ! is_array( $finding ) ) { throw new StorageException( 'invalid_finding_contract' ); } $this->contract->validate( $finding ); return array( 'id' => (int) $row['id'], 'finding_uuid' => $row['finding_uuid'], 'object_hash' => $row['object_hash'], 'finding' => $finding, ); } private function mysql_timestamp( string $timestamp ): string { try { $date = new DateTimeImmutable( $timestamp ); } catch ( \Exception $exception ) { // The previous exception is retained for local debugging, never rendered. // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped throw new InvalidArgumentException( 'invalid_finding_timestamp', 0, $exception ); } return $date->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ); } }