$data ) { $slug = dirname( $file ); $slug = ( '.' === $slug ) ? basename( $file, '.php' ) : $slug; $hit = self::lookup( 'plugin', $slug, $data['Version'], $data['Name'] ); if ( $hit ) { $items[] = $hit; } } // Themes. foreach ( wp_get_themes() as $slug => $theme ) { $hit = self::lookup( 'theme', $slug, $theme->get( 'Version' ), $theme->get( 'Name' ) ); if ( $hit ) { $items[] = $hit; } } $count = array_sum( array_map( static function ( $i ) { return count( $i['vulnerabilities'] ); }, $items ) ); return [ 'scanned_at' => current_time( 'mysql' ), 'items' => $items, 'count' => $count, 'status' => $count > 0 ? 'vulnerable' : 'clean', ]; } public static function run_and_store() { $result = self::scan(); \GemSecurity\OptionStore::set_state( 'vuln_scan', $result ); if ( $result['count'] > 0 ) { \GemSecurity\Logger::log( 'vuln_found', sprintf( 'Vulnerability scan found %d known issue(s) across %d item(s).', $result['count'], count( $result['items'] ) ), \GemSecurity\Logger::SEVERITY_CRITICAL ); } return $result; } public static function get_last_result() { $r = \GemSecurity\OptionStore::get_state( 'vuln_scan', null ); return is_array( $r ) ? $r : null; } /** * Look up one item; returns a finding array if the installed version is * affected, else null. * * @param string $type core|plugin|theme * @param string $slug * @param string $version Installed version. * @param string $name Display name. * @return array|null */ private static function lookup( $type, $slug, $version, $name ) { if ( '' === $slug || '' === $version ) { return null; } $cache_key = self::CACHE_PREFIX . md5( $type . '|' . $slug ); $entries = get_transient( $cache_key ); if ( false === $entries ) { $entries = self::fetch( $type, $slug ); // Cache even an empty array so a clean item isn't re-fetched all day. set_transient( $cache_key, is_array( $entries ) ? $entries : [], self::CACHE_TTL ); } if ( empty( $entries ) ) { return null; } $matched = []; foreach ( $entries as $vuln ) { if ( self::affects_version( $vuln, $version ) ) { $matched[] = self::format( $vuln ); } } if ( empty( $matched ) ) { return null; } return [ 'type' => $type, 'name' => $name, 'slug' => $slug, 'version' => $version, 'vulnerabilities' => $matched, ]; } /** * Fetch the raw vulnerability list for an item from the feed. * * @return array */ private static function fetch( $type, $slug ) { $url = trailingslashit( self::api_base() ) . $type . '/' . rawurlencode( $slug ) . '/'; $response = wp_remote_get( $url, [ 'timeout' => 8, 'headers' => [ 'Accept' => 'application/json' ] ] ); if ( is_wp_error( $response ) ) { return false; // signal "not cached, try later" } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 404 === $code ) { return []; // No record → not vulnerable. } if ( 200 !== $code ) { return false; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $body ) ) { return []; } // wpvulnerability shape: data.vulnerability[] (be defensive about variants). $list = $body['data']['vulnerability'] ?? ( $body['vulnerability'] ?? ( $body['vulnerabilities'] ?? [] ) ); return is_array( $list ) ? $list : []; } /** * Decide whether an installed version is affected by a vulnerability entry. * Best-effort and defensive across schema variations. * * @param array $vuln * @param string $version * @return bool */ private static function affects_version( $vuln, $version ) { $op = $vuln['operator'] ?? null; if ( ! is_array( $op ) ) { // No range info — report it (better to over-warn than miss). return true; } $map = [ 'lt' => '<', 'le' => '<=', 'gt' => '>', 'ge' => '>=', 'eq' => '=', 'ne' => '!=' ]; $ok = true; if ( ! empty( $op['min_version'] ) ) { $cmp = $map[ $op['min_operator'] ?? 'ge' ] ?? '>='; $ok = $ok && version_compare( $version, $op['min_version'], $cmp ); } if ( ! empty( $op['max_version'] ) ) { $cmp = $map[ $op['max_operator'] ?? 'le' ] ?? '<='; $ok = $ok && version_compare( $version, $op['max_version'], $cmp ); } return $ok; } /** * Normalise a feed entry for display. * * @param array $vuln * @return array */ private static function format( $vuln ) { $impact = $vuln['impact'] ?? []; // Severity: prefer the full word in cvss3, else expand the abbreviated // cvss code, else derive from the score. $sev_map = [ 'c' => 'critical', 'h' => 'high', 'm' => 'medium', 'l' => 'low', 'n' => 'none' ]; $severity = strtolower( $impact['cvss3']['severity'] ?? '' ); if ( ! in_array( $severity, [ 'critical', 'high', 'medium', 'low', 'none' ], true ) ) { $abbr = strtolower( $impact['cvss']['severity'] ?? '' ); $severity = $sev_map[ $abbr ] ?? ''; } $score = isset( $impact['cvss']['score'] ) ? (float) $impact['cvss']['score'] : 0.0; if ( '' === $severity && $score > 0 ) { $severity = $score >= 9 ? 'critical' : ( $score >= 7 ? 'high' : ( $score >= 4 ? 'medium' : 'low' ) ); } // CVE ids from the source list. $cves = []; foreach ( (array) ( $vuln['source'] ?? [] ) as $src ) { if ( ! empty( $src['id'] ) && 0 === stripos( $src['id'], 'CVE-' ) ) { $cves[] = $src['id']; } } // Title: the entry name unless it's just a bare version (core), in which // case use the weakness type (CWE) name. $title = trim( (string) ( $vuln['name'] ?? '' ) ); $cwe = $vuln['impact']['cwe'][0]['name'] ?? ''; if ( '' === $title || preg_match( '/^[\d.\s]+$/', $title ) ) { $title = $cwe ?: __( 'Known vulnerability', 'gemsecurity' ); } $fixed = ''; if ( ! empty( $vuln['operator']['max_version'] ) && in_array( $vuln['operator']['max_operator'] ?? 'le', [ 'lt', 'le' ], true ) ) { $fixed = $vuln['operator']['max_version']; } return [ 'title' => $title, 'cve' => implode( ', ', array_unique( $cves ) ), 'severity' => $severity ?: 'medium', 'score' => $score ?: null, 'link' => $vuln['source'][0]['link'] ?? '', 'fixed_in' => $fixed, ]; } }