0 ? ($state['items'][$state['offset'] - 1]['id'] ?? '') : ''; $state['locale'] = str_starts_with(determine_locale(), 'fa') ? 'fa' : 'en'; unset($state['items']); Store::set('sync', $state); } return $state; } private static function request(string $method, string $path, array $state, array $data = []): array { $timeout = max(1, min(12, self::$deadline - microtime(true))); return Client::request($method, $path, $data, locale: $state['locale'] ?? null, timeout: $timeout); } public static function start(bool $interactive = false): bool { $owner = Store::lock(); if ($owner === false) { return false; } try { if ((int) Store::get('schema', 0) < 2) Store::install(); if (ConnectionChanges::pending()) throw new \RuntimeException('connection_busy'); Client::path(); if (! Client::connected()) throw new \RuntimeException(Client::connectionError() ?: 'connection_required'); $current = self::state(); if ($current) { if ($interactive && empty($current['interactive'])) { $current['interactive'] = true; Store::set('sync', $current); } self::schedule(); return false; } $items = Inventory::collect(true); $generation = wp_generate_uuid4(); Store::stageInventory($generation, $items); Store::set('sync', ['operation' => wp_generate_uuid4(), 'generation' => $generation, 'stage' => 'begin', 'count' => count($items), 'locale' => str_starts_with(determine_locale(), 'fa') ? 'fa' : 'en', 'fingerprint' => Inventory::fingerprint($items), 'offset' => 0, 'upload_after' => '', 'cursor' => 0, 'processed' => 0, 'started' => microtime(true), 'attempts' => 0, 'interactive' => $interactive]); Store::remove('error'); self::schedule(); return true; } finally { Store::unlock($owner); } } public static function schedule(int $delay = 2): void { $retry = (int) Store::get('retry_after', 0); $due = max(time() + max(1, $delay), $retry); $next = wp_next_scheduled('flx_security_continue'); if ($next && $next >= $retry && $next <= $due) return; if ($next) wp_clear_scheduled_hook('flx_security_continue'); wp_schedule_single_event($due, 'flx_security_continue'); } public static function tick(int $seconds = 18, int $steps = 12): void { $owner = Store::lock(); if ($owner === false) { self::schedule(2); return; } $state = []; try { if ((int) Store::get('schema', 0) < 2) Store::install(); $state = self::state(); if (! $state) return; if (($state['expected_findings'] ?? 0) > self::MAX_FINDINGS) throw new \RuntimeException('result_limit'); if (ConnectionChanges::pending()) { Store::remove('sync'); Store::prune(); ConnectionChanges::schedule(); return; } if (! Client::connected()) throw new \RuntimeException(Client::connectionError() ?: 'connection_required'); $retry = (int) Store::get('retry_after', 0); if ($retry > time()) { self::schedule($retry - time()); return; } if ((float) $state['started'] < time() - DAY_IN_SECONDS) { throw new \RuntimeException('sync_expired'); } $budget = max(1, min(18, $seconds)); $maximum = (int) ini_get('max_execution_time'); if ($maximum > 0 && isset($_SERVER['REQUEST_TIME_FLOAT'])) $budget = min($budget, max(1, $maximum - (microtime(true) - (float) $_SERVER['REQUEST_TIME_FLOAT']) - 2)); $deadline = self::$deadline = microtime(true) + $budget; for ($step = 0; $step < max(1, min(12, $steps)) && microtime(true) < $deadline; $step++) { if (! Store::owns($owner)) { return; } self::step($state); if (ConnectionChanges::pending()) { Store::remove('sync'); Store::prune(); ConnectionChanges::schedule(); return; } if (! Store::owns($owner)) { return; } $state['attempts'] = 0; if ($state['stage'] === 'commit') { self::commit($state, $owner); return; } Store::set('sync', $state); Store::remove('error'); } self::schedule(2); } catch (\Throwable $error) { if (! Store::owns($owner)) { return; } $code = I18n::errorCode($error->getMessage()); Store::set('error', $code); if (! $state) return; if ($code === 'quota_exceeded') { Store::set('sync', $state); self::schedule(max(1, (int) Store::get('retry_after', 0) - time())); return; } $state['attempts'] = (int) ($state['attempts'] ?? 0) + 1; $terminal = in_array($code, ['token_expired', 'invalid_token', 'insufficient_scope', 'site_disconnected', 'connection_required', 'inventory_changed', 'catalog_changed', 'sync_expired', 'invalid_response', 'response_too_large', 'invalid_request', 'inventory_incomplete', 'request_rejected', 'result_limit'], true) || $state['attempts'] >= 5; self::log($state, 'failed', $code); if ($terminal) { Store::remove('sync'); if (in_array($code, ['token_expired', 'invalid_token', 'insufficient_scope', 'site_disconnected', 'connection_required'], true)) { wp_clear_scheduled_hook('flx_security_refresh'); } Store::prune(); if (in_array($code, ['catalog_changed', 'inventory_changed', 'sync_expired'], true) && Plugin::settings()['automatic']) { wp_schedule_single_event(time() + 300, 'flx_security_refresh'); } } else { Store::set('sync', $state); $delay = max((int) Store::get('retry_after', 0) - time(), min(3600, 60 * (2 ** $state['attempts']))); Store::set('retry_after', time() + $delay); self::schedule($delay); } } finally { Store::unlock($owner); } } private static function step(array &$state): void { $base = Client::path(); $stage = $state['stage']; if ($stage === 'begin') { $data = self::request('POST', $base.'/syncs', $state, ['operation_id' => $state['operation'], 'count' => $state['count']])['data']; if (! Client::uuid($data['sync_id'] ?? null)) { throw new \RuntimeException('invalid_response'); } $batch = $data['upload_batch'] ?? 50; if (! is_int($batch) || $batch < 1 || $batch > 50) { throw new \RuntimeException('invalid_response'); } $state['upload_batch'] = $batch; $state['remote_id'] = $data['sync_id']; $state['stage'] = 'upload'; Store::prune($state['generation']); return; } $path = $base.'/syncs/'.$state['remote_id']; if ($stage === 'upload') { $items = []; foreach (Store::inventoryPage($state['generation'], $state['upload_after'] ?? '', $state['upload_batch'] ?? 50) as $item) { if ($items && strlen(wp_json_encode(['items' => [...$items, $item]])) > 60000) { break; } $items[] = $item; } if ($items) { $data = self::request('POST', $path.'/items', $state, ['items' => $items])['data']; if (($data['accepted'] ?? null) !== count($items)) { throw new \RuntimeException('invalid_response'); } $state['offset'] += count($items); $state['upload_after'] = $items[array_key_last($items)]['id']; } if (! $items && $state['offset'] < $state['count']) throw new \RuntimeException('invalid_response'); if ($state['offset'] === $state['count']) { $state['stage'] = 'checking'; } return; } if ($stage === 'checking') { $data = self::request('POST', $path.'/advance', $state)['data']; if (! in_array($data['status'] ?? '', ['checking', 'complete'], true) || ! is_int($data['processed'] ?? null) || ($data['total'] ?? null) !== $state['count'] || $data['processed'] < 0 || $data['processed'] > $state['count']) { throw new \RuntimeException('invalid_response'); } $state['processed'] = $data['processed']; if ($data['status'] === 'complete') { if ($data['processed'] !== $state['count'] || ! is_array($data['coverage'] ?? null)) { throw new \RuntimeException('invalid_response'); } $state['coverage'] = $data['coverage']; if (! is_int($data['findings'] ?? null) || $data['findings'] < 0) { throw new \RuntimeException('invalid_response'); } $state['expected_findings'] = $data['findings']; if ($state['expected_findings'] > self::MAX_FINDINGS) throw new \RuntimeException('result_limit'); $state['stage'] = 'results'; } return; } $response = self::request('GET', $path.'/'.$stage.'?cursor='.$state['cursor'], $state); if (! array_is_list($response['data']) || count($response['data']) > 20 || ! array_key_exists('next_cursor', $response) || ($response['next_cursor'] !== null && (! is_int($response['next_cursor']) || $response['next_cursor'] <= $state['cursor']))) { throw new \RuntimeException('invalid_response'); } if (! $response['data'] && $response['next_cursor'] !== null) throw new \RuntimeException('invalid_response'); $pageBytes = strlen(wp_json_encode($response['data'])); if (($state['received_bytes'] ?? 0) + $pageBytes > self::MAX_SCAN_BYTES || ($stage === 'findings' && ($state['received_findings'] ?? 0) + count($response['data']) > min(self::MAX_FINDINGS, $state['expected_findings']))) throw new \RuntimeException('result_limit'); $keys = array_map(static fn ($row) => is_array($row) ? ($row[$stage === 'results' ? 'id' : 'item_id'] ?? null) : null, $response['data']); $manifest = Store::inventoryItems($state['generation'], $keys); foreach ($response['data'] as $row) { if ($stage === 'findings' && strlen(wp_json_encode($row['vulnerability'] ?? null)) > self::MAX_REPORT_BYTES) throw new \RuntimeException('result_limit'); if ($stage === 'results') { if (! is_array($row) || ! is_string($row['id'] ?? null) || ! isset($manifest[$row['id']]) || ($row['version'] ?? null) !== $manifest[$row['id']]['version'] || ! in_array($row['status'] ?? '', ['affected', 'not_affected_in_known_ranges', 'unknown_product', 'unknown_version', 'unsupported', 'conflicting'], true)) { throw new \RuntimeException('invalid_response'); } $item = $manifest[$row['id']]; Store::write('plugins', ['generation' => $state['generation'], 'item_key' => $row['id'], 'name' => $item['display_name'], 'slug' => $item['name'], 'version' => $item['version'], 'kind' => $item['kind'], 'active' => $item['active'] ? 1 : 0, 'status' => $row['status'], 'uncertain' => ! empty($row['uncertain']) ? 1 : 0, 'conflicting' => ! empty($row['conflicting']) ? 1 : 0, 'checked' => 1]); } else { if (! is_array($row) || ! is_string($row['item_id'] ?? null) || ! isset($manifest[$row['item_id']]) || ! self::validAdvisory($row['vulnerability'] ?? null)) { throw new \RuntimeException('invalid_response'); } $issue = $row['vulnerability']; Store::write('advisories', ['generation' => $state['generation'], 'vulnerability_id' => $issue['id'], 'severity' => $issue['severity'], 'document' => wp_json_encode($issue, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)]); Store::write('findings', ['generation' => $state['generation'], 'item_key' => $row['item_id'], 'vulnerability_id' => $issue['id']]); } } $state['received_bytes'] = ($state['received_bytes'] ?? 0) + $pageBytes; if ($stage === 'findings') $state['received_findings'] = ($state['received_findings'] ?? 0) + count($response['data']); if ($response['next_cursor'] !== null) { $state['cursor'] = $response['next_cursor']; } else { $state['cursor'] = 0; $state['stage'] = $stage === 'results' ? 'findings' : 'commit'; } } public static function validAdvisory(mixed $issue): bool { if (! is_array($issue) || ! is_string($issue['description'] ?? null) || strlen(wp_json_encode($issue)) > self::MAX_REPORT_BYTES || strlen($issue['description']) > 262144) return false; $valid = is_array($issue) && Client::uuid($issue['id'] ?? null) && is_string($issue['title'] ?? null) && strlen($issue['title']) <= 20000 && is_string($issue['description'] ?? null) && is_array($issue['products'] ?? null) && is_array($issue['identifiers'] ?? null) && is_array($issue['references'] ?? null) && is_array($issue['provenance'] ?? null) && in_array($issue['severity'] ?? '', ['critical', 'high', 'medium', 'low', 'unknown'], true); if (! $valid || ! array_is_list($issue['products']) || ! array_is_list($issue['identifiers']) || ! array_is_list($issue['references']) || ! array_is_list($issue['provenance'])) { return false; } if (count($issue['products']) > 1000 || count($issue['identifiers']) > 200 || count($issue['references']) > 200 || count($issue['provenance']) > 20) return false; foreach (array_merge($issue['identifiers'], $issue['references']) as $text) { if (! is_string($text) || strlen($text) > 8192) return false; } foreach ($issue['products'] as $claim) { if (! is_array($claim) || ! is_string($claim['name'] ?? null) || strlen($claim['name']) > 1000 || ! is_array($claim['fixed'] ?? null) || count($claim['fixed']) > 500 || (isset($claim['range']) && (! is_string($claim['range']) || strlen($claim['range']) > 65536))) return false; foreach ($claim['fixed'] as $fixed) { if (! is_string($fixed) || strlen($fixed) > 1000) return false; } } foreach ($issue['provenance'] as $origin) { if (! is_array($origin) || ! is_string($origin['attribution'] ?? null) || ! is_string($origin['license'] ?? null)) return false; foreach (['url', 'license_url'] as $key) { if (isset($origin[$key]) && ! is_string($origin[$key])) return false; } if (isset($origin['notices']) && (! is_array($origin['notices']) || count($origin['notices']) > 20)) return false; foreach ($origin['notices'] ?? [] as $notice) { if (! is_array($notice) || ! is_string($notice['notice'] ?? null) || ! is_string($notice['license'] ?? null) || ! is_string($notice['license_url'] ?? null)) return false; } } return ! isset($issue['url']) || is_string($issue['url']); } private static function commit(array $state, string $owner): void { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Validate uncommitted plugin-owned staging rows and lock ownership directly; a cached answer could publish an incomplete scan. global $wpdb; if (ConnectionChanges::pending() || ! Client::connected()) throw new \RuntimeException('connection_required'); if (! hash_equals($state['fingerprint'], Inventory::fingerprint(Inventory::collect()))) { throw new \RuntimeException('inventory_changed'); } $generation = $state['generation']; if ((int) $wpdb->get_var($wpdb->prepare('SELECT COUNT(*) FROM %i WHERE generation=%s', Store::table('findings'), $generation)) !== $state['expected_findings']) { throw new \RuntimeException('invalid_response'); } $plugins = Store::table('plugins'); $stored = $wpdb->get_row($wpdb->prepare('SELECT COUNT(*) AS reports, COALESCE(SUM(OCTET_LENGTH(document)),0) AS bytes, COALESCE(MAX(OCTET_LENGTH(document)),0) AS largest FROM %i WHERE generation=%s', Store::table('advisories'), $generation)); if (! $stored || $wpdb->last_error !== '') throw new \RuntimeException('storage_unavailable'); if ((int) $stored->reports > self::MAX_FINDINGS || (int) $stored->bytes > self::MAX_SCAN_BYTES || (int) $stored->largest > self::MAX_REPORT_BYTES) throw new \RuntimeException('result_limit'); $completed = $wpdb->get_row($wpdb->prepare('SELECT COUNT(*) AS total, COALESCE(SUM(status<>%s),0) AS identified FROM %i WHERE generation=%s AND checked=1', 'unknown_product', $plugins, $generation)); if (! $completed || $wpdb->last_error !== '' || (int) $completed->total !== $state['count']) { throw new \RuntimeException('invalid_response'); } $missing = $wpdb->get_var($wpdb->prepare('SELECT COUNT(*) FROM %i p WHERE p.generation=%s AND p.status=%s AND NOT EXISTS (SELECT 1 FROM %i f WHERE f.generation=p.generation AND f.item_key=p.item_key)', $plugins, $generation, 'affected', Store::table('findings'))); if ((int) $missing !== 0 || ! Store::owns($owner)) { throw new \RuntimeException('invalid_response'); } $invalid = $wpdb->get_var($wpdb->prepare('SELECT COUNT(*) FROM %i f LEFT JOIN %i p ON p.generation=f.generation AND p.item_key=f.item_key LEFT JOIN %i a ON a.generation=f.generation AND a.vulnerability_id=f.vulnerability_id WHERE f.generation=%s AND (p.item_key IS NULL OR p.status<>%s OR a.vulnerability_id IS NULL)', Store::table('findings'), $plugins, Store::table('advisories'), $generation, 'affected')); if ($invalid === null || (int) $invalid !== 0 || $wpdb->last_error !== '') { throw new \RuntimeException('invalid_response'); } Store::set('snapshot', ['generation' => $generation, 'checked_at' => time(), 'count' => $state['count'], 'identified' => (int) $completed->identified, 'fingerprint' => $state['fingerprint'], 'coverage' => $state['coverage']]); Store::remove('sync'); Store::remove('error'); Store::remove('inventory_dirty'); self::log($state, 'success'); Store::prune(); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching } private static function log(array $state, string $status, string $code = ''): void { Store::write('logs', ['started_at' => gmdate('Y-m-d H:i:s', (int) $state['started']), 'finished_at' => gmdate('Y-m-d H:i:s'), 'status' => $status, 'error_code' => $code, 'plugins' => $state['count'], 'duration_ms' => max(0, min(4294967295, (int) ((microtime(true) - $state['started']) * 1000)))]); } }