db = $db; $this->objects = $db->prefix . 'avai_objects'; $this->locks = $db->prefix . 'avai_locks'; $this->queue = $db->prefix . 'avai_work'; } public function migrate(): void { DataRemoval::assertWritable(); require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $charset = $this->db->get_charset_collate(); dbDelta( "CREATE TABLE {$this->objects} ( id varchar(64) NOT NULL, kind varchar(32) NOT NULL, status varchar(32) NOT NULL, body longtext NOT NULL, created bigint NOT NULL, updated bigint NOT NULL, revision bigint NOT NULL DEFAULT 1, PRIMARY KEY (id), KEY kind_status (kind,status,updated) ) $charset;" ); dbDelta( "CREATE TABLE {$this->locks} ( name varchar(64) NOT NULL, owner varchar(64) NOT NULL, expires bigint NOT NULL, PRIMARY KEY (name) ) $charset;" ); dbDelta( "CREATE TABLE {$this->queue} ( id bigint unsigned NOT NULL AUTO_INCREMENT, scan_id varchar(64) NOT NULL, root_id varchar(32) NOT NULL, path text NOT NULL, identity varchar(64) NOT NULL, kind varchar(16) NOT NULL, state varchar(16) NOT NULL, `cursor` bigint NOT NULL DEFAULT 0, attempts int NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY scan_identity (scan_id,identity), KEY scan_state (scan_id,state,id) ) $charset;" ); foreach ( array( $this->objects, $this->locks, $this->queue ) as $table ) { if ( $this->db->get_var( $this->db->prepare( 'SHOW TABLES LIKE %s', $this->db->esc_like( $table ) ) ) !== $table ) { throw new \RuntimeException( 'Migration incomplete; retry activation. Retained evidence has not been removed.' ); } } $required = array( $this->objects => array( 'id', 'kind', 'status', 'body', 'created', 'updated', 'revision' ), $this->locks => array( 'name', 'owner', 'expires' ), $this->queue => array( 'id', 'scan_id', 'root_id', 'path', 'identity', 'kind', 'state', 'cursor', 'attempts' ), ); foreach ( $required as $table => $columns ) { $present = $this->db->get_col( "SHOW COLUMNS FROM $table" ); if ( array_diff( $columns, $present ?: array() ) ) { throw new \RuntimeException( 'Migration columns incomplete; retry activation.' ); } $indexes = $this->db->get_results( "SHOW INDEX FROM $table", ARRAY_A ); $names = array_column( $indexes ?: array(), 'Key_name' ); $expected = $table === $this->objects ? array( 'PRIMARY', 'kind_status' ) : ( $table === $this->locks ? array( 'PRIMARY' ) : array( 'PRIMARY', 'scan_identity', 'scan_state' ) ); if ( array_diff( $expected, $names ) ) { throw new \RuntimeException( 'Migration indexes incomplete; retry activation.' ); } } update_option( 'antivirus_ai_schema', 1, false ); } public static function id( string $prefix = '' ): string { return $prefix . bin2hex( random_bytes( 16 ) ); } /** Recheck permissions after waits or transport without reusing an earlier request cache. */ public static function freshOption( string $name, mixed $default = false ): mixed { wp_cache_delete( $name, 'options' ); wp_cache_delete( 'alloptions', 'options' ); wp_cache_delete( 'notoptions', 'options' ); $value = get_option( $name, $default ); global $wpdb; if ( ! empty( $wpdb->last_error ) ) { throw new \RuntimeException( 'Current settings could not be read. No optional action was authorized; restore database access and retry.' ); } return $value; } /** A false update result can mean unchanged data or a failed database write. */ public static function saveOption( string $name, mixed $value ): void { DataRemoval::assertWritable(); if ( update_option( $name, $value, false ) ) { return; } $missing = new \stdClass(); $stored = self::freshOption( $name, $missing ); if ( $stored === $missing || (string) maybe_serialize( $stored ) !== (string) maybe_serialize( $value ) ) { throw new \RuntimeException( 'The setting could not be persisted. Reload and retry; the previous saved state still applies.' ); } } private function checkedRead( mixed $result ): mixed { if ( ! empty( $this->db->last_error ) ) { throw new \RuntimeException( 'Security evidence could not be read from the database. This is not an empty or clean result; retry after restoring database access.' ); } return $result; } public function put( string $kind, string $id, array $body, string $status = 'active', ?int $expectedRevision = null ): array { DataRemoval::assertWritable(); if ( ! preg_match( '/^[a-zA-Z0-9_-]{1,64}$/D', $id ) || strlen( $kind ) > 32 || strlen( $status ) > 32 ) { throw new \InvalidArgumentException( 'Invalid object identifier.' ); } foreach ( array_keys( $body ) as $key ) { if ( str_starts_with( (string) $key, '_' ) ) { unset( $body[ $key ] ); } } $json = wp_json_encode( $body, JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_SLASHES ); $cap = in_array( $kind, array( 'reference', 'scan' ), true ) ? 1048576 : ( 'finding' === $kind ? 32768 : 65536 ); if ( ! is_string( $json ) || strlen( $json ) > $cap ) { throw new \RuntimeException( 'Record exceeds storage budget.' ); } $old = $this->get( $id ); if ( ! $old ) { if ( in_array( $kind, array( 'activity', 'job' ), true ) && $this->count( $kind ) >= 10000 ) { $sql = $this->db->prepare( "SELECT id FROM {$this->objects} WHERE kind=%s AND status NOT IN ('pending','running','queued') ORDER BY updated ASC LIMIT 100", $kind ); foreach ( $this->db->get_col( $sql ) ?: array() as $expired ) { $this->delete( $expired ); } } $caps = array( 'finding' => 20000, 'scan' => 10000, 'repair' => 5000, 'exception' => 20000, 'activity' => 10000, 'job' => 10000, 'incident' => 20000, 'notification' => 2000, 'analysis' => 1000, 'policy' => 1000, 'file_cache' => 100000, 'summary' => 400, ); if ( isset( $caps[ $kind ] ) && $this->count( $kind ) >= $caps[ $kind ] ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception text is internal data, not HTML output; UI consumers escape at their output context. Escaping here would corrupt JSON/plain-text evidence and chained exception arguments. throw new \RuntimeException( 'Retention safety cap reached for ' . $kind . '. Export retained evidence and request an administrator retention review.' ); } } if ( $old && $old['_kind'] !== $kind ) { throw new \RuntimeException( 'Object kind conflict.' ); } $data = array( 'kind' => $kind, 'status' => $status, 'body' => $json, 'updated' => time(), ); if ( $old ) { $revision = $expectedRevision ?? (int) $old['_revision']; $data['revision'] = $revision + 1; $ok = $this->db->update( $this->objects, $data, array( 'id' => $id, 'revision' => $revision, ) ); if ( 1 !== $ok ) { throw new \RuntimeException( 'Concurrent record update; reload before retrying.' ); } } else { if ( null !== $expectedRevision ) { throw new \RuntimeException( 'Record no longer exists.' ); } $data += array( 'id' => $id, 'created' => time(), 'revision' => 1, ); if ( ! $this->db->insert( $this->objects, $data ) ) { throw new \RuntimeException( 'Could not persist evidence.' ); } } return $this->get( $id ); } public function get( string $id ): ?array { $row = $this->checkedRead( $this->db->get_row( $this->db->prepare( "SELECT * FROM {$this->objects} WHERE id=%s", $id ), ARRAY_A ) ); return $row ? $this->decode( $row ) : null; } private function decode( array $row ): array { $data = json_decode( $row['body'], true, 32, JSON_THROW_ON_ERROR ); return array_merge( $data, array( '_id' => $row['id'], '_kind' => $row['kind'], '_status' => $row['status'], '_revision' => (int) $row['revision'], '_created' => (int) $row['created'], '_updated' => (int) $row['updated'], ) ); } public function list( string $kind, ?string $status = null, int $limit = 100, int $offset = 0 ): array { $limit = max( 1, min( 'reference' === $kind ? 2 : ( 'scan' === $kind ? 10 : 200 ), $limit ) ); $offset = max( 0, $offset ); if ( null === $status ) { $sql = $this->db->prepare( "SELECT * FROM {$this->objects} WHERE kind=%s ORDER BY created DESC,id DESC LIMIT %d OFFSET %d", $kind, $limit, $offset ); } else { $sql = $this->db->prepare( "SELECT * FROM {$this->objects} WHERE kind=%s AND status=%s ORDER BY created DESC,id DESC LIMIT %d OFFSET %d", $kind, $status, $limit, $offset ); } return array_map( fn( $r )=>$this->decode( $r ), $this->checkedRead( $this->db->get_results( $sql, ARRAY_A ) ) ?: array() ); } public function count( string $kind, ?string $status = null ): int { $sql = null === $status ? $this->db->prepare( "SELECT COUNT(*) FROM {$this->objects} WHERE kind=%s", $kind ) : $this->db->prepare( "SELECT COUNT(*) FROM {$this->objects} WHERE kind=%s AND status=%s", $kind, $status ); return (int) $this->checkedRead( $this->db->get_var( $sql ) ); } public function latestBroadCompletedScan(): ?array { $row = $this->checkedRead( $this->db->get_row( "SELECT * FROM {$this->objects} WHERE kind='scan' AND status='completed' AND JSON_EXTRACT(body,'$.target_finding_id') IS NULL ORDER BY created DESC,id DESC LIMIT 1", ARRAY_A ) ); return $row ? $this->decode( $row ) : null; } public function searchFindings( array $filters, int $limit = 25, int $offset = 0 ): array { $where = "kind='finding'"; $args = array(); foreach ( array( 'scan_id', 'category', 'severity', 'confidence' ) as $key ) { if ( isset( $filters[ $key ] ) && '' !== $filters[ $key ] ) { $where .= ' AND ' . $this->jsonField( $key ) . '=%s'; $args[] = substr( (string) $filters[ $key ], 0, 100 ); } } if ( ! empty( $filters['status'] ) ) { $where .= ' AND status=%s'; $args[] = substr( (string) $filters['status'], 0, 32 ); } if ( ! empty( $filters['search'] ) ) { $term = substr( (string) $filters['search'], 0, 200 ); $where .= ' AND (body LIKE %s OR body LIKE %s)'; $args[] = '%' . $this->db->esc_like( $term ) . '%'; $args[] = '%' . $this->db->esc_like( substr( wp_json_encode( $term ), 1, -1 ) ) . '%'; } $base = "SELECT COUNT(*) FROM {$this->objects} WHERE $where"; $total = (int) $this->checkedRead( $this->db->get_var( $args ? $this->db->prepare( $base, ...$args ) : $base ) ); $sql = "SELECT * FROM {$this->objects} WHERE $where ORDER BY updated DESC,id DESC LIMIT %d OFFSET %d"; $rows = $this->checkedRead( $this->db->get_results( $this->db->prepare( $sql, ...array_merge( $args, array( max( 1, min( 200, $limit ) ), max( 0, $offset ) ) ) ), ARRAY_A ) ) ?: array(); return array( 'items' => array_map( fn( $r )=>$this->decode( $r ), $rows ), 'total' => $total, ); } /** Optional review reminders are read-only; they never expire an accepted finding. */ public function dueExceptions( string $today, int $limit = 10 ): array { if ( ! preg_match( '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/D', $today ) ) { throw new \InvalidArgumentException( 'A calendar date is required.' ); } $field = $this->jsonField( 'review_on' ); $sql = $this->db->prepare( "SELECT * FROM {$this->objects} WHERE kind='exception' AND status='active' AND $field<>'' AND $field<=%s ORDER BY $field ASC,id ASC LIMIT %d", $today, max( 1, min( 100, $limit ) ) ); $rows = $this->checkedRead( $this->db->get_results( $sql, ARRAY_A ) ) ?: array(); return array_map( fn( $row ) => $this->decode( $row ), $rows ); } public function byRescan( string $scanId ): array { $sql = $this->db->prepare( "SELECT * FROM {$this->objects} WHERE kind='repair' AND status IN ('verification_pending','health_failed') AND " . $this->jsonField( 'rescan_id' ) . '=%s LIMIT 100', $scanId ); return array_map( fn( $r )=>$this->decode( $r ), $this->checkedRead( $this->db->get_results( $sql, ARRAY_A ) ) ?: array() ); } private function jsonField( string $field ): string { if ( ! in_array( $field, array( 'scan_id', 'category', 'severity', 'confidence', 'rescan_id', 'review_on' ), true ) ) { throw new \InvalidArgumentException( 'Unknown indexed evidence field.' ); } $expression = "JSON_EXTRACT(body,'$.{$field}')"; // The official SQLite test adapter returns scalar strings directly; MySQL/MariaDB quote them. return is_a( $this->db, 'WP_SQLite_DB' ) ? $expression : 'JSON_UNQUOTE(' . $expression . ')'; } /** Remove exactly the reviewed record revision; never erase a concurrent update. */ public function deleteExpected( string $id, string $kind, int $expectedRevision ): void { DataRemoval::assertWritable(); if ( $expectedRevision < 1 || 1 !== $this->db->delete( $this->objects, array( 'id' => $id, 'kind' => $kind, 'revision' => $expectedRevision ) ) ) { throw new \RuntimeException( 'The record changed or could not be removed. Reload before retrying.' ); } } public function delete( string $id ): void { DataRemoval::assertWritable(); if ( false === $this->db->delete( $this->objects, array( 'id' => $id ) ) ) { throw new \RuntimeException( 'The security record could not be deleted. Its retained state is unchanged; retry.' ); } } public function lock( string $name, int $seconds = 120 ): ?string { DataRemoval::assertWritable(); $owner = bin2hex( random_bytes( 24 ) ); $now = time(); $expiry = $now + max( 15, min( 600, $seconds ) ); $this->db->query( $this->db->prepare( "UPDATE {$this->locks} SET owner=%s,expires=%d WHERE name=%s AND expires<%d", $owner, $expiry, $name, $now ) ); $this->db->query( $this->db->prepare( "INSERT IGNORE INTO {$this->locks} (name,owner,expires) VALUES (%s,%s,%d)", $name, $owner, $expiry ) ); $actual = $this->db->get_var( $this->db->prepare( "SELECT owner FROM {$this->locks} WHERE name=%s", $name ) ); return is_string( $actual ) && hash_equals( $owner, $actual ) ? $owner : null; } /** Verify a held lock immediately around bounded privacy mutations. */ public function ownsLock( string $name, string $owner ): bool { $actual = $this->checkedRead( $this->db->get_var( $this->db->prepare( "SELECT owner FROM {$this->locks} WHERE name=%s AND expires>%d", $name, time() ) ) ); return is_string( $actual ) && hash_equals( $owner, $actual ); } public function unlock( string $name, string $owner ): void { $this->db->delete( $this->locks, array( 'name' => $name, 'owner' => $owner, ) ); } public function enqueue( string $scan, string $root, string $path, string $kind ): bool { DataRemoval::assertWritable(); if ( $this->workCount( $scan ) >= 100000 ) { throw new \RuntimeException( 'Scan path budget reached.' ); } $result = $this->db->query( $this->db->prepare( "INSERT IGNORE INTO {$this->queue} (scan_id,root_id,path,identity,kind,state) VALUES (%s,%s,%s,%s,%s,'pending')", $scan, $root, $path, hash( 'sha256', $root . '|' . $path . '|' . $kind ), $kind ) ); if ( false === $result ) { throw new \RuntimeException( 'Cannot persist scan cursor.' ); } return 1 === $result; } public function work( string $scan, int $limit = 25 ): array { return $this->checkedRead( $this->db->get_results( $this->db->prepare( "SELECT * FROM {$this->queue} WHERE scan_id=%s AND state='pending' ORDER BY id ASC LIMIT %d", $scan, max( 1, min( 100, $limit ) ) ), ARRAY_A ) ) ?: array(); } public function finishWork( int $id, string $state, int $cursor = 0 ): void { DataRemoval::assertWritable(); if ( ! in_array( $state, array( 'pending', 'done', 'skipped', 'failed' ), true ) ) { throw new \InvalidArgumentException( 'Invalid work state.' ); } if ( false === $this->db->update( $this->queue, array( 'state' => $state, 'cursor' => $cursor, ), array( 'id' => $id ) ) ) { throw new \RuntimeException( 'Cannot persist scan progress.' ); } } /** Scope checkpoint replay to its real queue row; reject missing or regressed work. */ public function finishWorkForScan( string $scan, int $id, string $state, int $cursor = 0 ): void { DataRemoval::assertWritable(); if ( ! preg_match( '/^s_[a-f0-9]{32}$/D', $scan ) || $id < 1 || $cursor < 0 || ! in_array( $state, array( 'pending', 'done', 'skipped', 'failed' ), true ) ) { throw new \InvalidArgumentException( 'Invalid scoped scan work transition.' ); } $row = $this->checkedRead( $this->db->get_row( $this->db->prepare( "SELECT state,`cursor` FROM {$this->queue} WHERE id=%d AND scan_id=%s", $id, $scan ), ARRAY_A ) ); if ( ! $row ) { throw new \RuntimeException( 'Saved scan checkpoint has no matching work row.' ); } $before = (int) $row['cursor']; if ( $row['state'] === $state && $before === $cursor ) { return; } if ( 'pending' !== $row['state'] || $cursor < $before ) { throw new \RuntimeException( 'Saved scan checkpoint conflicts with newer work progress.' ); } $updated = $this->db->update( $this->queue, array( 'state' => $state, 'cursor' => $cursor ), array( 'id' => $id, 'scan_id' => $scan, 'state' => $row['state'], 'cursor' => $before ) ); if ( false === $updated ) { throw new \RuntimeException( 'Cannot persist scoped scan progress.' ); } $after = $this->checkedRead( $this->db->get_row( $this->db->prepare( "SELECT state,`cursor` FROM {$this->queue} WHERE id=%d AND scan_id=%s", $id, $scan ), ARRAY_A ) ); if ( ! $after || $state !== $after['state'] || $cursor !== (int) $after['cursor'] ) { throw new \RuntimeException( 'Scoped scan progress could not be verified.' ); } } public function workCount( string $scan, ?string $state = null ): int { return (int) $this->checkedRead( $this->db->get_var( null === $state ? $this->db->prepare( "SELECT COUNT(*) FROM {$this->queue} WHERE scan_id=%s", $scan ) : $this->db->prepare( "SELECT COUNT(*) FROM {$this->queue} WHERE scan_id=%s AND state=%s", $scan, $state ) ) ); } public function clearWork( string $scan ): void { DataRemoval::assertWritable(); if ( false === $this->db->delete( $this->queue, array( 'scan_id' => $scan ) ) ) { throw new \RuntimeException( 'The scan queue could not be cleared. Retained work must be reviewed before retrying.' ); } } public function activity( string $event, array $details = array() ): void { $this->put( 'activity', self::id( 'a_' ), array( 'event' => $event, 'at' => gmdate( 'c' ), 'details' => $details, ) ); if ( function_exists( 'do_action' ) ) { do_action( 'antivirus_ai_activity_recorded', $event ); } } public function prune(): void { DataRemoval::assertWritable(); $name = apply_filters( 'antivirus_ai_retention_lock', 'history-retention' ); if ( ! is_string( $name ) || ! preg_match( '/^[a-z0-9_-]{1,64}$/D', $name ) ) { throw new \RuntimeException( 'The retention policy lock is invalid. No history was removed.' ); } $owner = $this->lock( $name, 120 ); if ( ! $owner ) { throw new \RuntimeException( 'History retention settings are being changed. Cleanup will need to retry.' ); } try { $site = get_current_blog_id(); $days = apply_filters( 'antivirus_ai_retention_days', 30 ); if ( ! is_int( $days ) || $days < 7 || $days > 365 ) { throw new \RuntimeException( 'The retention policy is invalid. No history was removed.' ); } $cutoff = time() - $days * DAY_IN_SECONDS; $guard = function () use ( $name, $owner, $site, $days ): void { DataRemoval::assertWritable(); if ( $site !== get_current_blog_id() || ! $this->ownsLock( $name, $owner ) || $days !== apply_filters( 'antivirus_ai_retention_days', 30 ) ) { throw new \RuntimeException( 'The retention policy or cleanup lease changed. Remaining history was retained.' ); } }; // Recovery, findings, exceptions and credentials are never removed here. foreach ( array( 'activity', 'notification', 'scan', 'job', 'reference', 'analysis', 'file_cache' ) as $kind ) { $guard(); $rows = $this->checkedRead( $this->db->get_results( $this->db->prepare( "SELECT id,kind,status,revision,updated FROM {$this->objects} WHERE kind=%s AND updated<%d AND status NOT IN ('queued','running','pending') LIMIT 100", $kind, $cutoff ), ARRAY_A ) ) ?: array(); foreach ( $rows as $row ) { $guard(); // A worker may resume or refresh a record after selection. Delete only that exact old state. $deleted = $this->db->delete( $this->objects, array( 'id' => $row['id'], 'kind' => $kind, 'status' => $row['status'], 'revision' => (int) $row['revision'], 'updated' => (int) $row['updated'] ) ); if ( false === $deleted ) { throw new \RuntimeException( 'Expired history could not be removed. Remaining records were retained.' ); } if ( 1 === $deleted && 'scan' === $kind ) { $this->clearWork( $row['id'] ); } } } $guard(); if ( false === $this->db->query( $this->db->prepare( "DELETE FROM {$this->locks} WHERE expires<%d", time() - 600 ) ) ) { throw new \RuntimeException( 'Expired cleanup leases could not be removed.' ); } } finally { $this->unlock( $name, $owner ); } } }