database = $database; $this->table = Schema::tables( $database )['decisions']; } public function save( int $finding_id, string $decision, string $reason_masked, int $actor_id, string $decided_at ): void { $this->validate( $finding_id, $decision, $reason_masked, $actor_id, $decided_at ); // The table 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 $result = $this->database->query( $this->database->prepare( "INSERT INTO {$this->table} (finding_id, decision, reason_masked, actor_id, decided_at) VALUES (%d, %s, %s, %d, %s) ON DUPLICATE KEY UPDATE decision = VALUES(decision), reason_masked = VALUES(reason_masked), actor_id = VALUES(actor_id), decided_at = VALUES(decided_at)", $finding_id, $decision, $reason_masked, $actor_id, $decided_at ) ); if ( false === $result ) { throw new StorageException( 'finding_decision_save_failed' ); } } public function clear( int $finding_id ): void { if ( $finding_id < 1 ) { throw new InvalidArgumentException( 'invalid_finding_decision' ); } $result = $this->database->delete( $this->table, array( 'finding_id' => $finding_id ), array( '%d' ) ); if ( false === $result ) { throw new StorageException( 'finding_decision_clear_failed' ); } } /** * @param list $finding_ids Finding database IDs. * @return array */ public function for_findings( array $finding_ids ): array { $finding_ids = array_values( array_unique( array_filter( $finding_ids, static fn ( $id ): bool => is_int( $id ) && $id > 0 ) ) ); if ( array() === $finding_ids ) { return array(); } if ( count( $finding_ids ) > 200 ) { throw new InvalidArgumentException( 'invalid_finding_decision_query' ); } $placeholders = implode( ', ', array_fill( 0, count( $finding_ids ), '%d' ) ); // Dynamic placeholders are count-bounded integers; the table is a compiled allowlist value. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare $query = $this->database->prepare( "SELECT finding_id, decision, reason_masked, decided_at FROM {$this->table} WHERE finding_id IN ({$placeholders})", ...$finding_ids ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared $rows = $this->database->get_results( $query, ARRAY_A ); if ( ! is_array( $rows ) ) { throw new StorageException( 'finding_decision_read_failed' ); } $out = array(); foreach ( $rows as $row ) { if ( ! is_array( $row ) || ! isset( $row['finding_id'], $row['decision'], $row['reason_masked'], $row['decided_at'] ) ) { throw new StorageException( 'invalid_finding_decision' ); } $finding_id = (int) $row['finding_id']; $this->validate( $finding_id, (string) $row['decision'], (string) $row['reason_masked'], 1, (string) $row['decided_at'] ); $out[ $finding_id ] = array( 'decision' => (string) $row['decision'], 'reason_masked' => (string) $row['reason_masked'], 'actor_role' => 'administrator', 'decided_at' => $this->rfc3339( (string) $row['decided_at'] ), ); } return $out; } private function validate( int $finding_id, string $decision, string $reason_masked, int $actor_id, string $decided_at ): void { if ( $finding_id < 1 || $actor_id < 1 || ! in_array( $decision, self::DECISIONS, true ) || '' === trim( $reason_masked ) || strlen( $reason_masked ) > 500 || 1 === preg_match( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $reason_masked ) || 1 !== preg_match( '/^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$/D', $decided_at ) ) { throw new InvalidArgumentException( 'invalid_finding_decision' ); } $this->rfc3339( $decided_at ); } private function rfc3339( string $mysql_timestamp ): string { $date = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $mysql_timestamp, new DateTimeZone( 'UTC' ) ); $errors = DateTimeImmutable::getLastErrors(); if ( false === $date || ( is_array( $errors ) && ( 0 !== $errors['warning_count'] || 0 !== $errors['error_count'] ) ) || $mysql_timestamp !== $date->format( 'Y-m-d H:i:s' ) ) { throw new InvalidArgumentException( 'invalid_finding_decision' ); } return $date->format( DATE_RFC3339 ); } }