get_var($wpdb->prepare('SELECT option_value FROM %i WHERE option_name=%s', self::lockTable(), 'flx_security_connection_change')); if ($wpdb->last_error !== '') throw new \RuntimeException('storage_unavailable'); return $raw === null ? $default : maybe_unserialize($raw); } if (in_array($name, ['authorization', 'feedback'], true)) { $value = is_multisite() ? get_site_transient(self::personalKey($name)) : get_transient(self::personalKey($name)); return $value === false ? $default : $value; } return is_multisite() ? get_site_option('flx_security_'.$name, $default) : get_option('flx_security_'.$name, $default); } public static function set(string $name, mixed $value): void { if (in_array($name, ['authorization', 'feedback'], true)) { self::setPersonal($name, $value); return; } if (is_multisite()) { $saved = update_site_option('flx_security_'.$name, $value); } else { $saved = update_option('flx_security_'.$name, $value, false); } if (! $saved) { $missing = new \stdClass; $stored = self::get($name, $missing); // WordPress reloads scalar options as strings; an unchanged boolean is not a failed write. if ($stored === $missing || (string) maybe_serialize($stored) !== (string) maybe_serialize($value)) { throw new \RuntimeException('storage_unavailable'); } } } public static function remove(string $name): void { if ($name === 'connection_change') { global $wpdb; if ($wpdb->query($wpdb->prepare('DELETE FROM %i WHERE option_name=%s', self::lockTable(), 'flx_security_connection_change')) === false) throw new \RuntimeException('storage_unavailable'); return; } if (in_array($name, ['authorization', 'feedback'], true)) { if (is_multisite()) delete_site_transient(self::personalKey($name)); else delete_transient(self::personalKey($name)); return; } $deleted = is_multisite() ? delete_site_option('flx_security_'.$name) : delete_option('flx_security_'.$name); if (! $deleted && self::get($name, null) !== null) { throw new \RuntimeException('storage_unavailable'); } } public static function identity(): string { return hash('sha256', self::table('plugins').'|'.get_current_user_id().'|'.wp_get_session_token()); } private static function personalKey(string $name, ?string $identity = null): string { return self::personalPrefix($name).($identity ?? self::identity()); } public static function personalPrefix(string $name): string { if (! in_array($name, ['authorization', 'feedback'], true)) throw new \InvalidArgumentException('invalid_request'); return 'flx_security_'.$name.'_'.substr(hash('sha256', self::table('plugins')), 0, 12).'_'; } public static function setPersonal(string $name, array $value, ?string $identity = null): void { if (! in_array($name, ['authorization', 'feedback'], true) || ($identity !== null && ! preg_match('/^[a-f0-9]{64}$/D', $identity))) throw new \InvalidArgumentException('invalid_request'); $key = self::personalKey($name, $identity); $ttl = $name === 'authorization' ? 900 : 300; $saved = is_multisite() ? set_site_transient($key, $value, $ttl) : set_transient($key, $value, $ttl); if (! $saved && (is_multisite() ? get_site_transient($key) : get_transient($key)) !== $value) throw new \RuntimeException('storage_unavailable'); } public static function add(string $name, array $value): bool { if ($name !== 'connection_change') throw new \InvalidArgumentException('invalid_request'); global $wpdb; $saved = $wpdb->query($wpdb->prepare("INSERT IGNORE INTO %i (option_name,option_value,autoload) VALUES (%s,%s,'off')", self::lockTable(), 'flx_security_connection_change', maybe_serialize($value))); if ($saved === false) throw new \RuntimeException('storage_unavailable'); return $saved === 1; } public static function table(string $name): string { global $wpdb; if (! in_array($name, ['plugins', 'advisories', 'findings', 'logs', 'identities'], true)) { throw new \InvalidArgumentException('storage_unavailable'); } return (is_multisite() ? $wpdb->get_blog_prefix(get_main_site_id()) : $wpdb->prefix).'flx_security_'.$name; } public static function install(): void { global $wpdb; require_once ABSPATH.'wp-admin/includes/upgrade.php'; $charset = $wpdb->get_charset_collate(); $schemas = [ 'plugins' => 'generation varchar(36) NOT NULL, item_key char(64) NOT NULL, name varchar(200) NOT NULL, slug varchar(200) DEFAULT NULL, version varchar(80) NOT NULL, kind varchar(12) NOT NULL, active tinyint(1) NOT NULL DEFAULT 0, status varchar(40) NOT NULL, uncertain tinyint(1) NOT NULL DEFAULT 0, conflicting tinyint(1) NOT NULL DEFAULT 0, PRIMARY KEY (generation,item_key)', 'advisories' => 'generation varchar(36) NOT NULL, vulnerability_id varchar(36) NOT NULL, severity varchar(12) NOT NULL, document longtext NOT NULL, PRIMARY KEY (generation,vulnerability_id)', 'findings' => 'generation varchar(36) NOT NULL, item_key char(64) NOT NULL, vulnerability_id varchar(36) NOT NULL, PRIMARY KEY (generation,item_key,vulnerability_id)', 'logs' => 'id bigint unsigned NOT NULL AUTO_INCREMENT, started_at datetime NOT NULL, finished_at datetime NOT NULL, status varchar(16) NOT NULL, error_code varchar(60) NOT NULL DEFAULT \'\', plugins int unsigned NOT NULL DEFAULT 0, duration_ms int unsigned NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY finished_at (finished_at)', ]; $schemas['plugins'] = str_replace('PRIMARY KEY', 'checked tinyint(1) NOT NULL DEFAULT 1, PRIMARY KEY', $schemas['plugins']); $schemas['identities'] = 'item_key char(64) NOT NULL, metadata_hash char(64) NOT NULL, slug varchar(200) NOT NULL, checked_at datetime NOT NULL, PRIMARY KEY (item_key)'; foreach ($schemas as $name => $columns) { dbDelta('CREATE TABLE '.self::table($name)." (\n".str_replace(', ', ",\n", $columns)."\n) ENGINE=InnoDB $charset;"); } foreach (array_keys($schemas) as $name) { if (strcasecmp((string) $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like(self::table($name)))), self::table($name)) !== 0) { throw new \RuntimeException('storage_unavailable'); } } self::set('schema', 2); } /** Immutable scan input lives in indexed rows; checkpoints never contain the inventory. */ public static function stageInventory(string $generation, array $items): void { global $wpdb; foreach (array_chunk($items, 50) as $batch) { $values = []; $args = [self::table('plugins')]; foreach ($batch as $item) { $values[] = "(%s,%s,%s,NULLIF(%s,''),%s,%s,%d,'pending',0)"; array_push($args, $generation, $item['id'], $item['display_name'], $item['name'] ?? '', $item['version'], $item['kind'], (int) $item['active']); } // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Only fixed placeholder tuples are joined; all values and the identifier are prepared here. if ($wpdb->query($wpdb->prepare('INSERT IGNORE INTO %i (generation,item_key,name,slug,version,kind,active,status,checked) VALUES '.implode(',', $values), ...$args)) === false) { throw new \RuntimeException('storage_unavailable'); } } self::invalidateResults(); } public static function inventoryPage(string $generation, string $after, int $limit): array { global $wpdb; $rows = $wpdb->get_results($wpdb->prepare('SELECT item_key,name,slug,version,kind,active FROM %i WHERE generation=%s AND item_key>%s ORDER BY item_key LIMIT %d', self::table('plugins'), $generation, $after, max(1, min(50, $limit)))); if ($wpdb->last_error !== '') { throw new \RuntimeException('storage_unavailable'); } return array_map(static fn ($row) => ['id' => $row->item_key, 'name' => $row->slug, 'display_name' => $row->name, 'version' => $row->version, 'kind' => $row->kind, 'active' => (bool) $row->active], $rows); } public static function inventoryItems(string $generation, array $ids): array { global $wpdb; $items = []; foreach ($ids as $id) { if (! is_string($id) || ! preg_match('/^[a-f0-9]{64}$/D', $id)) { throw new \RuntimeException('invalid_response'); } } if (! $ids) return []; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The IN clause consists solely of fixed %s placeholders, with every value supplied separately. $rows = $wpdb->get_results($wpdb->prepare('SELECT item_key,name,slug,version,kind,active FROM %i WHERE generation=%s AND item_key IN ('.implode(',', array_fill(0, count($ids), '%s')).')', self::table('plugins'), $generation, ...$ids)); if ($wpdb->last_error !== '') throw new \RuntimeException('storage_unavailable'); foreach ($rows as $row) $items[$row->item_key] = ['id' => $row->item_key, 'name' => $row->slug, 'display_name' => $row->name, 'version' => $row->version, 'kind' => $row->kind, 'active' => (bool) $row->active]; return $items; } public static function verifiedIdentities(): array { global $wpdb; $result = []; $rows = $wpdb->get_results($wpdb->prepare('SELECT item_key,metadata_hash,slug FROM %i WHERE checked_at>=%s', self::table('identities'), gmdate('Y-m-d H:i:s', time() - 180 * DAY_IN_SECONDS))); if (! is_array($rows) || $wpdb->last_error !== '') throw new \RuntimeException('storage_unavailable'); foreach ($rows as $row) $result[$row->item_key] = ['hash' => $row->metadata_hash, 'slug' => $row->slug]; return $result; } public static function encrypt(#[\SensitiveParameter] string $plain): string { if (! function_exists('openssl_encrypt')) { throw new \RuntimeException('crypto_unavailable'); } $nonce = random_bytes(12); $tag = ''; $cipher = openssl_encrypt($plain, 'aes-256-gcm', self::key(), OPENSSL_RAW_DATA, $nonce, $tag, 'fluxcdn-security:v1'); if ($cipher === false) { throw new \RuntimeException('crypto_unavailable'); } return base64_encode($nonce.$tag.$cipher); } public static function decrypt(#[\SensitiveParameter] string $encrypted): string { $packed = base64_decode($encrypted, true); if (! function_exists('openssl_decrypt') || $packed === false || strlen($packed) < 29) { throw new \RuntimeException('connection_required'); } $plain = openssl_decrypt(substr($packed, 28), 'aes-256-gcm', self::key(), OPENSSL_RAW_DATA, substr($packed, 0, 12), substr($packed, 12, 16), 'fluxcdn-security:v1'); if ($plain === false) { throw new \RuntimeException('connection_required'); } return $plain; } private static function key(): string { return hash_hmac('sha256', 'fluxcdn-security:credentials:v1', wp_salt('auth').wp_salt('secure_auth'), true); } private static function lockTable(): string { global $wpdb; return is_multisite() ? $wpdb->get_blog_prefix(get_main_site_id()).'options' : $wpdb->options; } public static function lock(): string|false { global $wpdb; $table = self::lockTable(); $name = 'flx_security_job_lock'; $old = $wpdb->get_var($wpdb->prepare('SELECT option_value FROM %i WHERE option_name=%s', $table, $name)); if (is_string($old) && (int) explode(':', $old, 2)[0] < time()) { $wpdb->query($wpdb->prepare('DELETE FROM %i WHERE option_name=%s AND option_value=%s', $table, $name, $old)); } $owner = (time() + 120).':'.bin2hex(random_bytes(16)); $inserted = $wpdb->query($wpdb->prepare("INSERT IGNORE INTO %i (option_name,option_value,autoload) VALUES (%s,%s,'off')", $table, $name, $owner)); return $inserted === 1 ? $owner : false; } public static function owns(string $owner): bool { global $wpdb; return (int) explode(':', $owner, 2)[0] > time() && $wpdb->get_var($wpdb->prepare('SELECT option_value FROM %i WHERE option_name=%s', self::lockTable(), 'flx_security_job_lock')) === $owner; } public static function unlock(string $owner): void { global $wpdb; $wpdb->query($wpdb->prepare('DELETE FROM %i WHERE option_name=%s AND option_value=%s', self::lockTable(), 'flx_security_job_lock', $owner)); } public static function write(string $table, array $row): void { global $wpdb; if ($wpdb->replace(self::table($table), $row) === false) { throw new \RuntimeException('storage_unavailable'); } self::invalidateResults(); } public static function cacheVersion(): string { $key = self::table('plugins').':read-version'; $version = wp_cache_get($key, 'flx_security_results'); if (! is_string($version)) { wp_cache_add($key, bin2hex(random_bytes(16)), 'flx_security_results'); $version = wp_cache_get($key, 'flx_security_results'); } return is_string($version) ? $version : ''; } public static function invalidateResults(): void { wp_cache_set(self::table('plugins').':read-version', bin2hex(random_bytes(16)), 'flx_security_results'); } public static function prune(?string $staging = null): void { global $wpdb; self::invalidateResults(); $active = self::get('snapshot')['generation'] ?? ''; foreach (['findings', 'advisories', 'plugins'] as $table) { $wpdb->query($wpdb->prepare('DELETE FROM %i WHERE generation NOT IN (%s,%s)', self::table($table), $active, $staging ?? '')); } $logs = self::table('logs'); $wpdb->query($wpdb->prepare('DELETE FROM %i WHERE finished_at < %s', $logs, gmdate('Y-m-d H:i:s', time() - 30 * DAY_IN_SECONDS))); $cutoff = $wpdb->get_var($wpdb->prepare('SELECT id FROM %i ORDER BY id DESC LIMIT 1 OFFSET 999', $logs)); if ($cutoff) { $wpdb->query($wpdb->prepare('DELETE FROM %i WHERE id < %d', $logs, $cutoff)); } } }