store = $store; $this->reference = $reference ?? new WordPressReference(); $this->repository = new RepositoryIntegrity( $store ); } public function latest(): ?array { return $this->store->list( 'scan', null, 1 )[0] ?? null; } public function start( string $mode = 'full' ): string { if ( ! in_array( $mode, array( 'full', 'changed' ), true ) ) { throw new \InvalidArgumentException( 'Unsupported scan mode.' ); } return $this->create( null, $mode ); } /** Actual broad scan completions are separate from targeted checks and newer incomplete work. */ public function summary(): array { $summary = $this->store->get( 'scan_summary' ) ?? array(); $success = $summary['last_successful'] ?? null; // One indexed/status-filtered row exposes beta.1 evidence without rewriting it. if ( ! $success ) { $candidate = $this->store->latestBroadCompletedScan(); $success = $candidate ? self::summarize( $candidate ) : null; } $latest = $this->latest(); return array( 'latest' => $latest ? self::summarize( $latest ) : null, 'last_successful' => $success, 'last_full_sweep' => $summary['last_full_sweep'] ?? null, ); } private static function summarize( array $scan ): array { return array( 'id' => $scan['id'] ?? $scan['_id'], 'status' => $scan['_status'] ?? $scan['status'], 'mode' => $scan['mode'] ?? ( empty( $scan['target_finding_id'] ) ? 'full' : 'targeted' ), 'completed_at' => $scan['completed_at'] ?? null, 'result_state' => $scan['result_state'] ?? 'not_finished', 'counters' => $scan['counters'] ?? array(), 'coverage' => $scan['coverage'] ?? array(), ); } private function create( ?array $target, string $mode = 'full' ): string { $token = $this->store->lock( 'scan-start', 60 ); if ( ! $token ) { throw new \RuntimeException( 'A scan is being started. Retry shortly.' ); } try { foreach ( array( 'queued', 'running' ) as $state ) { $active = $this->store->list( 'scan', $state, 1 ); if ( $active ) { if ( $target && ( $active[0]['target_finding_id'] ?? null ) !== $target['_id'] ) { throw new \RuntimeException( 'A scan is active; finish or cancel it before targeted verification.' ); } return $active[0]['_id']; } } if ( 'changed' === $mode ) { $summary = $this->store->get( 'scan_summary' ); $at = strtotime( (string) ( $summary['last_full_sweep']['completed_at'] ?? '' ) ); if ( ! $at || $at > time() || time() - $at >= self::FULL_INTERVAL ) { $mode = 'full'; } } $inventory = Inventory::collect(); $id = Store::id( 's_' ); $roots = array(); $skips = array(); foreach ( $inventory['paths'] as $key => $path ) { if ( ! is_dir( $path ) ) { continue; } try { PathGuard::noLinks( $path ); if ( realpath( $path ) !== $path ) { throw new \RuntimeException( 'Noncanonical configured directory.' ); } foreach ( $roots as $root ) { if ( PathGuard::beneath( $path, $root['path'] ) ) { continue 2; } } $roots[ $key ] = array( 'path' => $path, 'label' => $key, ); } catch ( \Throwable $e ) { $skips[] = array( 'location' => $path, 'reason' => $e->getMessage(), ); } } $scan = array( 'id' => $id, 'mode' => $target ? 'targeted' : $mode, 'status' => 'queued', 'started_at' => gmdate( 'c' ), 'updated_at' => gmdate( 'c' ), 'completed_at' => null, 'heartbeat' => time(), 'phase' => 'references', 'roots' => $roots, 'inventory' => $inventory, 'first_state_trust' => 'untrusted', 'counters' => array( 'enumerated' => 0, 'checked' => 0, 'hashed' => 0, 'analyzed' => 0, 'reused' => 0, 'skipped' => count( $skips ) + count( $inventory['limitations'] ), 'excluded' => 0, 'unreadable' => 0, 'findings' => 0, 'exceptions' => 0, 'database_checked' => 0, ), 'coverage' => array( 'declared_scope' => 'Configured WordPress root, content, plugins, mu-plugins, uploads and registered theme directories; selected database data.', 'processing' => 'changed' === $mode && ! $target ? 'Every eligible file is safely read and SHA-256 hashed. Only unchanged clean structural assessments may be reused for up to 24 hours; integrity, vulnerability and database checks run again.' : 'Full structural assessment of each eligible file; declared exclusions and unavailable sources remain visible.', 'full_interval_seconds' => self::FULL_INTERVAL, 'file_max_bytes' => Detector::MAX_BYTES, 'integrity_stream_max_bytes' => FileSnapshot::MAX_BYTES, 'file_assessment' => 'Structural checks and the optional local engine assess files up to 2 MiB. Larger files up to 16 MiB receive bounded whole-file hashing and eligible integrity comparisons only; structural coverage remains incomplete.', 'path_limit' => 100000, 'finding_limit' => 5000, 'retained_finding_limit' => 20000, 'excluded_directories' => array( '.git', '.svn', 'node_modules', 'cache', 'upgrade', 'updraft', 'backups', 'ai1wm-backups' ), 'exclusion_scope' => 'VCS/node_modules directories anywhere; generated cache/backup directories only directly below the configured content directory. Configured component roots take precedence.', 'extensions' => array( 'php', 'phtml', 'php3', 'php4', 'php5', 'php7', 'php8', 'phar', 'inc', 'js', 'mjs', 'cjs', 'html', 'htm', 'svg', 'txt', 'css', 'json', 'htaccess' ), 'core_integrity' => 'unverified', 'component_integrity' => 'unverified: component references have not been assessed', 'detectors' => Detector::registry()['coverage'], ), 'limitations' => array_values( array_unique( array_merge( $inventory['limitations'], array( 'This is a limited beta with no maintained known-malware signature corpus or representative detection evaluation.', 'Repository plugins and themes require owner-confirmed exact identities and available official references. Must-use, drop-in, premium and custom components remain unverified.', 'Directory changes during enumeration can alter discovered coverage. Repeat a full scan after deployments.', 'Executable detection cannot cover unknown handlers or all indirect code behavior. The first observed state is untrusted.', ) ) ) ), 'skipped_items' => array_slice( $skips, 0, 100 ), 'database_cursor' => array( 'phase' => 'options', 'after' => 0, ), 'observed_findings' => array(), 'rule_registry' => array( 'version' => Detector::registry()['version'], 'published_at' => Detector::registry()['published_at'], ), ); if ( ! $roots ) { $scan['limitations'][] = 'No safe configured scan roots were available.'; ++$scan['counters']['skipped']; } if ( $target ) { $key = (string) ( $target['root_id'] ?? '' ); $relative = (string) ( $target['relative_path'] ?? '' ); if ( ! isset( $roots[ $key ] ) || $roots[ $key ]['path'] !== ( $target['root'] ?? null ) ) { throw new \RuntimeException( 'The original finding no longer maps to a configured scan root.' ); } PathGuard::resolve( $roots[ $key ]['path'], $relative ); $scan['target_finding_id'] = $target['_id']; $scan['target_location'] = $target['location']; $scan['target_detector_id'] = $target['detector_id']; $scan['coverage']['declared_scope'] = 'Targeted verification of ' . $target['location'] . '. Other site files and database rows were not checked.'; $scan['counters']['enumerated'] = 1; $scan['counters']['skipped'] = 0; } ScanAssessment::start( $scan ); ScanProvenance::start( $scan ); $this->save( $scan ); if ( 'incomplete' === $scan['status'] ) { $this->store->activity( 'scan_incomplete', array( 'scan_id' => $id, 'reason' => 'Scan summary storage budget reached during inventory.', ) ); return $id; } if ( $target ) { $this->store->enqueue( $id, $target['root_id'], $target['relative_path'], 'file' ); } else { foreach ( $roots as $key => $root ) { $this->store->enqueue( $id, $key, '', 'directory' ); } } $this->store->activity( 'scan_started', array( 'scan_id' => $id, 'roots' => array_keys( $roots ), 'online_references' => (bool) ( get_option( 'antivirus_ai_settings', array() )['online_references'] ?? false ), ) ); $previous = $this->store->list( 'scan', 'completed', 1 )[0] ?? null; if ( $previous && ! $target ) { $this->administratorChanges( $scan, $previous ); if ( ! class_exists( Persistence::class ) ) { require_once __DIR__ . '/Persistence.php'; } $persistence = Persistence::compare( $scan['inventory'], $previous['inventory'] ?? array() ); foreach ( $persistence['findings'] as $finding ) { $this->record( $scan, $finding ); } $scan['limitations'] = array_slice( array_values( array_unique( array_merge( $scan['limitations'], $persistence['limitations'] ) ) ), 0, 150 ); $this->save( $scan ); } return $id; } finally { $this->store->unlock( 'scan-start', $token ); } } public function cancel( string $scanId ): void { $token = $this->store->lock( 'scan-' . $scanId, 30 ); if ( ! $token ) { throw new \RuntimeException( 'The current batch is finishing; retry cancellation shortly.' ); } try { $scan = $this->scan( $scanId ); if ( ! in_array( $scan['_status'], array( 'queued', 'running' ), true ) ) { return; } $scan['status'] = 'cancelled'; $scan['completed_at'] = gmdate( 'c' ); $scan['result_state'] = self::incompleteResult( $scan ); $scan['limitations'][] = 'The owner cancelled this scan. Previously recorded evidence remains available.'; $this->save( $scan ); $this->store->activity( 'scan_cancelled', array( 'scan_id' => $scanId ) ); } finally { $this->store->unlock( 'scan-' . $scanId, $token ); } } public function rescanFinding( string $findingId ): string { $finding = $this->store->get( $findingId ); if ( ! $finding || 'finding' !== $finding['_kind'] ) { throw new \RuntimeException( 'Finding not found.' ); } if ( 'file' !== ( $finding['scope']['type'] ?? '' ) ) { return $this->start(); } if ( in_array( $finding['evidence'][0]['type'] ?? '', array( 'missing_reference_file', 'missing_repository_file' ), true ) ) { return $this->start(); } return $this->create( $finding ); } public function tick( string $scanId, int $budget = 25 ): array { $budget = max( 1, min( 25, $budget ) ); $deadline = microtime( true ) + 8; $this->repository = new RepositoryIntegrity( $this->store ); // Cache lives for one bounded batch only. $token = $this->store->lock( 'scan-' . $scanId, 45 ); if ( ! $token ) { return $this->scan( $scanId ); } try { $scan = $this->scan( $scanId ); if ( ! in_array( $scan['_status'], array( 'queued', 'running' ), true ) ) { return $scan; } WorkCheckpoint::resume( $this->store, $scan ); ScanProvenance::checkpoint( $scan ); $scan['status'] = 'running'; $scan['heartbeat'] = time(); global $wp_version; if ( (string) ( $wp_version ?? 'unknown' ) !== (string) ( $scan['inventory']['core']['version'] ?? 'unknown' ) ) { $this->invalidateReference( $scan, 'The WordPress runtime version changed after this scan started. Start a new scan to avoid comparing files across releases.' ); return $this->scan( $scanId ); } if ( 'references' === $scan['phase'] ) { $this->references( $scan ); $scan['phase'] = isset( $scan['target_finding_id'] ) && 'integrity.repository' !== ( $scan['target_detector_id'] ?? '' ) ? 'files' : 'repository'; $this->save( $scan ); // A network reference request has its own bounded timeout; never combine it with file work. return $this->scan( $scanId ); } if ( 'repository' === $scan['phase'] ) { $referenceInventory = $scan['inventory']; if ( isset( $scan['target_finding_id'] ) ) { $target = $this->store->get( $scan['target_finding_id'] ); $absolute = ( $target['root'] ?? '' ) . '/' . ( $target['relative_path'] ?? '' ); $referenceInventory['plugins'] = array_values( array_filter( $referenceInventory['plugins'], static fn( $component )=>PathGuard::beneath( $absolute, dirname( $component['path'] ) ) ) ); $referenceInventory['themes'] = array_values( array_filter( $referenceInventory['themes'] ?? array(), static fn( $component ) => PathGuard::beneath( $absolute, $component['path'] ) ) ); $referenceInventory['targeted'] = true; } $scan['repository_state'] = $this->repository->prepare( $referenceInventory, $scan['repository_state'] ?? array() ); $scan['coverage']['component_integrity'] = $scan['repository_state']['status']; $scan['limitations'] = array_slice( array_values( array_unique( array_merge( $scan['limitations'], $scan['repository_state']['limitations'] ) ) ), 0, 150 ); if ( $scan['repository_state']['done'] ) { $scan['phase'] = isset( $scan['target_finding_id'] ) ? 'files' : 'vulnerabilities'; } $this->save( $scan ); return $this->scan( $scanId ); } if ( 'vulnerabilities' === $scan['phase'] ) { $assessment = ( new Vulnerabilities( $this->store ) )->inspect( $scan['inventory'] ); $scan['coverage']['vulnerabilities'] = $assessment['status']; $scan['vulnerability_freshness'] = $assessment['freshness']; $scan['limitations'] = array_slice( array_values( array_unique( array_merge( $scan['limitations'], $assessment['limitations'] ) ) ), 0, 150 ); foreach ( $assessment['findings'] as $finding ) { $this->record( $scan, $finding ); } if ( in_array( $assessment['status'], array( 'unavailable', 'stale', 'partial' ), true ) ) { ++$scan['counters']['skipped']; } $scan['vulnerability_assessed'] = $assessment['assessed_components'] ?? array(); $scan['phase'] = $scan['vulnerability_assessed'] ? 'resolve_vulnerabilities' : 'files'; $this->save( $scan ); return $this->scan( $scanId ); } if ( 'resolve_vulnerabilities' === $scan['phase'] ) { $this->resolveVulnerabilitiesPage( $scan, $budget ); $this->save( $scan ); return $this->scan( $scanId ); } $this->checksums = array(); if ( ! empty( $scan['reference_record'] ) ) { $record = $this->store->get( $scan['reference_record'] ); $expires = is_string( $record['metadata']['expires_at'] ?? null ) ? strtotime( $record['metadata']['expires_at'] ) : false; if ( ! $record || 'reference' !== $record['_kind'] || empty( $record['checksums'] ) || ! is_array( $record['checksums'] ) ) { $this->invalidateReference( $scan, 'The saved trusted reference is missing or invalid. Remaining integrity checks were not completed; start a new scan.' ); return $this->scan( $scanId ); } if ( false === $expires || $expires <= time() ) { $this->invalidateReference( $scan, 'The saved trusted reference expired while this scan was paused. Remaining integrity checks were not completed; start a new scan to refresh references.' ); return $this->scan( $scanId ); } if ( ( $record['metadata']['version'] ?? '' ) !== $scan['inventory']['core']['version'] || ( $record['metadata']['locale'] ?? '' ) !== $scan['inventory']['core']['locale'] ) { $this->invalidateReference( $scan, 'The saved reference identity no longer matches the scan version and locale. No cross-release comparison was performed.' ); return $this->scan( $scanId ); } $this->checksums = $record['checksums']; ScanProvenance::references( $scan, array( 'core' => ScanProvenance::referenceDigest( $this->checksums ) ) ); } if ( 'files' === $scan['phase'] ) { $spent = 0; while ( $spent < $budget && microtime( true ) < $deadline ) { $jobs = $this->store->work( $scanId, 1 ); if ( ! $jobs ) { $scan['phase'] = isset( $scan['target_finding_id'] ) ? 'finished' : 'missing_core'; break; } $job = $jobs[0]; try { if ( 'directory' === $job['kind'] ) { $spent += $this->directory( $scan, $job, $budget - $spent, $deadline ); } else { $this->file( $scan, $job ); $this->checkpointWork( $scan, $job, 'done' ); ++$spent; } if ( 'incomplete' === $scan['status'] ) { break; } } catch ( \Throwable $e ) { // A durable marker must be replayed after a persistence interruption. // Never replace it with a second, differently-accounted transition. if ( isset( $scan['work_checkpoint'] ) ) { throw $e; } ++$scan['counters']['unreadable']; $this->skip( $scan, $job['root_id'] . ':' . $job['path'], $e->getMessage() ); $this->checkpointWork( $scan, $job, 'failed', (int) $job['cursor'] ); ++$spent; if ( str_contains( $e->getMessage(), 'budget reached' ) || str_contains( $e->getMessage(), 'finding budget' ) ) { $scan['status'] = 'incomplete'; $scan['limitations'][] = $e->getMessage(); break; } } } } elseif ( 'missing_core' === $scan['phase'] ) { $this->missingCore( $scan, $budget ); } elseif ( 'missing_repository' === $scan['phase'] ) { $page = $this->repository->missingFiles( $scan['repository_state'] ?? array(), (int) ( $scan['missing_repository_cursor'] ?? 0 ), $budget ); $scan['missing_repository_cursor'] = $page['cursor']; foreach ( $page['findings'] as $finding ) { $this->record( $scan, $this->normalizeRepositoryFinding( $scan, $finding ) ); } $scan['counters']['skipped'] += count( $page['limitations'] ); $scan['limitations'] = array_slice( array_values( array_unique( array_merge( $scan['limitations'], $page['limitations'] ) ) ), 0, 150 ); if ( $page['done'] ) { $scan['phase'] = 'database'; } } elseif ( 'database' === $scan['phase'] ) { $page = ( new DatabaseInspector() )->inspectPage( $scan['database_cursor'], $budget ); $scan['database_cursor'] = $page['cursor']; $scan['counters']['database_checked'] += $page['checked']; $scan['counters']['skipped'] += $page['skipped']; foreach ( $page['findings'] as $finding ) { $this->record( $scan, $finding ); } foreach ( $page['locations'] as $location ) { $this->resolveAbsent( $scan, $location['location'], $location['hash'], $location['detectors'], $page['findings'] ); } $scan['limitations'] = array_slice( array_values( array_unique( array_merge( $scan['limitations'], $page['limitations'] ) ) ), 0, 150 ); if ( $page['done'] ) { $scan['phase'] = 'finished'; } } if ( 'finished' === $scan['phase'] || 'incomplete' === $scan['status'] ) { $this->complete( $scan ); } else { $this->save( $scan ); } return $this->scan( $scanId ); } catch ( \Throwable $e ) { // Accounting may already be saved while queue progress was interrupted. // Leave that active checkpoint recoverable by the next bounded worker. if ( isset( $scan['work_checkpoint'] ) ) { throw $e; } if ( isset( $scan ) && in_array( $scan['status'], array( 'queued', 'running' ), true ) ) { $scan['status'] = str_contains( $e->getMessage(), 'finding budget' ) ? 'incomplete' : 'failed'; $scan['result_state'] = self::incompleteResult( $scan ); $scan['completed_at'] = gmdate( 'c' ); $scan['limitations'][] = 'Worker failed: ' . $e->getMessage(); $this->save( $scan ); $this->store->activity( 'scan_failed', array( 'scan_id' => $scanId, 'error' => $e->getMessage(), ) ); } throw $e; } finally { $this->store->unlock( 'scan-' . $scanId, $token ); } } private function references( array &$scan ): void { if ( isset( $scan['target_finding_id'] ) && ! in_array( $scan['target_detector_id'] ?? '', array( 'integrity.core', 'integrity.core.missing' ), true ) ) { $scan['coverage']['core_integrity'] = 'Outside this targeted finding verification; no core reference request made.'; return; } if ( empty( get_option( 'antivirus_ai_settings', array() )['online_references'] ) ) { $scan['coverage']['core_integrity'] = 'unverified: online trusted references are disabled'; $scan['limitations'][] = 'Core integrity was not checked against an authenticated reference. Local analysis still ran.'; return; } try { $core = $scan['inventory']['core']; $hashes = $this->reference->checksums( $core['version'], $core['locale'] ); $id = 'ref_' . substr( hash( 'sha256', $scan['id'] ), 0, 59 ); $this->store->put( 'reference', $id, array( 'scan_id' => $scan['id'], 'checksums' => $hashes, 'metadata' => $this->reference->metadata(), ) ); ScanProvenance::references( $scan, array( 'core' => ScanProvenance::referenceDigest( $hashes ) ) ); $scan['reference_record'] = $id; $scan['reference_source'] = $this->reference->metadata(); $scan['coverage']['core_integrity'] = 'authenticated references loaded; only readable discovered files are checked'; } catch ( \Throwable $e ) { $scan['coverage']['core_integrity'] = 'unverified: ' . $e->getMessage(); $scan['limitations'][] = $e->getMessage(); ++$scan['counters']['skipped']; } } private function invalidateReference( array &$scan, string $reason ): void { $this->checksums = array(); $scan['status'] = 'incomplete'; ++$scan['counters']['skipped']; $scan['coverage']['core_integrity'] = 'unverified: ' . $reason; $scan['limitations'][] = $reason; $this->complete( $scan ); } private function directory( array &$scan, array $job, int $limit, float $deadline ): int { $root = $scan['roots'][ $job['root_id'] ]['path'] ?? null; if ( ! $root ) { throw new \RuntimeException( 'Unknown server-owned root.' ); } $path = '' === $job['path'] ? $root : PathGuard::resolve( $root, $job['path'] ); PathGuard::noLinks( $path ); if ( ! is_readable( $path ) ) { throw new \RuntimeException( 'Unreadable directory.' ); } $handle = @opendir( $path ); if ( ! $handle ) { throw new \RuntimeException( 'Directory could not be opened.' ); } $cursor = 0; $processed = 0; $done = false; try { // readdir cannot seek portably. Rewalking the cursor is bounded by the tick deadline. while ( false !== $name = readdir( $handle ) ) { if ( microtime( true ) > $deadline ) { if ( $cursor < (int) $job['cursor'] ) { throw new \RuntimeException( 'Directory cursor cannot be reached within the time budget. Split this unusually large directory.' ); } break; } ++$cursor; if ( $cursor <= (int) $job['cursor'] ) { continue; } if ( '.' === $name || '..' === $name ) { continue; } $relative = '' === $job['path'] ? $name : $job['path'] . '/' . $name; ++$processed; ++$scan['counters']['enumerated']; try { $entry = PathGuard::resolve( $root, $relative ); if ( is_dir( $entry ) ) { if ( $this->excludedDirectory( $scan, $entry, $name ) ) { ++$scan['counters']['excluded']; $this->skip( $scan, $job['root_id'] . ':' . $relative, 'Excluded cache, backup, or development directory; its contents were not inspected.', true ); } else { $overlap = false; foreach ( $scan['roots'] as $otherId => $other ) { if ( $otherId !== $job['root_id'] && $other['path'] === $entry ) { $overlap = true; break; } } if ( ! $overlap ) { $this->store->enqueue( $scan['id'], $job['root_id'], $relative, 'directory' ); } } } else { $this->store->enqueue( $scan['id'], $job['root_id'], $relative, 'file' ); } } catch ( \Throwable $e ) { if ( str_contains( $e->getMessage(), 'budget reached' ) ) { throw $e; } ++$scan['counters']['skipped']; $this->skip( $scan, $job['root_id'] . ':' . $relative, $e->getMessage() ); } if ( $processed >= $limit ) { break; } } if ( false === $name ) { $done = true; } } finally { closedir( $handle ); } $this->checkpointWork( $scan, $job, $done ? 'done' : 'pending', $cursor ); return max( 1, $processed ); } private function file( array &$scan, array $job ): void { $root = $scan['roots'][ $job['root_id'] ]['path']; $relative = $job['path']; $location = $job['root_id'] . ':' . $relative; $extension = strtolower( pathinfo( $relative, PATHINFO_EXTENSION ) ); $referenceMd5 = 'wordpress' === $job['root_id'] ? ( $this->checksums[ $relative ] ?? null ) : null; $repositoryAssessed = $this->repository->assessedFile( array( 'path' => $root . '/' . $relative ), array(), $scan['repository_state'] ?? array() ); $uploadRoot = $scan['inventory']['paths']['uploads'] ?? ''; $uploadExecutable = $uploadRoot && PathGuard::beneath( $root . '/' . $relative, $uploadRoot ) && preg_match( '/\.(?:php[0-9]?|phtml|phar)(?:\.|$)/i', basename( $relative ) ); if ( ! in_array( $extension, $scan['coverage']['extensions'], true ) && '.htaccess' !== basename( $relative ) && null === $referenceMd5 && ! $uploadExecutable && ! $repositoryAssessed ) { ++$scan['counters']['excluded']; $this->skip( $scan, $location, 'Outside declared text/code extensions; not inspected.', true ); return; } $read = FileSnapshot::read( $root, $relative ); $version = $this->componentVersion( $scan, $read['path'] ); $context = array( 'scan_id' => $scan['id'], 'location' => $location, 'content_hash' => $read['hash'], 'upload_executable' => (bool) $uploadExecutable, 'policy_version' => Detector::POLICY_VERSION, 'component_version' => $version, 'root_id' => $job['root_id'], 'root' => $root, 'relative_path' => $relative, 'scope' => array( 'type' => 'file', 'root_id' => $job['root_id'], 'path' => $relative, ), ); $scan['counters']['hashed'] = (int) ( $scan['counters']['hashed'] ?? 0 ) + 1; if ( isset( $read['content'] ) ) { $assessment = $this->structural( $scan, $read['content'], $context ); } else { $assessment = array( 'findings' => array(), 'completed' => array(), 'limitations' => array( array( 'detectors' => Detector::ids(), 'reason' => 'Structural analysis exceeds the 2 MiB limit. The complete file was safely hashed for eligible integrity comparisons; its structural contents remain unassessed.' ) ) ); $scan['counters']['integrity_streamed'] = (int) ( $scan['counters']['integrity_streamed'] ?? 0 ) + 1; } $findings = $assessment['findings']; $detectors = $assessment['completed']; if ( $assessment['limitations'] ) { ++$scan['counters']['skipped']; $scan['counters']['structural_incomplete'] = (int) ( $scan['counters']['structural_incomplete'] ?? 0 ) + 1; foreach ( $assessment['limitations'] as $limit ) { $this->skip( $scan, $location, $limit['reason'] . ' Unfinished detectors: ' . implode( ', ', $limit['detectors'] ) . '. Other completed checks and recorded findings are retained.' ); } } // Persist already-established observations even if a later independent // engine or reference operation cannot finish. Resolution still needs success. try { // The optional host engine is assessed independently of cached structural results. $engine = isset( $read['content'] ) ? ClamEngine::assess( $read['content'], $context ) : array( 'status' => ClamEngine::enabled() ? 'limit' : 'disabled', 'findings' => array(), 'database' => array() ); if ( 'disabled' !== $engine['status'] ) { $previousIncomplete = ! empty( $scan['coverage']['local_engine']['incomplete'] ); $incompleteEngine = $previousIncomplete || ! in_array( $engine['status'], array( 'clear', 'matched' ), true ); $scan['coverage']['local_engine'] = array( 'status' => $incompleteEngine ? 'partial' : 'assessed', 'last_result' => $engine['status'], 'incomplete' => $incompleteEngine, 'database' => $engine['database'], 'scope' => 'Eligible files up to 2 MiB; host-managed ClamAV limits and definitions.' ); $scan['counters']['engine_' . $engine['status']] = (int) ( $scan['counters']['engine_' . $engine['status']] ?? 0 ) + 1; if ( in_array( $engine['status'], array( 'clear', 'matched' ), true ) ) { $detectors[] = 'engine.clamav'; $findings = array_merge( $findings, $engine['findings'] ); } else { ++$scan['counters']['skipped']; $this->skip( $scan, $location, 'The enabled local ClamAV assessment was ' . $engine['status'] . '; its prior evidence was retained. Local structural and integrity checks continue.' ); } } $integrityChecked = false; if ( $repositoryAssessed ) { $integrityChecked = true; $detectors[] = 'integrity.repository'; $repositoryFinding = $this->repository->inspectFile( $read, $context, $scan['repository_state'] ?? array() ); if ( $repositoryFinding ) { $findings[] = $repositoryFinding; } } if ( null !== $referenceMd5 && $this->eligibleCorePath( $relative ) ) { $integrityChecked = true; $detectors[] = 'integrity.core'; $detectors[] = 'integrity.core.missing'; if ( ! hash_equals( $referenceMd5, isset( $read['content'] ) ? md5( $read['content'] ) : $read['digests']['md5'] ) ) { $core = $scan['inventory']['core']; $findings[] = Finding::make( $context + array( 'category' => 'integrity_change', 'detector_id' => 'integrity.core', 'severity' => 'medium', 'confidence' => 'informational', 'reason' => 'This file differs from the authenticated reference for the installed WordPress version. The cause may be an owner edit, damage, or unauthorized modification.', 'evidence' => array( array( 'type' => 'reference_mismatch', 'algorithm' => 'md5', 'expected' => $referenceMd5, 'observed' => isset( $read['content'] ) ? md5( $read['content'] ) : $read['digests']['md5'], ), ), 'source' => $scan['reference_source'] ?? array(), 'limitations' => array( 'A checksum difference alone is not malware evidence.', 'The current core version may still have known vulnerabilities.', 'Matching a reference does not prove that release harmless.' ), 'recommended_action' => 'Review the difference. If the edit is not needed, approve a matching-version replacement after protected recovery is verified.', 'action_eligibility' => array( 'repair' => true, 'reason' => 'A matching-version authenticated core reference is available; owner review and safe recovery checks are required.', 'reference' => array( 'type' => 'core', 'version' => $core['version'], 'locale' => $core['locale'], 'path' => $relative, 'md5' => $referenceMd5, ), ), ) ); } } } finally { foreach ( $findings as $finding ) { if ( ! isset( $read['content'] ) && ! empty( $finding['action_eligibility']['repair'] ) ) { $finding['action_eligibility']['repair'] = false; $finding['action_eligibility']['reason'] = 'The integrity difference was checked by streaming, but this file exceeds the supported 2 MiB repair limit. Review an official update or reinstall with independent recovery material.'; } $this->record( $scan, $finding ); } } $this->resolveAbsent( $scan, $location, $read['hash'], $detectors, $findings ); ScanAssessment::observe( $scan, $location, array( 'structural' => $assessment['completed'], 'core_integrity' => in_array( 'integrity.core', $detectors, true ), 'repository_integrity' => $repositoryAssessed, 'local_engine' => in_array( $engine['status'], array( 'clear', 'matched' ), true ) ? 'assessed' : $engine['status'] ), isset( $read['content'] ) ? null : array( 'content_hash' => $read['hash'], 'bytes' => $read['stat']['size'] ) ); if ( $integrityChecked ) { $scan['counters']['integrity_checked'] = (int) ( $scan['counters']['integrity_checked'] ?? 0 ) + 1; } ++$scan['counters']['checked']; } /** Cache only a clean structural assessment; reference and database evidence are never cached here. */ private function structural( array &$scan, string $content, array $context ): array { $id = 'fc_' . substr( hash( 'sha256', $context['root'] . "\0" . $context['location'] ), 0, 60 ); $key = hash( 'sha256', wp_json_encode( array( 'content' => $context['content_hash'], 'component' => base64_encode( $context['component_version'] ), 'rules' => Detector::registry(), 'policy' => self::CACHE_POLICY, 'plugin' => defined( 'ANTIVIRUS_AI_VERSION' ) ? ANTIVIRUS_AI_VERSION : 'development', ), JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_SLASHES ) ); try { $cached = $this->store->get( $id ); } catch ( \Throwable $error ) { $cached = null; $scan['coverage']['cache'] = 'Clean-assessment cache unavailable; local analysis continues without that optimization.'; } $at = (int) ( $cached['assessed_at'] ?? 0 ); if ( 'changed' === ( $scan['mode'] ?? 'full' ) && $cached && 'file_cache' === $cached['_kind'] && hash_equals( (string) ( $cached['key'] ?? '' ), $key ) && $at <= time() && $at > time() - self::FULL_INTERVAL ) { $scan['counters']['reused'] = (int) ( $scan['counters']['reused'] ?? 0 ) + 1; return array( 'findings' => array(), 'completed' => Detector::ids(), 'limitations' => array() ); } $assessment = Detector::assess( $content, $context ); $findings = $assessment['findings']; $scan['counters']['analyzed'] = (int) ( $scan['counters']['analyzed'] ?? 0 ) + 1; if ( ! $findings && ! $assessment['limitations'] ) { try { if ( $cached || $this->store->count( 'file_cache' ) < 100000 ) { $this->store->put( 'file_cache', $id, array( 'key' => $key, 'assessed_at' => time() ), 'active', $cached['_revision'] ?? null ); } else { $scan['coverage']['cache'] = 'Clean-assessment cache reached its 100,000-file cap; uncached content is still analyzed.'; } } catch ( \Throwable $error ) { // Cache failure cannot withdraw local detection or damage retained finding evidence. $scan['coverage']['cache'] = 'Clean-assessment cache unavailable; local analysis continues without that optimization.'; } } return $assessment; } private function excludedDirectory( array $scan, string $path, string $name ): bool { $name = strtolower( $name ); if ( ! in_array( $name, $scan['coverage']['excluded_directories'], true ) ) { return false; } foreach ( $scan['inventory']['paths'] as $configured ) { if ( $path === $configured || PathGuard::beneath( $configured, $path ) ) { return false; } } if ( in_array( $name, array( '.git', '.svn', 'node_modules' ), true ) ) { return true; } return dirname( $path ) === ( $scan['inventory']['paths']['content'] ?? '' ); } private function eligibleCorePath( string $path ): bool { return 'wp-config.php' !== $path && ! str_starts_with( $path, 'wp-content/' ) && ( str_starts_with( $path, 'wp-admin/' ) || str_starts_with( $path, 'wp-includes/' ) || ( ! str_contains( $path, '/' ) && preg_match( '/^(?:wp-[a-z-]+|index|xmlrpc)\.php$/D', $path ) ) ); } private function missingCore( array &$scan, int $limit ): void { $keys = array_keys( $this->checksums ); $cursor = (int) ( $scan['missing_core_cursor'] ?? 0 ); $root = $scan['roots']['wordpress']['path'] ?? null; if ( ! $root ) { $scan['phase'] = 'missing_repository'; return; } foreach ( array_slice( $keys, $cursor, $limit ) as $path ) { $scan['missing_core_cursor'] = ++$cursor; if ( ! $this->eligibleCorePath( $path ) || false !== @lstat( $root . '/' . $path ) ) { continue; } $core = $scan['inventory']['core']; $finding = Finding::make( array( 'scan_id' => $scan['id'], 'location' => 'wordpress:' . $path, 'root_id' => 'wordpress', 'root' => $root, 'relative_path' => $path, 'scope' => array( 'type' => 'file', 'root_id' => 'wordpress', 'path' => $path, ), 'category' => 'integrity_change', 'detector_id' => 'integrity.core.missing', 'severity' => 'medium', 'confidence' => 'informational', 'component_version' => $core['version'], 'content_hash' => hash( 'sha256', 'missing|' . $core['version'] . '|' . $path ), 'reason' => 'A file expected by the authenticated matching-version core manifest was not found.', 'evidence' => array( array( 'type' => 'missing_reference_file', 'expected_md5' => $this->checksums[ $path ], ), ), 'source' => $scan['reference_source'] ?? array(), 'limitations' => array( 'An intentional deployment choice or damaged installation may explain a missing file. This is not infection confirmation.' ), 'recommended_action' => 'Review the deployment and restore the matching official core package using WordPress update/reinstall tools. This beta does not create missing files.', 'action_eligibility' => array( 'repair' => false, 'reason' => 'Automatic creation of missing files is outside the supported repair scope.', 'reference' => null, ), ) ); $this->record( $scan, $finding ); } if ( $cursor >= count( $keys ) ) { $scan['phase'] = 'missing_repository'; } } private function normalizeRepositoryFinding( array $scan, array $finding ): array { foreach ( $scan['roots'] as $rootId => $root ) { if ( ! PathGuard::beneath( $finding['location'], $root['path'] ) ) { continue; } $relative = substr( $finding['location'], strlen( $root['path'] ) + 1 ); PathGuard::relative( $relative ); $finding['location'] = $rootId . ':' . $relative; $finding['root_id'] = $rootId; $finding['root'] = $root['path']; $finding['relative_path'] = $relative; $finding['scope'] = array_merge( $finding['scope'] ?? array(), array( 'type' => 'file', 'root_id' => $rootId, 'path' => $relative, ) ); $finding['id'] = Finding::identity( $finding['location'], $finding['detector_id'] ); return $finding; } throw new \RuntimeException( 'Repository evidence is outside declared scan roots.' ); } private function resolveVulnerabilitiesPage( array &$scan, int $limit ): void { $components = $scan['vulnerability_assessed'] ?? array(); if ( ! $components ) { $scan['phase'] = 'files'; return; } // The feed adapter explicitly identifies complete/current assessments. Stale, absent, // unconfirmed, removed or ambiguous components cannot resolve historical evidence. $offset = (int) ( $scan['vulnerability_resolution_cursor'] ?? 0 ); $findings = $this->store->list( 'finding', null, $limit, $offset ); foreach ( $findings as $finding ) { if ( 'vulnerable_software' !== $finding['category'] || 'resolved' === $finding['_status'] || $finding['scan_id'] === $scan['id'] || ! isset( $components[ $finding['location'] ] ) ) { continue; } $this->resolveAbsent( $scan, $finding['location'], hash( 'sha256', (string) $components[ $finding['location'] ] ), array( $finding['detector_id'] ), array() ); } $scan['vulnerability_resolution_cursor'] = $offset + count( $findings ); if ( count( $findings ) < $limit || $scan['vulnerability_resolution_cursor'] >= 20000 ) { $scan['phase'] = 'files'; } } private function componentVersion( array $scan, string $absolute ): string { foreach ( array_merge( $scan['inventory']['plugins'], $scan['inventory']['mu_plugins'] ) as $component ) { $base = str_contains( $component['file'], '/' ) ? dirname( $component['path'] ) : $component['path']; if ( PathGuard::beneath( $absolute, $base ) ) { return $component['version'] ?: 'unknown'; } } foreach ( $scan['inventory']['themes'] as $theme ) { if ( PathGuard::beneath( $absolute, $theme['path'] ) ) { return $theme['version'] ?: 'unknown'; } } return PathGuard::beneath( $absolute, $scan['inventory']['paths']['wordpress'] ) ? (string) $scan['inventory']['core']['version'] : 'unknown'; } private function record( array &$scan, array $finding ): void { $finding['scan_id'] = $scan['id']; $id = $finding['id']; $old = $this->store->get( $id ); if ( ! $old && $this->store->count( 'finding' ) >= 20000 ) { throw new \RuntimeException( 'The retained finding budget was reached; review/export retained evidence before extending coverage.' ); } $key = Finding::exceptionKey( $finding ); $exception = $this->store->get( 'ex_' . substr( $key, 0, 60 ) ); $status = $exception && 'exception' === $exception['_kind'] && hash_equals( $key, (string) ( $exception['key'] ?? '' ) ) ? 'excepted' : 'open'; $finding['first_observed_at'] = $old['first_observed_at'] ?? $finding['observed_at']; $finding['last_observed_at'] = $finding['observed_at']; $finding['history'] = $old['history'] ?? array(); if ( $old && ! hash_equals( Finding::exceptionKey( $old ), $key ) ) { $finding['history'][] = array( 'observed_at' => $old['observed_at'], 'content_hash' => $old['content_hash'], 'detector_version' => $old['detector_version'], 'evidence' => $old['evidence'], 'status' => $old['_status'], ); $finding['history'] = array_slice( $finding['history'], -10 ); } elseif ( $old && 'acknowledged' === $old['_status'] && 'open' === $status ) { $status = 'acknowledged'; $finding['acknowledged_at'] = $old['acknowledged_at'] ?? null; $finding['acknowledged_by'] = $old['acknowledged_by'] ?? null; } if ( 'excepted' === $status ) { $finding['exception_id'] = $exception['_id']; } // Keep current evidence intact; only bounded prior observation snapshots may age out. while ( $finding['history'] && $this->jsonBytes( $finding ) > 30000 ) { array_shift( $finding['history'] ); $finding['history_truncated'] = true; } if ( $this->jsonBytes( $finding ) > 32768 ) { throw new \RuntimeException( 'Current evidence exceeds the finding budget; scan incomplete and prior evidence retained.' ); } $this->store->put( 'finding', $id, $finding, $status, $old ? (int) $old['_revision'] : null ); // Scan counters deduplicate retried batches, with a bounded set of finding identifiers. if ( ! isset( $scan['observed_findings'][ $id ] ) ) { if ( count( $scan['observed_findings'] ) >= $scan['coverage']['finding_limit'] ) { throw new \RuntimeException( 'The scan finding budget was reached; remaining coverage is incomplete.' ); } $scan['observed_findings'][ $id ] = 'excepted' === $status ? 'excepted' : ( 'confirmed' === $finding['confidence'] ? 'confirmed' : 'attention' ); ++$scan['counters']['findings']; if ( 'excepted' === $status ) { ++$scan['counters']['exceptions']; } } if ( function_exists( 'do_action' ) ) { do_action( 'antivirus_ai_finding_recorded', $finding ); } } private function resolveAbsent( array $scan, string $location, string $hash, array $detectors, array $present ): void { foreach ( $detectors as $detector ) { $id = Finding::identity( $location, $detector ); foreach ( $present as $finding ) { if ( $finding['id'] === $id ) { continue 2; } } $old = $this->store->get( $id ); if ( $old && 'finding' === $old['_kind'] ) { if ( 'resolved' !== $old['_status'] || empty( $old['resolved_at'] ) ) { $old['resolved_at'] = gmdate( 'c' ); } $old['verification_scan_id'] = $scan['id']; $old['verified_content_hash'] = $hash; $old['resolution'] = 'This detector no longer reproduced when the location was re-read. Original evidence is retained; this does not prove complete absence of malware.'; $this->store->put( 'finding', $id, $old, 'resolved', (int) $old['_revision'] ); } } } private function administratorChanges( array &$scan, array $previous ): void { if ( $scan['inventory']['limitations'] || ! empty( $previous['inventory']['limitations'] ) ) { $scan['limitations'][] = 'Administrator change comparison withheld because one inventory was incomplete.'; return; } $before = array(); foreach ( $previous['inventory']['administrators'] ?? array() as $admin ) { $before[ (int) $admin['id'] ] = $admin; } $current = array(); foreach ( $scan['inventory']['administrators'] as $admin ) { $current[ (int) $admin['id'] ] = $admin; if ( isset( $before[ $admin['id'] ] ) ) { continue; } $finding = Finding::make( array( 'location' => 'administrator:' . $admin['id'], 'detector_id' => 'inventory.new-administrator', 'content_hash' => hash( 'sha256', json_encode( $admin ) ), 'category' => 'configuration', 'severity' => 'medium', 'confidence' => 'informational', 'component_version' => 'administrator-inventory-v1', 'reason' => 'An administrator account appeared since the previous completed scan. This may be an authorized change.', 'evidence' => array( array( 'type' => 'administrator_added', 'user_id' => $admin['id'], 'login' => $admin['login'], 'previous_scan_id' => $previous['_id'], ), ), 'limitations' => array( 'Prior observed accounts are untrusted. This comparison cannot establish who authorized an account change.' ), 'recommended_action' => 'Confirm the account with the owner and review WordPress user management. No account was modified.', ) ); $this->record( $scan, $finding ); } foreach ( $before as $userId => $admin ) { if ( isset( $current[ $userId ] ) ) { continue; } $hash = hash( 'sha256', 'no-longer-administrator|' . $userId ); $this->resolveAbsent( $scan, 'administrator:' . $userId, $hash, array( 'inventory.new-administrator' ), array() ); $finding = Finding::make( array( 'location' => 'administrator:' . $userId, 'detector_id' => 'inventory.administrator-removed', 'content_hash' => $hash, 'category' => 'configuration', 'severity' => 'info', 'confidence' => 'informational', 'component_version' => 'administrator-inventory-v1', 'reason' => 'An account recorded as an administrator during the previous completed scan is no longer in the administrator-role inventory.', 'evidence' => array( array( 'type' => 'administrator_removed', 'user_id' => $userId, 'login' => $admin['login'], 'previous_scan_id' => $previous['_id'], ), ), 'limitations' => array( 'The role may have changed or the account may have been removed legitimately. This observation cannot identify who authorized the change.' ), 'recommended_action' => 'Confirm that the role or account change was expected. No account was modified.', ) ); $this->record( $scan, $finding ); } } private function complete( array &$scan ): void { $incomplete = 'incomplete' === $scan['status'] || $scan['counters']['unreadable'] > 0 || $scan['counters']['skipped'] > 0; $scan['status'] = $incomplete ? 'incomplete' : 'completed'; $scan['completed_at'] = gmdate( 'c' ); $scan['result_state'] = $incomplete ? self::incompleteResult( $scan ) : ( $scan['counters']['findings'] > 0 ? 'attention_needed' : 'no_threats_detected' ); foreach ( $scan['observed_findings'] as $finding ) { if ( 'confirmed' === $finding ) { $scan['result_state'] = 'confirmed_threat'; break; } } ScanProvenance::references( $scan, $this->repository->referenceDigests() ); ScanProvenance::checkpoint( $scan, true ); ScanAssessment::finish( $scan ); $this->save( $scan ); if ( empty( $scan['target_finding_id'] ) && 'finished' === ( $scan['phase'] ?? '' ) ) { $summary = $this->store->get( 'scan_summary' ) ?? array(); if ( 'completed' === $scan['status'] ) { $summary['last_successful'] = self::summarize( $scan ); } if ( 'full' === ( $scan['mode'] ?? 'full' ) ) { $summary['last_full_sweep'] = self::summarize( $scan ); } $this->store->put( 'scan_summary', 'scan_summary', $summary, 'active', $summary['_revision'] ?? null ); } $this->store->activity( 'scan_completed', array( 'scan_id' => $scan['id'], 'status' => $scan['status'], 'counters' => $scan['counters'], ) ); if ( function_exists( 'do_action' ) ) { do_action( 'antivirus_ai_scan_completed', $scan['id'] ); } } private function skip( array &$scan, string $location, string $reason, bool $excluded = false ): void { $item = array( 'location' => $location, 'reason' => $reason, 'excluded' => $excluded, ); if ( ! $excluded && count( $scan['skipped_items'] ) >= 100 ) { foreach ( array_reverse( array_keys( $scan['skipped_items'] ) ) as $index ) { $existing = $scan['skipped_items'][ $index ]; if ( ! empty( $existing['excluded'] ) || str_starts_with( $existing['reason'], 'Outside declared text/code extensions;' ) || str_starts_with( $existing['reason'], 'Excluded cache, backup, or development directory;' ) ) { array_splice( $scan['skipped_items'], $index, 1 ); break; } } } if ( count( $scan['skipped_items'] ) < 100 ) { if ( $excluded ) { $scan['skipped_items'][] = $item; } else { array_unshift( $scan['skipped_items'], $item ); } } else { $scan['coverage']['omitted_skip_details'] = (int) ( $scan['coverage']['omitted_skip_details'] ?? 0 ) + 1; } } private function checkpointWork( array &$scan, array $job, string $state, int $cursor = 0 ): void { WorkCheckpoint::commit( $this->store, $scan, $job, $state, $cursor, function ( array &$record ): void { $this->save( $record, false ); } ); } private static function incompleteResult( array $scan ): string { return 'confirmed_threat' === ( $scan['result_state'] ?? '' ) || in_array( 'confirmed', $scan['observed_findings'] ?? array(), true ) ? 'confirmed_threat' : 'scan_incomplete'; } private function save( array &$scan, bool $checkpoint = true ): void { ScanProvenance::references( $scan, $this->repository->referenceDigests() ); if ( $checkpoint && empty( $scan['review_context']['complete'] ) ) { ScanProvenance::checkpoint( $scan ); } $scan['updated_at'] = gmdate( 'c' ); $scan['heartbeat'] = time(); if ( $this->jsonBytes( $scan ) > 900 * 1024 ) { // Findings are separate objects. A redundant scan index cannot strand a running job. $scan['status'] = 'incomplete'; $scan['result_state'] = self::incompleteResult( $scan ); $scan['completed_at'] = gmdate( 'c' ); $scan['observed_findings'] = array(); $scan['observed_metadata_truncated'] = true; $scan['limitations'][] = 'Scan summary storage budget reached. The scan stopped incomplete; individually recorded finding evidence is retained.'; if ( $this->jsonBytes( $scan ) > 900 * 1024 ) { $inventory = $scan['inventory'] ?? array(); $counts = array(); foreach ( array( 'plugins', 'themes', 'mu_plugins', 'drop_ins', 'administrators', 'scheduled_tasks' ) as $key ) { $counts[ $key ] = count( $inventory[ $key ] ?? array() ); } $scan['inventory'] = array_intersect_key( $inventory, array_flip( array( 'observed_at', 'trust', 'core', 'paths', 'coverage' ) ) ) + array( 'summary_counts' => $counts, 'limitations' => array( 'Detailed inventory omitted after reaching the scan summary budget. No trusted baseline was established.' ), ); $scan['repository_state'] = array( 'status' => 'incomplete', 'done' => false, 'summary_components' => count( $scan['repository_state']['components'] ?? array() ), ); $scan['skipped_items'] = array_slice( $scan['skipped_items'] ?? array(), 0, 25 ); $scan['limitations'] = array_map( static fn( $text )=>substr( (string) $text, 0, 1000 ), array_slice( $scan['limitations'], -40 ) ); } if ( $this->jsonBytes( $scan ) > 900 * 1024 ) { // An abnormally huge configured-root inventory is unassessable. Retain a minimal // terminal checkpoint with counters and links to independent finding objects. $scan = array_intersect_key( $scan, array_flip( array( 'id', '_revision', 'started_at', 'updated_at', 'heartbeat', 'completed_at', 'counters', 'target_finding_id', 'work_checkpoint', 'result_state' ) ) ) + array( 'status' => 'incomplete', 'result_state' => 'scan_incomplete', 'phase' => 'finished', 'roots' => array(), 'inventory' => array( 'trust' => 'untrusted_observation', 'limitations' => array( 'Inventory exceeded the persisted summary budget.' ), ), 'coverage' => array( 'declared_scope' => 'Scan stopped at the persisted summary budget; consult individually retained evidence.' ), 'observed_metadata_truncated' => true, 'observed_findings' => array(), 'limitations' => array( 'Scan summary storage budget reached. Individual findings were retained; remaining paths were not checked.' ), 'skipped_items' => array(), ); } } $scan = $this->store->put( 'scan', $scan['id'], $scan, $scan['status'], isset( $scan['_revision'] ) ? (int) $scan['_revision'] : null ); } private function jsonBytes( array $value ): int { foreach ( array_keys( $value ) as $key ) { if ( str_starts_with( (string) $key, '_' ) ) { unset( $value[ $key ] ); } } $encoded = wp_json_encode( $value, JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_SLASHES ); return is_string( $encoded ) ? strlen( $encoded ) : PHP_INT_MAX; } private function scan( string $id ): array { $scan = $this->store->get( $id ); if ( ! $scan || 'scan' !== $scan['_kind'] ) { throw new \RuntimeException( 'Scan not found.' ); } return $scan; } }