'wpv-plugin', 'theme' => 'wpv-theme', 'core' => 'wpv-core', ); /** * Server software beneath WordPress that WPVulnerability publishes a feed for. * * Type => display name. A component of one of these types is looked up through * the proxy's wpv-software source by product and version, and it never falls * through to NVD: a keyword search for "php" or "mysql" returns thousands of * unrelated records and would be reported as findings. The list mirrors * VULN_SOFTWARE_PRODUCTS on the proxy; a type missing there is refused with a 400, * which the caller reports as unchecked rather than clean. * * Every one of these is something a WordPress update cannot fix. The finding is * for the host, the package manager or the control panel, and the fix text says so. */ const SERVER_SOFTWARE = array( 'php' => 'PHP', 'mysql' => 'MySQL', 'mariadb' => 'MariaDB', 'nginx' => 'nginx', 'apache' => 'Apache httpd', 'curl' => 'curl', 'imagemagick' => 'ImageMagick', 'redis' => 'Redis', 'memcached' => 'Memcached', 'sqlite' => 'SQLite', ); /** Wordfence Intelligence v3 scanner feed (licensed key required). */ const WF_ENDPOINT = 'https://www.wordfence.com/api/intelligence/v3/vulnerabilities/scanner'; /** * CVE lookup, via the CloudScale API rather than NVD directly. * * The plugin no longer contacts nvd.nist.gov. The proxy does, holding one keyed * credential and a shared 24-hour cache, see vuln-lookup.php in CloudScale-Wp-Proxy. * It returns NVD's own response body unchanged, so everything below parses the same * shape it always did. */ const PROXY_VULN_ENDPOINT = 'https://api.cloudscale.consulting/vuln-lookup'; /** Wordfence public advisory page, sprintf'd with the advisory id. */ const WF_DETAIL_URL = 'https://www.wordfence.com/threat-intel/vulnerabilities/id/%s'; /** Hard cap on network requests per lookup() call, independent of the time budget. */ /* * Both WPVulnerability and NVD cost one request per component, so this cap is * also a cap on how many components get checked at all. At 10 a site with more * plugins than that had the remainder reported as skipped, which was correct * but quiet. Raised to 30 because the requests go to the proxy, whose shared * cache absorbs them for every site at once, and because the wall-clock budget * below is the limit that should actually bite. Anything still not reached is * marked skipped and now says so in the report's coverage line. */ const MAX_REQUESTS = 30; /** Default wall-clock budget for a whole lookup() call, in seconds. */ const DEFAULT_BUDGET = 45; /** Refuse to decode a feed body larger than this (memory guard). */ const MAX_FEED_BYTES = 33554432; // 32 MB /** Descriptive UA sent on every request so feed operators can identify us. */ const USER_AGENT = 'CloudScale-Cyber-DevTools/vuln-feed (WordPress security scanner; +https://cloudscale.consulting)'; /** Unix timestamp of the last NVD request in this process. */ private static $last_nvd_request = 0; /** Network requests issued during the current lookup() call. */ private static $requests_made = 0; // ── Public API ──────────────────────────────────────────────────── /** * Which feed source is active, for display in the UI. * * @return array{ wordfence: bool, nvd: bool, nvd_key: bool, active: string, label: string } */ public static function is_configured(): array { $wf_key = (string) get_option( 'csdt_vuln_feed_wf_key', '' ); $has_wf = '' !== trim( $wf_key ); // nvd_key is retained in the returned shape, always false, because callers read // it. There is no longer a per-site NVD key to set: the proxy holds one keyed // credential for every site, which is the whole point of routing through it. return array( 'wordfence' => $has_wf, 'wpvulnerability' => true, // always available, no key needed 'nvd' => true, // always available, no key needed 'nvd_key' => false, // WPVulnerability is the default rather than NVD. Both are keyless and // both go through the same proxy, so this costs nothing and is simply // the better source for WordPress components. 'active' => $has_wf ? 'wordfence' : 'wpvulnerability', 'label' => $has_wf ? 'Wordfence Intelligence v3 (licensed key)' : 'WPVulnerability curated WordPress advisories, via the CloudScale API (shared cache)', ); } /** * Look up known CVEs for a list of installed components. * * Never throws. Never runs on a plain page load, the feed is rate limited * and slow by design, so it is only permitted inside WP-Cron, WP-CLI, the * background scan path (which has already flushed its response via * CSDT_AI_Dispatcher::send_and_continue()), or when the caller explicitly * defines CSDT_BACKGROUND_SCAN. * * @param array> $components List of * [ 'slug' => string, 'name' => string, 'version' => string, * 'type' => 'plugin'|'theme'|'core' ]. * @param int $budget Wall-clock budget in seconds. * @return array{ available: bool, reason?: string, source?: string, truncated?: bool, * requests?: int, components?: array> } */ public static function lookup( array $components, int $budget = self::DEFAULT_BUDGET ): array { if ( ! self::is_background_context() ) { return array( 'available' => false, 'reason' => 'CVE feed lookups only run in the background scan path (cron / WP-CLI / post-flush AJAX); refusing to block a page load.', ); } $components = self::normalise_components( $components ); if ( empty( $components ) ) { return array( 'available' => true, 'source' => self::is_configured()['active'], 'truncated' => false, 'requests' => 0, 'components' => array(), ); } $started = microtime( true ); $budget = max( 5, $budget ); self::$requests_made = 0; $cfg = self::is_configured(); $source = $cfg['active']; // Wordfence is one fetch for the whole dataset. If it is unavailable for // any reason (401 without a licensed key, 5xx, transport error) fall // through rather than reporting "no vulnerabilities" -- to WPVulnerability // now, which is the next-best WordPress-specific source, rather than // straight to a general CVE database. $wf_index = null; if ( 'wordfence' === $source ) { $wf_index = self::fetch_wordfence_index( $started, $budget ); if ( null === $wf_index ) { $source = 'wpvulnerability'; } } $out = array(); $truncated = false; foreach ( $components as $component ) { $cache_key = self::CACHE_PREFIX . md5( $component['slug'] . '|' . $component['version'] ); $cached = get_transient( $cache_key ); // Negative results are cached too (as an array with an empty // matched_cves list), so a cache hit is "is_array", not "is truthy". if ( is_array( $cached ) && ( $cached['source'] ?? '' ) === $source ) { $out[] = $cached['result']; continue; } if ( ( microtime( true ) - $started ) >= $budget || self::$requests_made >= self::MAX_REQUESTS ) { $truncated = true; $out[] = self::empty_result( $component, 'not checked, lookup budget exhausted' ); continue; } if ( self::is_server_type( $component['type'] ) ) { // Wordfence indexes WordPress components only, and NVD keyword search // is meaningless for a product name, so server software has exactly // one source. When it cannot answer, the component is reported as // unchecked, which is the honest reading and the one the coverage // line renders as such. $result = self::match_wpvuln( $component, $started, $budget ); if ( null === $result ) { $result = self::empty_result( $component, 'not checked, the server-software feed did not answer' ); } } elseif ( 'wordfence' === $source && is_array( $wf_index ) ) { $result = self::match_wordfence( $component, $wf_index ); } elseif ( 'wpvulnerability' === $source ) { // Per-component fall-through, not per-run: one slug failing upstream // must not downgrade the whole scan, and must not be reported as // "no vulnerabilities" either. Each matched_cves entry carries its // own 'source', so provenance stays accurate when the pair mixes. $result = self::match_wpvuln( $component, $started, $budget ); if ( null === $result ) { $result = self::match_nvd( $component, $started, $budget ); } } else { $result = self::match_nvd( $component, $started, $budget ); } $out[] = $result; // Only cache a result we actually completed; a budget/transport skip // must be retried on the next run, not remembered for 24h. if ( empty( $result['skipped'] ) ) { set_transient( $cache_key, array( 'source' => $source, 'result' => $result, ), self::CACHE_TTL ); } } return array( 'available' => true, 'source' => $source, 'truncated' => $truncated, 'requests' => self::$requests_made, 'components' => $out, ); } // ── Context + input handling ────────────────────────────────────── /** * True when it is safe to spend tens of seconds on feed requests. * * @return bool */ private static function is_background_context(): bool { if ( defined( 'CSDT_BACKGROUND_SCAN' ) && CSDT_BACKGROUND_SCAN ) { return true; } if ( defined( 'WP_CLI' ) && WP_CLI ) { return true; } if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) { return true; } // The background scan paths run inside an admin-ajax request that has // already sent its response and closed the connection, so nothing is // waiting on this. A synchronous admin page render is not ajax. if ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) { return true; } return false; } /** * Validate and normalise the caller's component list. * * @param array> $components Raw component list. * @return array> */ private static function normalise_components( array $components ): array { $out = array(); $seen = array(); $types = array_merge( array( 'plugin', 'theme', 'core' ), array_keys( self::SERVER_SOFTWARE ) ); foreach ( $components as $c ) { if ( ! is_array( $c ) ) { continue; } $slug = isset( $c['slug'] ) ? sanitize_key( (string) $c['slug'] ) : ''; $type = isset( $c['type'] ) ? strtolower( (string) $c['type'] ) : 'plugin'; if ( ! in_array( $type, $types, true ) ) { $type = 'plugin'; } if ( 'core' === $type && '' === $slug ) { $slug = 'wordpress'; } $version = isset( $c['version'] ) ? trim( (string) $c['version'] ) : ''; if ( self::is_server_type( $type ) ) { // The product IS the slug, and the feed wants a bare dotted number: // "10.11.4-MariaDB-log" and "8.2.10-1+ubuntu" are what the server // reports, "10.11.4" and "8.2.10" are what the feed understands. $slug = $type; $version = self::server_version( $version ); if ( ! isset( $c['name'] ) || '' === (string) $c['name'] ) { $c['name'] = self::SERVER_SOFTWARE[ $type ]; } } if ( '' === $slug || '.' === $slug || '' === $version ) { continue; } $key = $type . '|' . $slug . '|' . $version; if ( isset( $seen[ $key ] ) ) { continue; } $seen[ $key ] = true; $out[] = array( 'slug' => $slug, 'name' => isset( $c['name'] ) ? (string) $c['name'] : $slug, 'version' => $version, 'type' => $type, ); } return $out; } /** * A component result with no findings. * * @param array $component Normalised component. * @param string $note Optional explanation (set = skipped). * @return array */ private static function empty_result( array $component, string $note = '' ): array { $result = array( 'slug' => $component['slug'], 'name' => $component['name'], 'version' => $component['version'], 'type' => $component['type'], 'matched_cves' => array(), ); if ( '' !== $note ) { $result['skipped'] = true; $result['note'] = $note; } return $result; } // ── WPVulnerability (free, keyless, WordPress-specific) ─────────── /** * Look one component up in WPVulnerability, via the CloudScale proxy. * * Returns null when the lookup could not be completed, which the caller treats * as "try NVD for this component". Null is deliberately distinct from an empty * result: an empty result means the feed answered and knows of nothing, and only * one of those two is evidence. * * @param array $component Normalised component. * @param float $started Run start, microtime( true ). * @param int $budget Wall-clock budget in seconds. * @return array|null */ private static function match_wpvuln( array $component, float $started, int $budget ) { $type = $component['type']; $server = self::is_server_type( $type ); $source = $server ? 'wpv-software' : ( self::WPV_SOURCES[ $type ] ?? '' ); if ( '' === $source ) { return null; } if ( ( microtime( true ) - $started ) >= $budget || self::$requests_made >= self::MAX_REQUESTS ) { return null; } // Core is looked up by version, server software by product and version, // plugins and themes by slug. if ( $server ) { $query = 'product=' . rawurlencode( $type ) . '&version=' . rawurlencode( $component['version'] ); } elseif ( 'core' === $type ) { $query = 'version=' . rawurlencode( $component['version'] ); } else { $query = 'slug=' . rawurlencode( $component['slug'] ); } ++self::$requests_made; $resp = wp_remote_get( self::PROXY_VULN_ENDPOINT . '?source=' . $source . '&' . $query, array( 'timeout' => 8, 'sslverify' => true, 'user-agent' => self::USER_AGENT, ) ); $r = CSDT_Http::json( $resp ); $code = $r['code']; if ( 0 === $code ) { return null; } // 404 is a real answer from this API: the component is not in the database, // which is not the same as the request failing. Treat it as "nothing known" // so the caller does not spend a second request asking NVD the same thing. if ( 404 === $code ) { return self::empty_result( $component ); } if ( 200 !== $code ) { return null; } $data = $r['data']; if ( null === $data || ! isset( $data['data'] ) ) { return null; } $list = $data['data']['vulnerability'] ?? array(); if ( ! is_array( $list ) ) { return null; } $result = self::empty_result( $component ); if ( $server ) { $result['support'] = self::wpvuln_support( $data['data'] ); } foreach ( $list as $vuln ) { if ( ! is_array( $vuln ) ) { continue; } $verdict = self::wpvuln_verdict( $component, $vuln ); if ( 'out' === $verdict['state'] ) { continue; // range parsed, this version is not affected } $src = ( isset( $vuln['source'][0] ) && is_array( $vuln['source'][0] ) ) ? $vuln['source'][0] : array(); $cve_id = (string) ( $src['id'] ?? '' ); // The plugin feed carries a flat 'cvss' block; the server-software feed // carries 'cvss3' beside it, and sometimes only that. $score = $vuln['impact']['cvss']['score'] ?? ( $vuln['impact']['cvss3']['score'] ?? null ); $result['matched_cves'][] = array( 'cve_id' => '' !== $cve_id ? $cve_id : (string) ( $vuln['name'] ?? 'WPVulnerability advisory' ), 'cvss_score' => is_numeric( $score ) ? (float) $score : null, 'severity' => self::severity_from_score( $score ), 'summary' => self::trim_summary( (string) ( $vuln['name'] ?? ( $src['name'] ?? '' ) ) ), 'affected_versions' => $verdict['label'], 'fixed_in' => self::wpvuln_fixed_in( $vuln['operator'] ?? array() ), 'confidence' => $verdict['confidence'], 'confidence_reason' => $verdict['reason'], 'affected_component' => $component['slug'], 'source' => 'wpvulnerability', 'url' => self::wpvuln_url( $src ), // Known exploited in the wild (CISA KEV). Carried so a rule can rank // an actively exploited flaw above a merely high-scoring one. 'kev' => ! empty( $vuln['impact']['kev'] ), ); } // Worst first, THEN cap. A MySQL 8.0 branch answers with several hundred // advisories; keeping the first twenty in feed order would keep whichever // twenty happened to be listed first and drop a critical listed later. usort( $result['matched_cves'], static function ( array $a, array $b ): int { $cmp = ( $b['kev'] ? 1 : 0 ) <=> ( $a['kev'] ? 1 : 0 ); if ( 0 !== $cmp ) { return $cmp; } return (float) ( $b['cvss_score'] ?? 0 ) <=> (float) ( $a['cvss_score'] ?? 0 ); } ); $result['total_matched'] = count( $result['matched_cves'] ); if ( $result['total_matched'] > 20 ) { $result['matched_cves'] = array_slice( $result['matched_cves'], 0, 20 ); } return $result; } /** * Branch support facts from a server-software feed answer. * * The feed keys each product by branch ("php": "8.2") and states when that * branch stops receiving fixes ("date_end"). That is an end-of-life date from * the source that tracks it, so the audit no longer needs a hand-kept table * of PHP release dates that goes stale the moment a branch is added. * * @param array $data The feed's 'data' block. * @return array{branch:string,name:string,status:string,date_end:string,eol:?bool} */ private static function wpvuln_support( array $data ): array { $branch = ''; foreach ( self::SERVER_SOFTWARE as $product => $label ) { if ( isset( $data[ $product ] ) && is_scalar( $data[ $product ] ) ) { $branch = (string) $data[ $product ]; break; } } $end = (string) ( $data['date_end'] ?? '' ); $eol = null; if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $end ) ) { $ts = strtotime( $end . ' 23:59:59 UTC' ); $eol = false !== $ts ? ( $ts < time() ) : null; } return array( 'branch' => $branch, 'name' => (string) ( $data['name'] ?? '' ), 'status' => (string) ( $data['status'] ?? '' ), 'date_end' => $end, 'eol' => $eol, ); } /** * Whether a component type is server software rather than a WordPress component. * * @param string $type Component type. * @return bool */ public static function is_server_type( string $type ): bool { return isset( self::SERVER_SOFTWARE[ $type ] ); } /** * Reduce what a server reports about itself to the dotted number the feed wants. * * "10.11.4-MariaDB-log", "8.2.10-1+ubuntu22.04", "1.25.3" all become their leading * dotted number. A value with no such number ("nginx", a proxy that strips the * version) becomes '', and the caller reports the component as unverifiable. * * A "-N" patch level is KEPT when the N is a bare number: ImageMagick numbers * its releases 7.1.2-27, and the feed's fixed_in values carry that suffix, so * dropping it turned 7.1.2-27 into "7.1.2" and matched 166 advisories fixed * in 7.1.2-15. Found on the first live run. A "-0ubuntu0.22.04.2" package * revision is not a patch level and is still dropped: a patch level is a bare * number followed by a space or the end of the string, nothing else. * * @param string $raw Whatever the server reported. * @return string */ public static function server_version( string $raw ): string { return preg_match( '/(\d+(?:\.\d+){1,3}(?:-\d+(?=\s|$))?)/', $raw, $m ) ? $m[1] : ''; } /** * Decide whether a WPVulnerability advisory applies to the installed version. * * Routed through version_in_range() rather than a bespoke comparison so that * WPVulnerability findings carry the same three-state confidence as Wordfence * and NVD ones. The older helper in CSDT_Site_Audit returns a plain boolean and * treats an advisory with no bounds as AFFECTED, which is a false positive the * UI cannot distinguish from a confirmed one; here that becomes 'unknown' plus * 'unconfirmed', which the panel renders as "possibly affected". * * @param array $component Normalised component. * @param array $vuln One WPVulnerability advisory. * @return array{state:string,confidence:string,reason:string,label:string} */ private static function wpvuln_verdict( array $component, array $vuln ): array { // The core feed is fetched per version (/core//), so every entry it // returns already applies to the running version. Comparing ranges again // would only introduce a way to be wrong. if ( 'core' === $component['type'] ) { return array( 'state' => 'in', 'confidence' => 'confirmed', 'reason' => 'the WPVulnerability core feed is scoped to this exact WordPress version', 'label' => $component['version'], ); } $range = self::wpvuln_range( (array) ( $vuln['operator'] ?? array() ) ); $verdict = self::version_in_range( $component['version'], $range ); $verdict['label'] = $range['label']; return $verdict; } /** * Map a WPVulnerability operator block onto the shared range shape. * * @param array $op WPVulnerability 'operator' block. * @return array */ private static function wpvuln_range( array $op ): array { $min = isset( $op['min_version'] ) ? (string) $op['min_version'] : ''; $max = isset( $op['max_version'] ) ? (string) $op['max_version'] : ''; if ( '' === $min && '' === $max ) { return self::unparsed_range( 'the WPVulnerability advisory stated no affected version range' ); } $min_op = (string) ( $op['min_operator'] ?? '' ); $max_op = (string) ( $op['max_operator'] ?? '' ); $from = '' !== $min ? $min : null; $to = '' !== $max ? $max : null; // An "eq" bound with nothing on the other side names ONE version, not a // half-open range. Read as an upper bound only, "eq 2.4.49" would have // flagged every release before 2.4.49; the Apache and ImageMagick feeds // both publish entries in this shape. if ( 'eq' === $max_op && null === $from ) { $from = $to; $min_op = 'eq'; } elseif ( 'eq' === $min_op && null === $to ) { $to = $from; $max_op = 'eq'; } return array( 'from' => $from, 'from_inclusive' => 'ge' === $min_op || 'eq' === $min_op, 'to' => $to, 'to_inclusive' => 'le' === $max_op || 'eq' === $max_op, 'origin' => 'structured', 'label' => self::range_label( $from, 'ge' === $min_op || 'eq' === $min_op, $to, 'le' === $max_op || 'eq' === $max_op ), 'reason' => '', ); } /** * Version an advisory was fixed in, from its upper bound. * * @param array $op WPVulnerability 'operator' block. * @return string|null */ private static function wpvuln_fixed_in( array $op ): ?string { if ( ! empty( $op['max_version'] ) && in_array( $op['max_operator'] ?? '', array( 'lt', 'le' ), true ) ) { return (string) $op['max_version']; } return null; } /** * The link the feed itself supplied for an advisory, or '' when it supplied none. * * No advisory page address is built here: a finding without a supplied link shows its * CVE id as text, so the plugin names no site the administrator did not get from the feed. * * @param array $src Advisory source entry. * @return string */ private static function wpvuln_url( array $src ): string { $link = (string) ( $src['link'] ?? '' ); return 0 === strpos( $link, 'https://' ) ? $link : ''; } // ── Wordfence Intelligence v3 ───────────────────────────────────── /** * Fetch the Wordfence scanner feed once per run. * * @param float $started Run start time from microtime( true ). * @param int $budget Wall-clock budget in seconds. * @return array|null Decoded feed, or null when unavailable. */ private static function fetch_wordfence_index( float $started, int $budget ) { $key = trim( (string) get_option( 'csdt_vuln_feed_wf_key', '' ) ); if ( '' === $key ) { return null; } if ( ( microtime( true ) - $started ) >= $budget ) { return null; } ++self::$requests_made; $resp = wp_remote_get( self::WF_ENDPOINT . '?k=' . rawurlencode( $key ), array( 'timeout' => 25, 'sslverify' => true, 'user-agent' => self::USER_AGENT, 'headers' => array( 'Accept' => 'application/json' ), ) ); // Any non-200 (401 without a licensed key, 403, 5xx) means "unavailable", // never "no vulnerabilities", the caller falls through to NVD. $r = CSDT_Http::json( $resp, self::MAX_FEED_BYTES ); if ( 200 !== $r['code'] || empty( $r['data'] ) ) { return null; } return $r['data']; } /** * Match one component against the Wordfence feed (local, no network). * * @param array $component Normalised component. * @param array $index Decoded Wordfence feed. * @return array */ private static function match_wordfence( array $component, array $index ): array { $result = self::empty_result( $component ); foreach ( $index as $advisory_id => $advisory ) { if ( ! is_array( $advisory ) ) { continue; } $software = $advisory['software'] ?? array(); if ( ! is_array( $software ) ) { continue; } foreach ( $software as $sw ) { if ( ! is_array( $sw ) ) { continue; } if ( ! self::wf_software_matches( $sw, $component ) ) { continue; } // A Wordfence advisory can list several affected ranges for the // same slug; the component is affected if ANY of them matches, so // evaluate them all rather than trusting the first. $best = null; foreach ( self::wf_ranges( $sw ) as $candidate ) { $verdict = self::version_in_range( $component['version'], $candidate ); if ( 'in' === $verdict['state'] ) { $best = array( $candidate, $verdict ); break; } if ( null === $best || ( 'unknown' === $verdict['state'] && 'out' === $best[1]['state'] ) ) { $best = array( $candidate, $verdict ); } } if ( null === $best || 'out' === $best[1]['state'] ) { continue; // range parsed and this version is not affected } [ $range, $verdict ] = $best; $cve_id = isset( $advisory['cve'] ) ? (string) $advisory['cve'] : ''; $score = $advisory['cvss']['score'] ?? null; $rating = $advisory['cvss']['rating'] ?? null; $result['matched_cves'][] = array( 'cve_id' => '' !== $cve_id ? $cve_id : (string) $advisory_id, 'cvss_score' => is_numeric( $score ) ? (float) $score : null, 'severity' => is_string( $rating ) && '' !== $rating ? strtolower( $rating ) : self::severity_from_score( $score ), 'summary' => self::trim_summary( (string) ( $advisory['title'] ?? $advisory['description'] ?? '' ) ), 'affected_versions' => $range['label'], 'fixed_in' => isset( $sw['patched_versions'][0] ) ? (string) $sw['patched_versions'][0] : null, 'confidence' => $verdict['confidence'], 'confidence_reason' => $verdict['reason'], 'affected_component' => $component['slug'], 'source' => 'wordfence', 'url' => self::wf_url( $advisory, (string) $advisory_id ), ); if ( count( $result['matched_cves'] ) >= 20 ) { return $result; } break; // one finding per advisory } } return $result; } /** * Does a Wordfence software entry describe this component? * * @param array $sw Wordfence software entry. * @param array $component Normalised component. * @return bool */ private static function wf_software_matches( array $sw, array $component ): bool { $type = strtolower( (string) ( $sw['type'] ?? '' ) ); if ( 'core' === $component['type'] ) { return 'core' === $type; } if ( $type !== $component['type'] ) { return false; } return sanitize_key( (string) ( $sw['slug'] ?? '' ) ) === $component['slug']; } /** * Extract every affected-version range from a Wordfence software entry. * * Wordfence keys each range by a human label ("* - 1.9.2") and an advisory * can carry more than one. * * @param array $sw Wordfence software entry. * @return array> Always at least one range. */ private static function wf_ranges( array $sw ): array { $versions = $sw['affected_versions'] ?? array(); if ( ! is_array( $versions ) || empty( $versions ) ) { return array( self::unparsed_range( 'Wordfence advisory carried no affected_versions block' ) ); } $out = array(); foreach ( $versions as $label => $range ) { if ( ! is_array( $range ) ) { continue; } $from = (string) ( $range['from_version'] ?? '' ); $to = (string) ( $range['to_version'] ?? '' ); $from = ( '' === $from || '*' === $from ) ? null : $from; $to = ( '' === $to || '*' === $to ) ? null : $to; $from_inclusive = (bool) ( $range['from_inclusive'] ?? true ); $to_inclusive = (bool) ( $range['to_inclusive'] ?? true ); $out[] = array( 'from' => $from, 'from_inclusive' => $from_inclusive, 'to' => $to, 'to_inclusive' => $to_inclusive, 'origin' => 'structured', 'label' => ( is_string( $label ) && '' !== $label ) ? $label : self::range_label( $from, $from_inclusive, $to, $to_inclusive ), 'reason' => '', ); } if ( empty( $out ) ) { return array( self::unparsed_range( 'Wordfence affected_versions block was not in the expected shape' ) ); } return $out; } /** * Canonical URL for a Wordfence advisory. * * @param array $advisory Advisory record. * @param string $advisory_id Feed key. * @return string */ private static function wf_url( array $advisory, string $advisory_id ): string { $refs = $advisory['references'] ?? array(); if ( is_array( $refs ) ) { foreach ( $refs as $ref ) { $url = is_array( $ref ) ? ( $ref['url'] ?? '' ) : $ref; if ( is_string( $url ) && '' !== $url ) { return esc_url_raw( $url ); } } } $id = (string) ( $advisory['id'] ?? $advisory_id ); return esc_url_raw( sprintf( self::WF_DETAIL_URL, rawurlencode( $id ) ) ); } // ── NVD CVE API 2.0 ─────────────────────────────────────────────── /** * Query NVD for one component (one rate-limited request). * * @param array $component Normalised component. * @param float $started Run start time from microtime( true ). * @param int $budget Wall-clock budget in seconds. * @return array */ private static function match_nvd( array $component, float $started, int $budget ): array { $keyword = 'core' === $component['type'] ? 'wordpress core' : 'wordpress ' . str_replace( '-', ' ', $component['slug'] ); if ( ( microtime( true ) - $started ) >= $budget ) { return self::empty_result( $component, 'not checked, the lookup budget was already spent' ); } // Asked via the CloudScale API rather than NVD directly. The proxy holds one // keyed NVD credential (50 requests/30s instead of the keyless 5) and caches // each answer for a day, so the per-site 7-second spacing this used to need is // gone, and the plugin contacts one documented service instead of NIST. // The response body is NVD's own, unchanged, so the parsing below is untouched. $url = self::PROXY_VULN_ENDPOINT . '?source=nvd&keyword=' . rawurlencode( $keyword ); $args = array( 'timeout' => 20, 'sslverify' => true, 'user-agent' => self::USER_AGENT, 'headers' => array( 'Accept' => 'application/json' ), ); ++self::$requests_made; self::$last_nvd_request = time(); $resp = wp_remote_get( $url, $args ); $r = CSDT_Http::json( $resp ); if ( 0 === $r['code'] ) { return self::empty_result( $component, 'CVE lookup failed: ' . $r['error'] ); } $code = $r['code']; if ( 200 !== $code ) { // 429 (our own ceiling) or 5xx (upstream down) means unavailable, not "clean". return self::empty_result( $component, 'CVE lookup returned HTTP ' . $code . ' (treated as unavailable, not as "no CVEs")' ); } $data = $r['data']; if ( null === $data || ! isset( $data['vulnerabilities'] ) || ! is_array( $data['vulnerabilities'] ) ) { return self::empty_result( $component, 'NVD response shape was unexpected; the API may have changed' ); } $result = self::empty_result( $component ); foreach ( $data['vulnerabilities'] as $entry ) { $cve = is_array( $entry ) ? ( $entry['cve'] ?? null ) : null; if ( ! is_array( $cve ) ) { continue; } $finding = self::nvd_finding( $cve, $component ); if ( null !== $finding ) { $result['matched_cves'][] = $finding; } if ( count( $result['matched_cves'] ) >= 20 ) { break; } } return $result; } /** * Turn one NVD CVE record into a finding, or null when it does not apply. * * @param array $cve Decoded .vulnerabilities[].cve object. * @param array $component Normalised component. * @return array|null */ private static function nvd_finding( array $cve, array $component ) { $cve_id = isset( $cve['id'] ) ? (string) $cve['id'] : ''; if ( '' === $cve_id ) { return null; } $description = ''; if ( isset( $cve['descriptions'] ) && is_array( $cve['descriptions'] ) ) { foreach ( $cve['descriptions'] as $d ) { if ( is_array( $d ) && ( 'en' === ( $d['lang'] ?? 'en' ) ) && ! empty( $d['value'] ) ) { $description = (string) $d['value']; break; } } } // keywordSearch is a full-text match, so it happily returns advisories for // unrelated software. Refuse to attribute a CVE to this component unless // the component is actually named in the record. A miss is much better // than telling an admin they are vulnerable to someone else's CVE. if ( ! self::nvd_mentions_component( $cve, $description, $component ) ) { return null; } $score = null; $severity = ''; foreach ( array( 'cvssMetricV31', 'cvssMetricV30', 'cvssMetricV2' ) as $metric_key ) { $metric = $cve['metrics'][ $metric_key ][0] ?? null; if ( is_array( $metric ) && isset( $metric['cvssData']['baseScore'] ) ) { $score = (float) $metric['cvssData']['baseScore']; $severity = strtolower( (string) ( $metric['cvssData']['baseSeverity'] ?? $metric['baseSeverity'] ?? '' ) ); break; } } if ( '' === $severity ) { $severity = self::severity_from_score( $score ); } $range = self::nvd_range( $cve, $description, $component ); $verdict = self::version_in_range( $component['version'], $range ); if ( 'out' === $verdict['state'] ) { return null; // range parsed and this version is outside it } return array( 'cve_id' => $cve_id, 'cvss_score' => $score, 'severity' => $severity, 'summary' => self::trim_summary( $description ), 'affected_versions' => $range['label'], 'fixed_in' => ( null !== $range['to'] && empty( $range['to_inclusive'] ) ) ? $range['to'] : null, 'confidence' => $verdict['confidence'], 'confidence_reason' => $verdict['reason'], 'affected_component' => $component['slug'], 'source' => 'nvd', 'url' => '', ); } /** * Is this component actually named in the CVE record? * * @param array $cve Decoded CVE object. * @param string $description English description. * @param array $component Normalised component. * @return bool */ private static function nvd_mentions_component( array $cve, string $description, array $component ): bool { if ( 'core' === $component['type'] ) { // Core keyword search: require a WordPress core CPE, otherwise the // record is about a plugin/theme that merely mentions WordPress. foreach ( self::nvd_cpe_matches( $cve ) as $match ) { if ( false !== strpos( (string) ( $match['criteria'] ?? '' ), ':wordpress:wordpress:' ) ) { return true; } } return false; } $slug = $component['slug']; $spaced = str_replace( '-', ' ', $slug ); foreach ( self::nvd_cpe_matches( $cve ) as $match ) { $criteria = strtolower( (string) ( $match['criteria'] ?? '' ) ); if ( '' !== $criteria && ( false !== strpos( $criteria, ':' . $slug . ':' ) || false !== strpos( $criteria, ':' . str_replace( '-', '_', $slug ) . ':' ) ) ) { return true; } } $haystack = strtolower( $description ); if ( '' === $haystack ) { return false; } return false !== strpos( $haystack, $slug ) || false !== strpos( $haystack, $spaced ); } /** * Flatten every cpeMatch in a CVE's configurations. * * @param array $cve Decoded CVE object. * @return array> */ private static function nvd_cpe_matches( array $cve ): array { $out = array(); $cfg = $cve['configurations'] ?? array(); if ( ! is_array( $cfg ) ) { return $out; } foreach ( $cfg as $configuration ) { $nodes = is_array( $configuration ) ? ( $configuration['nodes'] ?? array() ) : array(); if ( ! is_array( $nodes ) ) { continue; } foreach ( $nodes as $node ) { $matches = is_array( $node ) ? ( $node['cpeMatch'] ?? array() ) : array(); if ( ! is_array( $matches ) ) { continue; } foreach ( $matches as $match ) { if ( is_array( $match ) ) { $out[] = $match; } } } } return $out; } /** * Best available affected-version range for an NVD record. * * Structured CPE version bounds are preferred; the description text is only * used as a fallback and is reported at lower confidence. * * @param array $cve Decoded CVE object. * @param string $description English description. * @param array $component Normalised component. * @return array */ private static function nvd_range( array $cve, string $description, array $component ): array { $slug = $component['slug']; foreach ( self::nvd_cpe_matches( $cve ) as $match ) { $criteria = strtolower( (string) ( $match['criteria'] ?? '' ) ); $relevant = 'core' === $component['type'] ? ( false !== strpos( $criteria, ':wordpress:wordpress:' ) ) : ( false !== strpos( $criteria, ':' . $slug . ':' ) || false !== strpos( $criteria, ':' . str_replace( '-', '_', $slug ) . ':' ) ); if ( ! $relevant ) { continue; } $start_incl = $match['versionStartIncluding'] ?? null; $start_excl = $match['versionStartExcluding'] ?? null; $end_incl = $match['versionEndIncluding'] ?? null; $end_excl = $match['versionEndExcluding'] ?? null; if ( null !== $start_incl || null !== $start_excl || null !== $end_incl || null !== $end_excl ) { $from = null !== $start_incl ? (string) $start_incl : ( null !== $start_excl ? (string) $start_excl : null ); $to = null !== $end_incl ? (string) $end_incl : ( null !== $end_excl ? (string) $end_excl : null ); return array( 'from' => $from, 'from_inclusive' => ( null !== $start_incl ), 'to' => $to, 'to_inclusive' => ( null !== $end_incl ), 'origin' => 'structured', 'label' => self::range_label( $from, ( null !== $start_incl ), $to, ( null !== $end_incl ) ), 'reason' => '', ); } // An exact pinned version in the CPE string, e.g. ...:1.9.2:*:... $parts = explode( ':', $criteria ); $exact = $parts[5] ?? '*'; if ( '*' !== $exact && '-' !== $exact && '' !== $exact ) { return array( 'from' => $exact, 'from_inclusive' => true, 'to' => $exact, 'to_inclusive' => true, 'origin' => 'structured', 'label' => $exact, 'reason' => '', ); } } return self::range_from_text( $description ); } /** * Parse an affected range out of advisory prose. * * Deliberately narrow: only the handful of phrasings NVD/Wordfence * descriptions actually use. Anything else is reported as unparseable * rather than guessed at. * * @param string $text Advisory description. * @return array */ private static function range_from_text( string $text ): array { $v = '([0-9]+(?:\.[0-9]+)*(?:[-.][0-9A-Za-z]+)?)'; // "versions 1.2 through 1.9", "from 1.2 to 1.9" if ( preg_match( '/(?:versions?|from)\s+' . $v . '\s+(?:through|to|up to and including|\x{2013}|-)\s+' . $v . '/iu', $text, $m ) ) { return self::text_range( $m[1], true, $m[2], true, 'range "' . $m[1] . ' - ' . $m[2] . '" read from the advisory text' ); } // "all versions up to, and including, 1.9" if ( preg_match( '/up to,?\s*and including,?\s+' . $v . '/i', $text, $m ) ) { return self::text_range( null, true, $m[1], true, 'upper bound "<= ' . $m[1] . '" read from the advisory text' ); } // "through 1.9", "1.9 and earlier/below/prior" if ( preg_match( '/\bthrough\s+' . $v . '/i', $text, $m ) || preg_match( '/\b' . $v . '\s+and\s+(?:earlier|below|prior|older)/i', $text, $m ) ) { return self::text_range( null, true, $m[1], true, 'upper bound "<= ' . $m[1] . '" read from the advisory text' ); } // "before 1.9", "prior to 1.9", "earlier than 1.9" if ( preg_match( '/\b(?:before|prior to|earlier than|less than)\s+(?:version\s+)?' . $v . '/i', $text, $m ) ) { return self::text_range( null, true, $m[1], false, 'upper bound "< ' . $m[1] . '" read from the advisory text' ); } return self::unparsed_range( 'no affected version range could be parsed from the advisory (no structured CPE bounds and no recognised phrasing in the description)' ); } /** * Build a description-derived range. * * @param string|null $from Lower bound. * @param bool $from_inclusive Lower bound inclusive. * @param string|null $to Upper bound. * @param bool $to_inclusive Upper bound inclusive. * @param string $reason Human explanation of where it came from. * @return array */ private static function text_range( $from, bool $from_inclusive, $to, bool $to_inclusive, string $reason ): array { return array( 'from' => $from, 'from_inclusive' => $from_inclusive, 'to' => $to, 'to_inclusive' => $to_inclusive, 'origin' => 'description', 'label' => self::range_label( $from, $from_inclusive, $to, $to_inclusive ), 'reason' => $reason, ); } /** * A range we could not parse at all. * * @param string $reason Why it could not be parsed. * @return array */ private static function unparsed_range( string $reason ): array { return array( 'from' => null, 'from_inclusive' => true, 'to' => null, 'to_inclusive' => true, 'origin' => 'none', 'label' => 'unknown', 'reason' => $reason, ); } // ── Version matching ────────────────────────────────────────────── /** * Decide whether an installed version falls inside an affected range. * * Three outcomes, and the confidence is deliberately conservative: * - 'in' + confidence 'confirmed' , structured CPE / Wordfence bounds matched. * - 'in' + confidence 'likely' , bounds were read from advisory prose. * - 'out' , bounds parsed, this version is unaffected (caller drops it). * - 'unknown' + 'unconfirmed' , no bounds parseable; reported with the reason so the * UI/AI can say "possibly affected" instead of "vulnerable". * * @param string $version Installed version. * @param array $range Range from wf_range()/nvd_range(). * @return array{ state: string, confidence: string, reason: string } */ private static function version_in_range( string $version, array $range ): array { $clean = self::normalise_version( $version ); if ( '' === $clean ) { return array( 'state' => 'unknown', 'confidence' => 'unconfirmed', 'reason' => 'installed version "' . $version . '" is not comparable, so the affected range could not be applied', ); } if ( 'none' === $range['origin'] || ( null === $range['from'] && null === $range['to'] ) ) { return array( 'state' => 'unknown', 'confidence' => 'unconfirmed', 'reason' => '' !== $range['reason'] ? $range['reason'] : 'the advisory did not state an affected version range', ); } if ( null !== $range['from'] ) { $from = self::normalise_version( (string) $range['from'] ); if ( '' === $from ) { return array( 'state' => 'unknown', 'confidence' => 'unconfirmed', 'reason' => 'lower bound "' . $range['from'] . '" is not comparable', ); } $op = $range['from_inclusive'] ? '<' : '<='; if ( version_compare( $clean, $from, $op ) ) { return array( 'state' => 'out', 'confidence' => 'confirmed', 'reason' => 'installed version is below the affected range', ); } } if ( null !== $range['to'] ) { $to = self::normalise_version( (string) $range['to'] ); if ( '' === $to ) { return array( 'state' => 'unknown', 'confidence' => 'unconfirmed', 'reason' => 'upper bound "' . $range['to'] . '" is not comparable', ); } $op = $range['to_inclusive'] ? '>' : '>='; if ( version_compare( $clean, $to, $op ) ) { return array( 'state' => 'out', 'confidence' => 'confirmed', 'reason' => 'installed version is above the affected range', ); } } if ( 'structured' === $range['origin'] ) { return array( 'state' => 'in', 'confidence' => 'confirmed', 'reason' => 'installed version ' . $version . ' falls inside the advisory\'s published affected range (' . $range['label'] . ')', ); } return array( 'state' => 'in', 'confidence' => 'likely', 'reason' => 'installed version ' . $version . ' falls inside a range inferred from the advisory description (' . $range['label'] . '); no structured version data was published', ); } /** * Reduce a version string to something version_compare() handles sanely. * * @param string $version Raw version. * @return string Empty when nothing comparable could be extracted. */ private static function normalise_version( string $version ): string { $version = trim( $version ); if ( '' === $version || '*' === $version ) { return ''; } // Strip a leading "v" and anything after the first space ("1.2.3 (beta)"). $version = preg_replace( '/^[vV]/', '', $version ); $version = (string) preg_replace( '/\s.*$/', '', (string) $version ); if ( ! preg_match( '/^[0-9]/', $version ) ) { return ''; } return $version; } // ── Small helpers ───────────────────────────────────────────────── /** * Human label for a version range. * * @param string|null $from Lower bound. * @param bool $from_inclusive Lower bound inclusive. * @param string|null $to Upper bound. * @param bool $to_inclusive Upper bound inclusive. * @return string */ private static function range_label( $from, bool $from_inclusive, $to, bool $to_inclusive ): string { if ( null !== $from && null !== $to ) { if ( $from === $to ) { return (string) $from; } return ( $from_inclusive ? '>= ' : '> ' ) . $from . ', ' . ( $to_inclusive ? '<= ' : '< ' ) . $to; } if ( null !== $to ) { return ( $to_inclusive ? '<= ' : '< ' ) . $to; } if ( null !== $from ) { return ( $from_inclusive ? '>= ' : '> ' ) . $from; } return 'unknown'; } /** * CVSS base score to the usual severity band. * * @param mixed $score Numeric score or null. * @return string */ private static function severity_from_score( $score ): string { if ( ! is_numeric( $score ) ) { return 'unknown'; } $score = (float) $score; if ( $score >= 9.0 ) { return 'critical'; } if ( $score >= 7.0 ) { return 'high'; } if ( $score >= 4.0 ) { return 'medium'; } if ( $score > 0 ) { return 'low'; } return 'none'; } /** * Collapse whitespace and cap an advisory summary length. * * @param string $text Raw summary. * @return string */ private static function trim_summary( string $text ): string { $text = trim( (string) preg_replace( '/\s+/', ' ', $text ) ); if ( strlen( $text ) > 600 ) { $text = substr( $text, 0, 597 ) . '...'; } return $text; } }