referenceDigests; } public static function confirmation( array $component, string $type ): array { if ( ! in_array( $type, array( 'plugin', 'theme' ), true ) ) { throw new \RuntimeException( 'Unsupported component type.' ); } $slug = (string) ( $component['slug'] ?? '' ); $version = (string) ( $component['version'] ?? '' ); $name = (string) ( $component['name'] ?? '' ); if ( ! preg_match( '/^[a-z0-9][a-z0-9-]{0,99}$/D', $slug ) || ! Vulnerabilities::stableVersion( $version ) || '' === $name || strlen( $name ) > 500 ) { throw new \RuntimeException( 'A stable version and supported repository identity are required.' ); } $file = 'plugin' === $type ? (string) ( $component['file'] ?? '' ) : $slug; PathGuard::relative( $file ); if ( 'plugin' === $type && ( ! str_starts_with( $file, $slug . '/' ) || 1 !== substr_count( $file, '/' ) || ! str_ends_with( strtolower( $file ), '.php' ) ) ) { throw new \RuntimeException( 'Only plugins in a uniquely identified repository directory are supported.' ); } $uri = (string) ( $component['update_uri'] ?? '' ); if ( '' !== $uri && ! preg_match( '~^https://(?:www\.)?wordpress\.org/' . ( 'theme' === $type ? 'themes' : 'plugins' ) . '/' . preg_quote( $slug, '~' ) . '/?$~D', $uri ) ) { throw new \RuntimeException( 'A custom update source cannot be assumed to be a WordPress.org component.' ); } $data = array( 'schema_version' => 1, 'type' => $type, 'slug' => $slug, 'file' => $file, 'name' => $name, 'version' => $version, ); $data['identity_hash'] = hash( 'sha256', json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR ) ); $data['confirmed_at'] = gmdate( 'c' ); return $data; } public static function identity( array $component, string $type, array $confirmations ): array { try { $expected = self::confirmation( $component, $type ); } catch ( \Throwable $e ) { return array( 'verified' => false, 'reason' => $e->getMessage(), 'basis' => 'unverified', ); } $key = $type . ':' . $expected['file']; $stored = $confirmations[ $key ] ?? null; if ( ! is_array( $stored ) || ! isset( $stored['identity_hash'], $stored['confirmed_at'] ) || ! is_string( $stored['identity_hash'] ) || ! hash_equals( $expected['identity_hash'], $stored['identity_hash'] ) ) { return array( 'verified' => false, 'reason' => 'Repository identity requires owner confirmation for this exact name, version and component file; a matching slug is insufficient.', 'basis' => 'unverified', ); } return array( 'verified' => true, 'reason' => '', 'basis' => 'Owner-confirmed WordPress.org identity for this observed name, version and component file.', 'identity_hash' => $expected['identity_hash'], ); } /** One component per call; each network/package operation has explicit time and size bounds. */ public function prepare( array $inventory, array $state = array() ): array { $state += array( 'components' => array(), 'limitations' => array(), 'cursor' => 0, 'done' => false, 'status' => 'preparing', ); if ( empty( get_option( 'antivirus_ai_settings', array() )['online_references'] ) ) { $state['done'] = true; $state['status'] = 'disabled'; $state['limitations'][] = 'Repository plugin/theme references are disabled; local observations are not a trusted baseline.'; return $state; } $plugins = array_merge( array_map( static fn( $c ) => $c + array( 'type' => 'plugin' ), array_values( is_array( $inventory['plugins'] ?? null ) ? $inventory['plugins'] : array() ) ), array_map( static fn( $c ) => $c + array( 'type' => 'theme' ), array_values( is_array( $inventory['themes'] ?? null ) ? $inventory['themes'] : array() ) ) ); $count = min( count( $plugins ), self::MAX_PLUGINS ); if ( (int) $state['cursor'] >= $count ) { $state['done'] = true; if ( count( $plugins ) > self::MAX_PLUGINS ) { $state['limitations'][] = 'Repository reference limit of 100 components reached; remaining components are unverified.'; } $state['limitations'][] = 'Repository checks cover owner-confirmed plugins with official checksums and themes with exact official packages. Must-use plugins, drop-ins, premium/custom sources and unidentified or unavailable versions remain unverified. Matching a package does not establish safety.'; $state['limitations'] = array_slice( array_values( array_unique( $state['limitations'] ) ), 0, 150 ); $state['status'] = count( array_filter( $state['components'], static fn( $c ) => 'reference_available' !== $c['status'] ) ) ? 'partial' : 'available'; if ( empty( $inventory['targeted'] ) ) { $this->prune( $state['components'] ); } return $state; } $component = $plugins[ (int) $state['cursor']++ ]; $type = $component['type']; $file = 'theme' === $type ? (string) $component['slug'] : (string) ( $component['file'] ?? '' ); $componentKey = 'theme' === $type ? 'theme:' . $file : $file; $entry = array( 'type' => $type, 'file' => $file, 'name' => (string) ( $component['name'] ?? '' ), 'slug' => (string) ( $component['slug'] ?? '' ), 'version' => (string) ( $component['version'] ?? '' ), 'status' => 'unverified', 'root' => '', 'reference_option' => '', 'identity_basis' => 'unverified', ); try { $confirmations = get_option( 'antivirus_ai_repository_confirmations', array() ); $identity = self::identity( $component, $type, is_array( $confirmations ) ? $confirmations : array() ); if ( ! $identity['verified'] ) { throw new \RuntimeException( $identity['reason'] ); } $same = array_filter( $plugins, static fn( $c ) => ( $c['type'] ?? '' ) === $type && ( $c['slug'] ?? '' ) === $entry['slug'] ); if ( 1 !== count( $same ) ) { throw new \RuntimeException( 'Multiple component headers share this identity; reference coverage is ambiguous.' ); } $root = 'theme' === $type ? dirname( (string) ( $component['path'] ?? '' ) ) : rtrim( (string) ( $inventory['paths']['plugins'] ?? '' ), '/' ); if ( 'theme' === $type && ! in_array( $root, array_filter( $inventory['paths'] ?? array(), static fn( $key ) => str_starts_with( (string) $key, 'themes' ), ARRAY_FILTER_USE_KEY ), true ) ) { throw new \RuntimeException( 'Theme root is outside registered inventory paths.' ); } PathGuard::resolve( $root, 'theme' === $type ? $file . '/style.css' : $file ); $entry['root'] = PathGuard::resolve( $root, $entry['slug'] ); $entry['identity_basis'] = $identity['basis']; $entry['identity_hash'] = $identity['identity_hash']; $key = 'antivirus_ai_repository_' . hash( 'sha256', $componentKey ); $manifest = get_option( $key, array() ); if ( ! $this->fresh( $manifest, $entry ) ) { $manifest = $this->fetch( $entry ); update_option( $key, $manifest, false ); } $entry['reference_option'] = $key; $entry['status'] = 'reference_available'; $entry['source'] = $manifest['source']; $entry['file_count'] = count( $manifest['files'] ); } catch ( \Throwable $e ) { $entry['reason'] = $e->getMessage(); $state['limitations'][] = ucfirst( $type ) . ' ' . substr( $file, 0, 220 ) . ' unverified: ' . $e->getMessage(); } $state['components'][ $componentKey ] = $entry; return $state; } private function fresh( $manifest, array $entry ): bool { if ( ! is_array( $manifest ) || 2 !== ( $manifest['schema_version'] ?? null ) || ( $manifest['type'] ?? '' ) !== ( $entry['type'] ?? 'plugin' ) || ( $manifest['slug'] ?? '' ) !== $entry['slug'] || ( $manifest['version'] ?? '' ) !== $entry['version'] || ( $manifest['identity_hash'] ?? '' ) !== ( $entry['identity_hash'] ?? '' ) || ! is_int( $manifest['fetched'] ?? null ) || $manifest['fetched'] > time() || time() - $manifest['fetched'] >= 86400 || ! is_array( $manifest['source'] ?? null ) ) { return false; } try { self::validateFiles( $manifest['files'] ?? null ); } catch ( \Throwable $e ) { return false; } return true; } private static function validateFiles( $files ): array { if ( ! is_array( $files ) || ! $files || count( $files ) > 10000 || strlen( json_encode( $files, JSON_THROW_ON_ERROR ) ) > self::CACHE_BYTES ) { throw new \RuntimeException( 'Reference file map is missing or exceeds budget.' ); } foreach ( $files as $path => $entry ) { PathGuard::relative( (string) $path ); if ( strlen( (string) $path ) > 512 || ! is_array( $entry ) || ! in_array( $entry['algorithm'] ?? '', array( 'md5', 'sha256' ), true ) || ! is_array( $entry['hashes'] ?? null ) || ! $entry['hashes'] || count( $entry['hashes'] ) > 8 ) { throw new \RuntimeException( 'Malformed reference entry.' ); } foreach ( $entry['hashes'] as $hash ) { if ( ! is_string( $hash ) || ! preg_match( '/^[a-f0-9]{' . ( 'sha256' === $entry['algorithm'] ? '64' : '32' ) . '}$/D', $hash ) ) { throw new \RuntimeException( 'Malformed reference hash.' ); } } } return $files; } private function fetch( array $entry ): array { if ( 'theme' === ( $entry['type'] ?? '' ) ) { return $this->fetchTheme( $entry ); } $url = 'https://downloads.wordpress.org/plugin-checksums/' . rawurlencode( $entry['slug'] ) . '/' . rawurlencode( $entry['version'] ) . '.json'; // Fixed WordPress.org origin; validated slug/version cannot select a host or redirect. $response = wp_remote_get( $url, array( 'timeout' => 5, 'redirection' => 0, 'sslverify' => true, 'limit_response_size' => self::MAX_BYTES, 'user-agent' => 'Shieldlume Security/0.1', ) ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { throw new \RuntimeException( 'Authenticated repository checksums are unavailable.' ); } $body = wp_remote_retrieve_body( $response ); if ( ! is_string( $body ) || strlen( $body ) >= self::MAX_BYTES ) { throw new \RuntimeException( 'Repository manifest exceeded the response budget.' ); } $files = self::parseManifest( $body, $entry['slug'], $entry['version'] ); if ( strlen( json_encode( $files, JSON_THROW_ON_ERROR ) ) > self::CACHE_BYTES ) { throw new \RuntimeException( 'Repository manifest exceeded the normalized cache budget.' ); } return array( 'schema_version' => 2, 'type' => $entry['type'] ?? 'plugin', 'slug' => $entry['slug'], 'version' => $entry['version'], 'identity_hash' => $entry['identity_hash'], 'fetched' => time(), 'files' => $files, 'source' => array( 'provider' => 'WordPress.org', 'url' => $url, 'fetched_at' => gmdate( 'c' ), 'expires_at' => gmdate( 'c', time() + 86400 ), 'transport' => 'HTTPS certificate validation; redirects refused', 'reference_hash' => 'SHA-256 when supplied; otherwise repository MD5. Correspondence is not a safety guarantee.', ), ); } private function fetchTheme( array $entry ): array { $package = RepositoryPackage::inspect( 'theme', $entry['slug'], $entry['version'], static function ( $zip, $entries ) use ( $entry ) { RepositoryPackage::checkHeader( RepositoryPackage::content( $zip, $entry['slug'], 'style.css', $entries ), 'theme', $entry['version'], $entry['name'] ); $files = array(); $deadline = microtime( true ) + 5; foreach ( $entries as $path => $stat ) { if ( microtime( true ) > $deadline ) { throw new \RuntimeException( 'Theme hash generation budget exceeded.' ); } $content = RepositoryPackage::content( $zip, $entry['slug'], $path, $entries, 16777216 ); $files[ $path ] = array( 'algorithm' => 'sha256', 'hashes' => array( hash( 'sha256', $content ) ) ); } ksort( $files, SORT_STRING ); return array( 'files' => self::validateFiles( $files ) ); } ); return array( 'schema_version' => 2, 'type' => 'theme', 'slug' => $entry['slug'], 'version' => $entry['version'], 'identity_hash' => $entry['identity_hash'], 'fetched' => time(), 'files' => $package['files'], 'source' => $package['source'] ); } public static function parseManifest( string $body, string $slug, string $version ): array { if ( strlen( $body ) >= self::MAX_BYTES ) { throw new \RuntimeException( 'Repository manifest exceeded budget.' ); } $data = json_decode( $body, true, 12, JSON_THROW_ON_ERROR ); if ( ! is_array( $data ) || empty( $data['files'] ) || ! is_array( $data['files'] ) || count( $data['files'] ) > 10000 || ( isset( $data['plugin'] ) && $data['plugin'] !== $slug ) || ( isset( $data['version'] ) && $data['version'] !== $version ) ) { throw new \RuntimeException( 'Repository manifest identity or schema mismatch.' ); } $out = array(); foreach ( $data['files'] as $path => $hashes ) { PathGuard::relative( (string) $path ); if ( strlen( (string) $path ) > 512 || ! is_array( $hashes ) ) { throw new \RuntimeException( 'Unsupported repository manifest path or hash.' ); } $algorithm = array_key_exists( 'sha256', $hashes ) ? 'sha256' : 'md5'; $values = $hashes[ $algorithm ] ?? null; if ( is_string( $values ) ) { $values = array( $values ); } if ( ! is_array( $values ) || ! $values || count( $values ) > 8 ) { throw new \RuntimeException( 'Repository hashes missing or excessive.' ); } foreach ( $values as $hash ) { if ( ! is_string( $hash ) || ! preg_match( '/^[a-f0-9]{' . ( 'sha256' === $algorithm ? '64' : '32' ) . '}$/D', $hash ) ) { throw new \RuntimeException( 'Invalid repository hash.' ); } } $out[ $path ] = array( 'algorithm' => $algorithm, 'hashes' => array_values( array_unique( $values ) ), ); } ksort( $out, SORT_STRING ); return $out; } private function manifest( array $component ): ?array { $key = (string) ( $component['reference_option'] ?? '' ); if ( 'reference_available' !== ( $component['status'] ?? '' ) || ! preg_match( '/^antivirus_ai_repository_[a-f0-9]{64}$/D', $key ) ) { return null; } if ( ! array_key_exists( $key, $this->loaded ) ) { if ( count( $this->loaded ) >= 4 ) { array_shift( $this->loaded ); } $candidate = get_option( $key, array() ); $this->loaded[ $key ] = $this->fresh( $candidate, $component ) ? $candidate : null; if ( is_array( $this->loaded[ $key ] ) ) { $digest = ScanProvenance::referenceDigest( array( 'identity' => $candidate['identity_hash'], 'files' => $candidate['files'] ) ); // Retain a seen change even if a manifest changes back inside one batch. $before = $this->referenceDigests[ $key ] ?? $digest; $this->referenceDigests[ $key ] = hash_equals( $before, $digest ) ? $digest : 'changed'; } } return is_array( $this->loaded[ $key ] ) && time() - $this->loaded[ $key ]['fetched'] < 86400 ? $this->loaded[ $key ] : null; } private function reference( array $read, array $prepared ): ?array { $path = (string) ( $read['path'] ?? '' ); foreach ( $prepared['components'] ?? array() as $component ) { $root = (string) ( $component['root'] ?? '' ); if ( ! $root || $path === $root || ! PathGuard::beneath( $path, $root ) ) { continue; } $manifest = $this->manifest( $component ); if ( ! $manifest ) { return null; } $relative = substr( $path, strlen( $root ) + 1 ); PathGuard::relative( $relative ); return array( 'component' => $component, 'manifest' => $manifest, 'relative' => $relative, 'hash' => $manifest['files'][ $relative ] ?? null, ); } return null; } public function assessedFile( array $read, array $context, array $prepared ): bool { return null !== $this->reference( $read, $prepared ); } public function inspectFile( array $read, array $context, array $prepared ): ?array { $ref = $this->reference( $read, $prepared ); if ( ! $ref ) { return null; } $expected = $ref['hash']; $actual = null; if ( $expected ) { if ( isset( $read['content'] ) && is_string( $read['content'] ) ) { $actual = hash( $expected['algorithm'], $read['content'] ); } else { // Only the scanner's verified hash-only snapshot uses this branch. $algorithm = $expected['algorithm']; $length = array( 'md5' => 32, 'sha256' => 64 )[ $algorithm ] ?? 0; $actual = $read['digests'][ $algorithm ] ?? null; if ( ! $length || ! is_string( $actual ) || ! preg_match( '/^[a-f0-9]{' . $length . '}$/D', $actual ) || true !== ( $read['structural_limit'] ?? null ) ) { throw new \RuntimeException( 'A verified complete file digest is required for integrity comparison.' ); } } } if ( $expected && in_array( $actual, $expected['hashes'], true ) ) { return null; } return $this->finding( $ref['component'], (string) $read['path'], (string) $read['hash'], $expected ? 'The file differs from the authenticated repository manifest for this owner-confirmed component version.' : 'The file is absent from the authenticated repository manifest; a locally created file can be legitimate.', array( array( 'type' => $expected ? 'checksum_mismatch' : 'unexpected_repository_file', 'relative_path' => $ref['relative'], 'algorithm' => $expected['algorithm'] ?? null, 'expected' => $expected['hashes'] ?? array(), 'observed' => $actual, ), ), $context ); } /** Inspect missing reference files without walking all paths in one request. */ public function missingFiles( array $prepared, int $cursor = 0, int $limit = 25 ): array { $result = array( 'findings' => array(), 'limitations' => array(), 'cursor' => max( 0, $cursor ), 'done' => true, ); $limit = max( 1, min( 25, $limit ) ); $index = 0; $checked = 0; foreach ( $prepared['components'] ?? array() as $component ) { if ( $index + (int) ( $component['file_count'] ?? 0 ) <= $cursor ) { $index += (int) ( $component['file_count'] ?? 0 ); continue; } $manifest = $this->manifest( $component ); if ( ! $manifest ) { if ( 'reference_available' === ( $component['status'] ?? '' ) ) { $result['limitations'][] = 'Repository reference expired during the scan; rescan to refresh it.'; } continue; } foreach ( $manifest['files'] as $relative => $hash ) { if ( $index++ < $cursor ) { continue; } if ( $checked++ >= $limit ) { $result['done'] = false; return $result; } $result['cursor'] = $index; $path = $component['root'] . '/' . $relative; try { PathGuard::noLinks( $component['root'] ); $part = $component['root']; $missing = false; foreach ( explode( '/', $relative ) as $segment ) { $part .= '/' . $segment; clearstatcache( true, $part ); $stat = @lstat( $part ); if ( ! $stat ) { $missing = true; break; } if ( 0120000 === ( $stat['mode'] & 0170000 ) ) { throw new \RuntimeException( 'Reference path is symlinked.' ); } } if ( ! $missing ) { continue; } } catch ( \Throwable $e ) { $result['limitations'][] = 'Repository reference path changed or is symlinked during the missing-file check.'; continue; } $result['findings'][] = $this->finding( $component, $path, 'missing', 'A file listed in the authenticated repository manifest is absent. Removal may be intentional; this does not establish malware.', array( array( 'type' => 'missing_repository_file', 'relative_path' => $relative, 'algorithm' => $hash['algorithm'], 'expected' => $hash['hashes'], ), ), array() ); } } return $result; } private function finding( array $component, string $path, string $contentHash, string $reason, array $evidence, array $context ): array { $scope = array_merge( is_array( $context['scope'] ?? null ) ? $context['scope'] : array(), array( 'component' => $component['type'] ?? 'plugin', 'slug' => $component['slug'], 'identity_basis' => $component['identity_basis'], ) ); foreach ( array( 'root_id', 'root', 'relative_path' ) as $key ) { if ( isset( $context[ $key ] ) ) { $scope[ $key ] = $context[ $key ]; } } if ( $context ) { $scope['type'] = 'file'; } return Finding::make( array( 'scan_id' => (string) ( $context['scan_id'] ?? '' ), 'category' => 'integrity_change', 'location' => (string) ( $context['location'] ?? $path ), 'detector_id' => 'integrity.repository', 'detector_version' => '1.0.0', 'severity' => 'medium', 'confidence' => 'strong', 'reason' => $reason, 'root_id' => (string) ( $context['root_id'] ?? '' ), 'root' => (string) ( $context['root'] ?? '' ), 'relative_path' => (string) ( $context['relative_path'] ?? '' ), 'limitations' => array( 'A checksum difference or missing file does not prove infection. Owner edits and compromised original releases remain possible.', 'Only existing reference-listed files can be repaired after owner approval and host recovery checks. Missing/unexpected files need separate review. The referenced version may itself have known vulnerabilities.' ), 'recommended_action' => 'Review the difference and advisory. Preview an eligible matching-version replacement or use the vendor update/reinstall workflow after preserving recovery material.', 'evidence' => $evidence, 'source' => $component['source'], 'scope' => $scope, 'rule_freshness' => array( 'fetched_at' => $component['source']['fetched_at'], 'expires_at' => $component['source']['expires_at'], ), 'content_hash' => $contentHash, 'component_version' => $component['version'], 'action_eligibility' => array( 'repair' => 'checksum_mismatch' === ( $evidence[0]['type'] ?? '' ) && (bool) $context, 'reason' => 'Existing reference-listed files may be replaced after exact identity revalidation, owner approval and protected recovery checks. Missing or unexpected files are never automatically created or removed.', 'reference' => 'checksum_mismatch' === ( $evidence[0]['type'] ?? '' ) && $context ? array( 'type' => $component['type'] ?? 'plugin', 'slug' => $component['slug'], 'file' => $component['file'], 'name' => $component['name'], 'version' => $component['version'], 'component_root' => $component['root'], 'identity_hash' => $component['identity_hash'], 'path' => $evidence[0]['relative_path'], 'expected' => array( 'algorithm' => $evidence[0]['algorithm'], 'hashes' => $evidence[0]['expected'] ) ) : null, ), ) ); } private function prune( array $components ): void { $keys = array_values( array_filter( array_column( $components, 'reference_option' ) ) ); $previous = get_option( 'antivirus_ai_repository_cache_index', array() ); foreach ( is_array( $previous ) ? array_slice( $previous, 0, self::MAX_PLUGINS ) : array() as $key ) { if ( is_string( $key ) && preg_match( '/^antivirus_ai_repository_[a-f0-9]{64}$/D', $key ) && ! in_array( $key, $keys, true ) ) { delete_option( $key ); } } update_option( 'antivirus_ai_repository_cache_index', $keys, false ); } }