array(), 'limitations' => array(), 'status' => 'disabled', 'assessed_components' => array(), 'freshness' => array( 'provider' => 'Wordfence Intelligence', 'status' => 'unavailable', 'fetched_at' => null, ), ); try { if ( empty( self::currentOption( 'antivirus_ai_settings', array() )['vulnerability_feed'] ) ) { $result['limitations'][] = 'Online vulnerability checks are disabled. Local scanning does not determine whether installed components have known vulnerabilities.'; return $result; } $snapshot = self::withStateLock( function (): array { $key = class_exists( FeedConnection::class ) ? FeedConnection::key() : getenv( 'ANTIVIRUS_AI_WORDFENCE_KEY' ); if ( ! is_string( $key ) || ! preg_match( '/^[\x21-\x7e]{8,512}$/D', $key ) ) { throw new \RuntimeException( 'Wordfence is not connected. Open Protection to save your permitted feed token, or ask your host to configure it.' ); } $generation = class_exists( FeedConnection::class ) ? FeedConnection::generation() : ''; $this->authorize( $key, $generation ); return array( 'key' => $key, 'generation' => $generation, 'binding' => hash_hmac( 'sha256', $key, function_exists( 'wp_salt' ) ? wp_salt( 'auth' ) : __CLASS__ ) ); } ); [$components, $limitations] = $this->components( $inventory ); $result['limitations'] = $limitations; $fingerprint = hash( 'sha256', json_encode( $components, JSON_THROW_ON_ERROR ) ); $cache = self::withStateLock( function () use ( $snapshot ): array { $this->authorize( $snapshot['key'], $snapshot['generation'] ); $value = self::currentOption( self::CACHE, array() ); return is_array( $value ) ? $value : array(); } ); $usable = self::validCache( $cache, $components, $fingerprint ) && ( $cache['feed_generation'] ?? null ) === $snapshot['generation'] && is_string( $cache['connection_binding'] ?? null ) && hash_equals( $snapshot['binding'], $cache['connection_binding'] ); $current = $usable && time() - $cache['fetched'] < 86400; if ( ! $current ) { try { self::withStateLock( function () use ( $snapshot ): void { $this->authorize( $snapshot['key'], $snapshot['generation'] ); $attempt = self::currentOption( 'antivirus_ai_vulnerability_attempt', array() ); if ( is_array( $attempt ) && (int) ( $attempt['at'] ?? 0 ) > time() - 3600 ) { throw new \RuntimeException( 'Vulnerability refresh is in its one-hour retry backoff after an unavailable or changed feed.' ); } self::saveOption( 'antivirus_ai_vulnerability_attempt', array( 'at' => time() ) ); } ); // Network and bounded parsing are deliberately outside the state lock. $parsed = $this->download( $snapshot['key'], $components, $snapshot['generation'] ); $candidate = array( 'schema_version' => 1, 'inventory_hash' => $fingerprint, 'fetched' => time(), 'records' => $parsed['records'], 'limitations' => $parsed['limitations'], 'complete' => $parsed['complete'], 'record_count' => $parsed['record_count'], 'feed_generation' => $snapshot['generation'], 'connection_binding' => $snapshot['binding'] ); if ( strlen( json_encode( $candidate, JSON_THROW_ON_ERROR ) ) > 1048576 ) { throw new \RuntimeException( 'Relevant vulnerability data exceeds the one-MiB cache budget.' ); } self::withStateLock( function () use ( $snapshot, $candidate ): void { $this->authorize( $snapshot['key'], $snapshot['generation'] ); self::saveOption( self::CACHE, $candidate ); } ); $cache = $candidate; $usable = true; $current = true; } catch ( \Throwable $error ) { $result['limitations'][] = $error->getMessage(); } } // Disconnect, token replacement and permission changes share this short // boundary. A withdrawn in-flight operation cannot fall back to old data. return self::withStateLock( function () use ( $snapshot, $cache, $components, $result, $usable, $current ): array { $this->authorize( $snapshot['key'], $snapshot['generation'] ); if ( ! $usable ) { $result['status'] = 'unavailable'; return $result; } return $this->assessCache( $cache, $components, $result, $current ); } ); } catch ( \Throwable $error ) { $result['findings'] = array(); $result['assessed_components'] = array(); $result['status'] = 'unavailable'; try { if ( empty( self::currentOption( 'antivirus_ai_settings', array() )['vulnerability_feed'] ) ) { $result['status'] = 'disabled'; } } catch ( \Throwable $ignored ) {} $result['limitations'][] = $error->getMessage(); return $result; } } private static function withStateLock( callable $callback ): mixed { return class_exists( FeedConnection::class ) ? FeedConnection::withLock( $callback ) : $callback(); } private static function currentOption( string $name, mixed $default = false ): mixed { return method_exists( Store::class, 'freshOption' ) ? Store::freshOption( $name, $default ) : get_option( $name, $default ); } private static function saveOption( string $name, mixed $value ): void { if ( method_exists( Store::class, 'saveOption' ) ) { Store::saveOption( $name, $value ); } else { update_option( $name, $value, false ); } } private function assessCache( array $cache, array $components, array $result, bool $current ): array { $result['freshness'] = array( 'provider' => 'Wordfence Intelligence', 'status' => $current ? 'current' : 'stale', 'fetched_at' => gmdate( 'c', $cache['fetched'] ), 'expires_at' => gmdate( 'c', $cache['fetched'] + 86400 ), 'source' => self::ENDPOINT, 'meaning' => 'Time this complete feed was retrieved, not a guarantee that every advisory was recently reviewed.', 'transport' => 'HTTPS certificate validation; the provider feed has no independent signature.', ); $result['limitations'] = array_merge( $result['limitations'], $cache['limitations'] ?? array() ); if ( ! $current ) { $result['limitations'][] = 'Using stale last-known data for the same inventory. New or changed advisories may be missing; absence of matches is not a current vulnerability assessment.'; } foreach ( $cache['records'] as $record ) { $component = $components[ $record['component_key'] ] ?? null; if ( ! $component ) { continue; } $matched = array(); $unknown = false; foreach ( $record['affected_versions'] as $range ) { $match = self::versionInRange( $component['version'], $range ); if ( null === $match ) { $unknown = true; } elseif ( $match ) { $matched[] = $range; } } if ( $unknown ) { $result['limitations'][] = $component['location'] . ': advisory ' . $record['id'] . ' includes a prerelease or unsupported version interval; that interval remains unassessed.'; } if ( ! $matched ) { continue; } $result['findings'][] = $this->finding( $component, $record, $matched, $result['freshness'] ); } $result['limitations'] = array_slice( array_values( array_unique( $result['limitations'] ) ), 0, 150 ); $result['status'] = ! $current ? 'stale' : ( ( ! empty( $result['limitations'] ) || empty( $cache['complete'] ) ) ? 'partial' : 'available' ); if ( 'available' === $result['status'] ) { foreach ( $components as $component ) { $result['assessed_components'][ $component['location'] ] = $component['version']; } } return $result; } /** Persisted option data must satisfy the same evidence contract as a fresh feed. */ private static function validCache( $cache, array $components, string $fingerprint ): bool { if ( ! is_array( $cache ) || 1 !== ( $cache['schema_version'] ?? null ) || ( $cache['inventory_hash'] ?? '' ) !== $fingerprint || ! is_int( $cache['fetched'] ?? null ) || $cache['fetched'] > time() || time() - $cache['fetched'] >= 604800 || ! is_array( $cache['records'] ?? null ) || ! array_is_list( $cache['records'] ) || count( $cache['records'] ) > self::MAX_MATCHES || ! is_array( $cache['limitations'] ?? null ) || count( $cache['limitations'] ) > 100 || ! is_bool( $cache['complete'] ?? null ) || ! is_int( $cache['record_count'] ?? null ) || $cache['record_count'] < 1 || $cache['record_count'] > 100000 ) { return false; } try { if ( strlen( json_encode( $cache, JSON_THROW_ON_ERROR ) ) > 1048576 ) { return false; } foreach ( $cache['limitations'] as $limitation ) { if ( ! is_string( $limitation ) || strlen( $limitation ) > 2048 ) { return false; } } $normalized = array(); $limitations = array(); foreach ( $cache['records'] as $record ) { if ( ! is_array( $record ) || ! is_string( $record['id'] ?? null ) || ! preg_match( '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/Di', $record['id'] ) || ! is_string( $record['component_key'] ?? null ) || ! isset( $components[ $record['component_key'] ] ) ) { return false; } $component = $components[ $record['component_key'] ]; self::normalize( array_merge( $record, array( 'software' => array( array( 'type' => $component['type'], 'slug' => $component['slug'], 'affected_versions' => $record['affected_versions'] ?? null, 'patched' => $record['patched'] ?? null, 'patched_versions' => $record['patched_versions'] ?? null, ) ) ) ), $components, $normalized, $limitations ); $identity = $record['id'] . ':' . $record['component_key']; if ( ! isset( $normalized[ $identity ] ) || count( $record ) !== count( $normalized[ $identity ] ) ) { return false; } foreach ( $normalized[ $identity ] as $field => $value ) { if ( ! array_key_exists( $field, $record ) || $record[ $field ] !== $value ) { return false; } } } return count( $normalized ) === count( $cache['records'] ) && ( ! $cache['complete'] || ! $limitations ); } catch ( \Throwable $error ) { return false; } } private function components( array $inventory ): array { $components = array(); $limitations = array(); $core = (string) ( $inventory['core']['version'] ?? '' ); if ( self::stableVersion( $core ) ) { $components['core:wordpress'] = array( 'type' => 'core', 'slug' => 'wordpress', 'version' => $core, 'location' => 'core:wordpress', 'identity_basis' => 'WordPress core runtime version; host compromise can falsify local metadata.', ); } else { $limitations[] = 'WordPress core version is prerelease or unsupported; no vulnerable/clean version verdict is inferred.'; } $confirmations = get_option( 'antivirus_ai_repository_confirmations', array() ); $confirmations = is_array( $confirmations ) ? $confirmations : array(); foreach ( array( 'plugin' => 'plugins', 'theme' => 'themes', ) as $type => $key ) { $items = is_array( $inventory[ $key ] ?? null ) ? $inventory[ $key ] : array(); $counts = array(); foreach ( $items as $item ) { $slug = (string) ( $item['slug'] ?? '' ); $counts[ $slug ] = ( $counts[ $slug ] ?? 0 ) + 1; } foreach ( array_slice( $items, 0, 100 ) as $item ) { $slug = (string) ( $item['slug'] ?? '' ); $location = $type . ':' . (string) ( 'plugin' === $type ? ( $item['file'] ?? '' ) : $slug ); $identity = RepositoryIntegrity::identity( $item, $type, $confirmations ); if ( ! $identity['verified'] || 1 !== ( $counts[ $slug ] ?? 0 ) ) { $limitations[] = $location . ': identity unverified or ambiguous; slug-only vulnerability matching was refused.'; continue; } $components[ $type . ':' . $slug ] = array( 'type' => $type, 'slug' => $slug, 'version' => (string) $item['version'], 'location' => $location, 'identity_basis' => $identity['basis'], ); } if ( count( $items ) > 100 ) { $limitations[] = 'Vulnerability identity limit of 100 ' . $key . ' reached.'; } } if ( ! empty( $inventory['mu_plugins'] ) || ! empty( $inventory['drop_ins'] ) ) { $limitations[] = 'Must-use plugins and drop-ins are not mapped to vulnerability feed identities.'; } ksort( $components, SORT_STRING ); return array( $components, $limitations ); } /** Keep optional feed bytes off disk; native HTTP transports retain bounded response copies. */ private static function memoryLimit(): int { $value = ini_get( 'memory_limit' ); if ( false === $value || '-1' === trim( $value ) ) { return PHP_INT_MAX; } $limit = wp_convert_hr_to_bytes( $value ); if ( $limit <= 0 ) { throw new \RuntimeException( 'The PHP memory limit could not be determined safely for the optional vulnerability feed.' ); } return $limit; } private static function responseBudget(): int { // Reserve 24 MiB for incremental decoding and WordPress work, plus room // for three response-sized buffers while native transports parse HTTP. $available = self::memoryLimit() - memory_get_usage( true ) - 25165824; $limit = min( self::MAX_BYTES, max( 0, intdiv( $available, 3 ) ) ); if ( $limit < 1048576 ) { throw new \RuntimeException( 'The optional vulnerability feed needs more available PHP memory. Local scanning remains available; no complete feed result was inferred.' ); } return $limit; } private function download( string $key, array $components, ?string $generation = null ): array { $this->authorize( $key, $generation ); $limit = self::responseBudget(); $marker = bin2hex( random_bytes( 16 ) ); $markerHeader = 'X-Shieldlume-Feed'; $prepared = false; $prepare = static function ( &$url, &$headers, &$data, &$method, &$options ) use ( $marker, $markerHeader, &$prepared ): void { if ( ! is_array( $headers ) || ( $headers[ $markerHeader ] ?? null ) !== $marker ) { return; } unset( $headers[ $markerHeader ] ); if ( self::ENDPOINT !== $url || ! ( ( $options['hooks'] ?? null ) instanceof \WP_HTTP_Requests_Hooks ) ) { throw new \RuntimeException( 'The vulnerability feed request lost its native HTTP safety context.' ); } $prepared = true; $curl = null; $options['hooks']->register( 'curl.before_send', static function ( $handle ) use ( &$curl ): void { $curl = $handle; }, PHP_INT_MAX ); $options['hooks']->register( 'curl.after_send', static function () use ( &$curl ): void { // Requests retries WRITE_ERROR/BAD_CONTENT_ENCODING internally. A // failed authenticated feed request must not silently be sent twice. // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_errno -- Inspect WordPress's own handle only; WordPress dispatches and owns the request. if ( null !== $curl && 0 !== curl_errno( $curl ) ) { throw new \WpOrg\Requests\Exception( 'The vulnerability feed transport failed; no automatic retry was performed.', 'shieldlume_feed_transport' ); } }, PHP_INT_MAX ); $options['hooks']->register( 'requests.before_parse', static function ( &$raw ): void { // Requests can decompress even when WordPress decompress=false. // Inspect only bounded headers before its parser sees an encoding. $end = is_string( $raw ) ? strpos( $raw, "\r\n\r\n" ) : false; if ( false === $end || $end > 16384 ) { throw new \RuntimeException( 'Encoded or unsupported vulnerability feed headers were refused before decoding.' ); } preg_match_all( '/(?:^|\r\n)content-encoding\s*:[^\r\n]*(?:\r\n[ \t]+[^\r\n]*)*/i', substr( $raw, 0, $end ), $encodings, PREG_OFFSET_CAPTURE ); if ( $encodings[0] ) { $line = $encodings[0][0][0]; if ( 1 !== count( $encodings[0] ) || 'identity' !== strtolower( trim( substr( $line, strpos( $line, ':' ) + 1 ) ) ) ) { throw new \RuntimeException( 'Encoded or ambiguous vulnerability feed headers were refused before decoding.' ); } // Native Requests attempts decompression for any encoding header, // even identity. Remove that exact header before its parser runs. $raw = substr_replace( $raw, '', $encodings[0][0][1], strlen( $line ) ); } }, PHP_INT_MAX ); }; add_action( 'requests-requests.before_request', $prepare, PHP_INT_MAX, 5 ); try { // Constant trusted origin, no caller URL; no redirects, files or cookies. $response = wp_remote_get( self::ENDPOINT, array( 'timeout' => 5, 'redirection' => 0, 'sslverify' => true, 'limit_response_size' => $limit, 'stream' => false, 'decompress' => false, 'cookies' => array(), 'user-agent' => 'Shieldlume Security/0.1', 'headers' => array( 'Authorization' => 'Bearer ' . $key, 'Accept' => 'application/json', 'Accept-Encoding' => 'identity', $markerHeader => $marker, ), ) ); if ( is_wp_error( $response ) ) { throw new \RuntimeException( 'Wordfence feed request failed. Local findings remain available; no clean verdict is inferred.' ); } if ( ! $prepared ) { throw new \RuntimeException( 'The vulnerability feed response did not pass the native HTTP decoding guard. No complete result was inferred.' ); } $code = wp_remote_retrieve_response_code( $response ); if ( 200 !== $code ) { throw new \RuntimeException( 'Wordfence feed unavailable (HTTP ' . (int) $code . '); credentials, quota or service availability require review.' ); } if ( '' !== (string) wp_remote_retrieve_header( $response, 'content-encoding' ) ) { throw new \RuntimeException( 'Encoded feed responses are refused to enforce the in-memory byte budget.' ); } $body = wp_remote_retrieve_body( $response ); unset( $response ); // Release the native raw HTTP response before incremental parsing. if ( ! is_string( $body ) || strlen( $body ) >= $limit ) { throw new \RuntimeException( 'Wordfence feed response reached its available-memory or 32-MiB byte budget. No complete feed result was inferred.' ); } $this->authorize( $key, $generation ); return self::parseFeed( $body, $components, microtime( true ) + 2.0 ); } finally { remove_action( 'requests-requests.before_request', $prepare, PHP_INT_MAX ); } } private function authorize( string $key, ?string $generation = null ): void { $settings = method_exists( Store::class, 'freshOption' ) ? Store::freshOption( 'antivirus_ai_settings', array() ) : get_option( 'antivirus_ai_settings', array() ); if ( empty( $settings['vulnerability_feed'] ) ) { throw new \RuntimeException( 'Vulnerability permission was withdrawn. This result will not be saved.' ); } if ( null !== $generation && class_exists( FeedConnection::class ) && ! hash_equals( $generation, FeedConnection::generation() ) ) { throw new \RuntimeException( 'Feed permission or connection changed during this operation. Start another check.' ); } $current = class_exists( FeedConnection::class ) ? FeedConnection::key() : getenv( 'ANTIVIRUS_AI_WORDFENCE_KEY' ); if ( ! is_string( $current ) || ! hash_equals( $key, $current ) ) { throw new \RuntimeException( 'Feed access changed during this operation. Retry using the current connection.' ); } } /** Read one top-level JSON record at a time; no whole-feed json_decode or executable data. */ public static function parseFeed( $stream, array $components, ?float $deadline = null ): array { if ( ! is_resource( $stream ) && ! is_string( $stream ) ) { throw new \InvalidArgumentException( 'A feed byte string or stream is required.' ); } $length = is_string( $stream ) ? strlen( $stream ) : null; $offset = 0; $memoryLimit = self::memoryLimit(); $state = 'root'; $key = ''; $value = ''; $quoted = false; $escaped = false; $depth = 0; $bytes = 0; $count = 0; $seen = array(); $records = array(); $limitations = array(); while ( null !== $length ? $offset < $length : ! feof( $stream ) ) { if ( memory_get_usage( true ) > $memoryLimit - 25165824 ) { throw new \RuntimeException( 'The vulnerability parser reached its available-memory reserve. Partial data is not a complete feed.' ); } // A string slice creates at most 8 KiB, never another whole-feed buffer. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- The retained stream API reads caller-supplied streams only; downloads no longer create or write a staging file. $chunk = null !== $length ? substr( $stream, $offset, 8192 ) : fread( $stream, 8192 ); $offset += strlen( $chunk ?: '' ); if ( ! is_string( $chunk ) ) { throw new \RuntimeException( 'Feed read failed.' ); } $bytes += strlen( $chunk ); if ( $bytes >= self::MAX_BYTES || ( null !== $deadline && microtime( true ) > $deadline ) ) { throw new \RuntimeException( 'Feed byte or processing-time budget reached; partial data is not used as a complete feed.' ); } for ( $i = 0, $n = strlen( $chunk ); $i < $n; ++$i ) { $char = $chunk[ $i ]; if ( 'value' === $state ) { $value .= $char; if ( strlen( $value ) > self::MAX_RECORD_BYTES ) { throw new \RuntimeException( 'Feed record exceeds the per-record budget.' ); } if ( $quoted ) { if ( $escaped ) { $escaped = false; } elseif ( '\\' === $char ) { $escaped = true; } elseif ( '"' === $char ) { $quoted = false; } } elseif ( '"' === $char ) { $quoted = true; } elseif ( '{' === $char || '[' === $char ) { if ( ++$depth > 20 ) { throw new \RuntimeException( 'Feed nesting budget exceeded.' ); } } elseif ( '}' === $char || ']' === $char ) { if ( 0 === --$depth ) { if ( ++$count > 100000 ) { throw new \RuntimeException( 'Feed record-count budget exceeded.' ); } $record = json_decode( $value, true, 24, JSON_THROW_ON_ERROR ); if ( ! is_array( $record ) || ( $record['id'] ?? '' ) !== $key || isset( $seen[ $key ] ) ) { throw new \RuntimeException( 'Feed record identity is invalid or duplicated.' ); } $seen[ $key ] = true; self::normalize( $record, $components, $records, $limitations ); if ( count( $records ) > self::MAX_MATCHES ) { throw new \RuntimeException( 'Relevant vulnerability record budget exceeded; no complete assessment is available.' ); } $value = ''; $state = 'separator'; } } continue; } if ( 'key' === $state ) { if ( '"' === $char ) { if ( ! preg_match( '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/Di', $key ) ) { throw new \RuntimeException( 'Invalid vulnerability identifier.' ); } $state = 'colon'; } elseif ( strlen( $key ) >= 36 || ! str_contains( 'abcdefABCDEF0123456789-', $char ) ) { throw new \RuntimeException( 'Invalid vulnerability key.' ); } else { $key .= $char; } continue; } if ( str_contains( " \t\r\n", $char ) ) { continue; } if ( 'root' === $state && '{' === $char ) { $state = 'first_key'; } elseif ( ( 'first_key' === $state || 'next_key' === $state ) && '"' === $char ) { $key = ''; $state = 'key'; } elseif ( 'first_key' === $state && '}' === $char ) { $state = 'end'; } elseif ( 'colon' === $state && ':' === $char ) { $state = 'value_start'; } elseif ( 'value_start' === $state && '{' === $char ) { $state = 'value'; $depth = 1; $quoted = false; $escaped = false; $value = '{'; } elseif ( 'separator' === $state && ',' === $char ) { $state = 'next_key'; } elseif ( 'separator' === $state && '}' === $char ) { $state = 'end'; } else { throw new \RuntimeException( 'Feed JSON framing is invalid or truncated.' ); } } } if ( 'end' !== $state || 0 === $count ) { throw new \RuntimeException( 'Feed is empty or truncated; no clean verdict inferred.' ); } return array( 'records' => array_values( $records ), 'limitations' => array_slice( array_values( array_unique( $limitations ) ), 0, 100 ), 'complete' => ! $limitations, 'record_count' => $count, ); } private static function normalize( array $record, array $components, array &$records, array &$limitations ): void { if ( ! is_array( $record['software'] ?? null ) || ! $record['software'] || count( $record['software'] ) > 500 ) { throw new \RuntimeException( 'Unsupported vulnerability software schema.' ); } foreach ( $record['software'] as $software ) { if ( ! is_array( $software ) || ! is_string( $software['type'] ?? null ) || ! is_string( $software['slug'] ?? null ) ) { throw new \RuntimeException( 'Invalid vulnerability component.' ); } $componentKey = $software['type'] . ':' . $software['slug']; // Match exact feed identities; never guess aliases from display names. if ( ! isset( $components[ $componentKey ] ) ) { continue; } if ( ! is_string( $record['title'] ?? null ) || strlen( $record['title'] ) > 500 || ! is_bool( $record['informational'] ?? null ) || ! is_array( $software['affected_versions'] ?? null ) || ! $software['affected_versions'] || count( $software['affected_versions'] ) > 128 || ! is_bool( $software['patched'] ?? null ) || ! is_array( $software['patched_versions'] ?? null ) || count( $software['patched_versions'] ) > 64 ) { throw new \RuntimeException( 'Relevant vulnerability record has unsupported fields.' ); } $ranges = array(); foreach ( $software['affected_versions'] as $range ) { if ( ! is_array( $range ) || ! is_string( $range['from_version'] ?? null ) || ! is_string( $range['to_version'] ?? null ) || strlen( $range['from_version'] ) > 64 || strlen( $range['to_version'] ) > 64 || ! is_bool( $range['from_inclusive'] ?? null ) || ! is_bool( $range['to_inclusive'] ?? null ) ) { throw new \RuntimeException( 'Malformed vulnerability version interval.' ); } $ranges[] = array_intersect_key( $range, array_flip( array( 'from_version', 'from_inclusive', 'to_version', 'to_inclusive' ) ) ); if ( null === self::versionInRange( $components[ $componentKey ]['version'], $range ) ) { $limitations[] = $componentKey . ': unsupported advisory version interval in ' . $record['id'] . '.'; } } foreach ( $software['patched_versions'] as $version ) { if ( ! is_string( $version ) || strlen( $version ) > 64 ) { throw new \RuntimeException( 'Malformed patched-version data.' ); } } $references = $record['references'] ?? ( $software['references'] ?? array() ); if ( ! is_array( $references ) || count( $references ) > 30 ) { throw new \RuntimeException( 'Malformed advisory references.' ); } foreach ( $references as $url ) { if ( ! is_string( $url ) || strlen( $url ) > 2048 || ! preg_match( '~^https?://[^\s\x00-\x1f]+$~D', $url ) ) { throw new \RuntimeException( 'Malformed advisory link.' ); } } if ( ! array_filter( $references, static fn( $url ) => preg_match( '~^https?://(?:www\.)?wordfence\.com/threat-intel/vulnerabilities/~D', $url ) ) ) { throw new \RuntimeException( 'Relevant record lacks a source-advisory hyperlink required for attribution.' ); } $copyrights = $record['copyrights'] ?? null; if ( null !== $copyrights && ( ! is_array( $copyrights ) || strlen( json_encode( $copyrights, JSON_THROW_ON_ERROR ) ) > 16384 ) ) { throw new \RuntimeException( 'Copyright data cannot be preserved within the record budget.' ); } // Keep the exact source notices/license data. Nothing is bundled or stripped for resale. $normalized = array( 'id' => $record['id'], 'component_key' => $componentKey, 'title' => $record['title'], 'informational' => $record['informational'], 'affected_versions' => $ranges, 'patched' => $software['patched'], 'patched_versions' => array_values( $software['patched_versions'] ), 'references' => $references, 'copyrights' => $copyrights, 'published' => is_string( $record['published'] ?? null ) ? substr( $record['published'], 0, 40 ) : null, ); if ( strlen( json_encode( $normalized, JSON_THROW_ON_ERROR ) ) > 24576 ) { throw new \RuntimeException( 'Relevant advisory exceeds the evidence budget; refusing to remove attribution.' ); } $identity = $record['id'] . ':' . $componentKey; if ( isset( $records[ $identity ] ) ) { throw new \RuntimeException( 'Duplicate software identity in vulnerability record.' ); } $records[ $identity ] = $normalized; } } public static function stableVersion( string $version ): bool { return (bool) preg_match( '/^[0-9]{1,8}(?:\.[0-9]{1,8}){0,5}$/D', $version ); } /** null means unknown, never an unaffected result. Only finite numeric releases are ordered. */ public static function versionInRange( string $version, array $range ): ?bool { if ( ! self::stableVersion( $version ) || ! is_string( $range['from_version'] ?? null ) || ! is_string( $range['to_version'] ?? null ) || ! is_bool( $range['from_inclusive'] ?? null ) || ! is_bool( $range['to_inclusive'] ?? null ) ) { return null; } $from = $range['from_version']; $to = $range['to_version']; if ( ( '*' !== $from && ! self::stableVersion( $from ) ) || ( '*' !== $to && ! self::stableVersion( $to ) ) ) { return null; } if ( '*' !== $from && '*' !== $to && self::compare( $from, $to ) > 0 ) { return null; } $lower = '*' === $from ? 1 : self::compare( $version, $from ); $upper = '*' === $to ? -1 : self::compare( $version, $to ); return ( $lower > 0 || ( 0 === $lower && $range['from_inclusive'] ) ) && ( $upper < 0 || ( 0 === $upper && $range['to_inclusive'] ) ); } private static function compare( string $left, string $right ): int { $a = explode( '.', $left ); $b = explode( '.', $right ); for ( $i = 0, $n = max( count( $a ), count( $b ) ); $i < $n; ++$i ) { $comparison = (int) ( $a[ $i ] ?? 0 ) <=> (int) ( $b[ $i ] ?? 0 ); if ( $comparison ) { return $comparison; } } return 0; } private function finding( array $component, array $record, array $matched, array $freshness ): array { $advisory = ''; foreach ( $record['references'] as $url ) { if ( preg_match( '~^https?://(?:www\.)?wordfence\.com/threat-intel/vulnerabilities/~D', $url ) ) { $advisory = $url; break; } } if ( '' === $advisory ) { $advisory = $record['references'][0] ?? 'https://www.wordfence.com/threat-intel/'; } return Finding::make( array( 'category' => 'vulnerable_software', 'location' => $component['location'], 'detector_id' => 'vulnerability.wordfence.' . $record['id'], 'detector_version' => '3.0.0', 'severity' => $record['informational'] ? 'info' : 'medium', 'confidence' => 'strong', 'reason' => $record['title'] . ' — installed version ' . $component['version'] . ' matches the advisory range.', 'limitations' => array( 'This establishes reported software exposure, not infection or exploitability on this site.', 'The scanner feed does not provide CVSS severity; medium is the local review priority for non-informational records.', 'Component identity was checked conservatively; local metadata can be falsified by a compromised host.', 'stale' === $freshness['status'] ? 'The feed is stale; the advisory may have changed.' : 'Feed retrieval freshness does not guarantee advisory completeness.' ), 'recommended_action' => $record['patched'] ? 'Review the linked advisory and vendor release notes, back up, then update to a listed patched or later confirmed-safe release. A same-version integrity repair does not patch the vulnerability.' : 'No patched version is listed. Review the vendor advisory and consider disabling or replacing the component after assessing business impact.', 'evidence' => array( array( 'type' => 'affected_version_match', 'installed_version' => $component['version'], 'affected_versions' => $matched, 'patched' => $record['patched'], 'patched_versions' => $record['patched_versions'], 'published' => $record['published'], ), ), 'source' => array( 'provider' => 'Wordfence Intelligence', 'feed' => self::ENDPOINT, 'advisory_id' => $record['id'], 'advisory_url' => $advisory, 'references' => $record['references'], 'copyrights' => $record['copyrights'], ), 'scope' => array( 'component' => $component['type'], 'slug' => $component['slug'], 'identity_basis' => $component['identity_basis'], ), 'rule_freshness' => $freshness, 'component_version' => $component['version'], 'content_hash' => hash( 'sha256', json_encode( array( $component, $record ), JSON_THROW_ON_ERROR ) ), 'action_eligibility' => array( 'repair' => false, 'reason' => 'A vulnerability requires a separately reviewed software update or mitigation; file replacement does not patch a same-version release.', 'reference' => null, ), ) ); } }